Basics working
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
dev-music
|
||||
dist
|
||||
__pycache__
|
||||
.cursor
|
||||
313
beetsplug/beets_music_videos/__init__.py
Normal file
313
beetsplug/beets_music_videos/__init__.py
Normal file
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from beets import logging, plugins, util
|
||||
from beets.autotag.hooks import TrackInfo
|
||||
from beets.library import Item
|
||||
from beets.metadata_plugins import SearchApiMetadataSourcePlugin
|
||||
|
||||
from typing import Annotated, Any, Literal, Sequence
|
||||
from typing import Self
|
||||
|
||||
from beets.autotag.hooks import AlbumInfo
|
||||
from beets.metadata_plugins import SearchFilter
|
||||
|
||||
from .types import SearchResponseType, VideoType
|
||||
from httpx_auth import HeaderApiKey
|
||||
from lapidary.runtime import Body, ClientBase, get, Query, Response, Responses, UnexpectedResponse
|
||||
|
||||
log = logging.getLogger("beets")
|
||||
|
||||
|
||||
# class IMVDBApi:
|
||||
# def __init__(self, api_key: str) -> None:
|
||||
# self.api_key = api_key
|
||||
# self.api_url = "https://imvdb.com/api/v1"
|
||||
|
||||
# def _base_call(self, endpoint: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
# response = requests.get(
|
||||
# f"{self.api_url}/{endpoint}",
|
||||
# params={**params, "api_key": self.api_key},
|
||||
# )
|
||||
# return response.json()
|
||||
|
||||
# def search(self, query: str) -> list[VideoType]:
|
||||
# response: SearchResponseType = self._base_call("search", params={"q": query})
|
||||
# return response["results"]
|
||||
|
||||
|
||||
class IMVDBApi(ClientBase):
|
||||
"""Lapidary-based IMVDB client. Use api_key in security; do not hardcode."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = "https://imvdb.com/api/v1",
|
||||
**kwargs: Any,
|
||||
) -> None: # type: ignore[override, assignment]
|
||||
# Some APIs return 500/502 for non-browser User-Agent; send a browser-like one.
|
||||
headers = dict(kwargs.pop("headers", {}))
|
||||
headers.setdefault(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
)
|
||||
super().__init__(
|
||||
base_url=base_url,
|
||||
security=[{"api_key": [api_key]}],
|
||||
headers=headers,
|
||||
**kwargs,
|
||||
)
|
||||
# Send API key as header IMVDB-APP-KEY on every request.
|
||||
self.lapidary_authenticate(
|
||||
api_key=HeaderApiKey(api_key, header_name="IMVDB-APP-KEY"),
|
||||
)
|
||||
|
||||
@get("/search/videos") # type: ignore[misc]
|
||||
async def video_search(
|
||||
self: Self,
|
||||
q: Annotated[str, Query()],
|
||||
) -> Annotated[
|
||||
SearchResponseType,
|
||||
Responses(
|
||||
responses={
|
||||
"2xx": Response(body=Body({"application/json": SearchResponseType})),
|
||||
}
|
||||
),
|
||||
]:
|
||||
"""Search IMVDB; returns response with .results (list of VideoType)."""
|
||||
pass
|
||||
# return cast(SearchResponseType, {}) # unreachable; lapidary implements this
|
||||
|
||||
def sync_video_search(self, query: str) -> list[VideoType]:
|
||||
"""Sync wrapper used by the plugin. Runs video_search and returns .results."""
|
||||
import asyncio
|
||||
# Reuse a single event loop to avoid "Event loop is closed" on subsequent calls.
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
resp = loop.run_until_complete(self.video_search(q=query))
|
||||
return resp.get("results", [])
|
||||
except UnexpectedResponse as e:
|
||||
# Lapidary raises UnexpectedResponse when body validation fails (e.g. API shape differs from SearchResponseType). For 200, parse body and return results.
|
||||
if e.response.status_code == 200:
|
||||
try:
|
||||
body = e.response.json()
|
||||
return body.get("results", []) if isinstance(body, dict) else []
|
||||
except Exception:
|
||||
pass
|
||||
log.debug("imvdb search failed for %r: %s", query[:50], e)
|
||||
return []
|
||||
except Exception as e:
|
||||
log.debug("imvdb search failed for %r: %s", query[:50], e)
|
||||
return []
|
||||
|
||||
|
||||
class BeetsMusicVideos(SearchApiMetadataSourcePlugin[VideoType]): # type: ignore[type-var]
|
||||
"""Plugin that lets beets import "non-audio" files (e.g. music videos)
|
||||
by treating selected extensions as supported and creating library
|
||||
`Item`s for them without going through `MediaFile`.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None: # type: ignore[override, assignment]
|
||||
super().__init__()
|
||||
|
||||
# Default set of extensions to treat as importable media, in
|
||||
# addition to the formats supported by `mediafile`.
|
||||
self.config.add(
|
||||
{
|
||||
"extensions": [
|
||||
".mp4",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".avi",
|
||||
".webm",
|
||||
],
|
||||
"imvdb_api_key": "",
|
||||
}
|
||||
)
|
||||
|
||||
self.imvdb_api_key = self.config["imvdb_api_key"].get(str)
|
||||
|
||||
self._patch_import_task_factory()
|
||||
|
||||
self.imvdb_api = IMVDBApi(self.imvdb_api_key)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalized_extensions(self) -> set[str]:
|
||||
"""Return the configured extensions as a normalized set.
|
||||
|
||||
All values are lowercased and guaranteed to start with a leading
|
||||
dot, e.g. ``.mp4``.
|
||||
"""
|
||||
exts: set[str] = set()
|
||||
for raw in self.config["extensions"].as_str_seq():
|
||||
raw = raw.strip().lower()
|
||||
if not raw:
|
||||
continue
|
||||
if not raw.startswith("."):
|
||||
raw = f".{raw}"
|
||||
exts.add(raw)
|
||||
return exts
|
||||
|
||||
def _patch_import_task_factory(self) -> None:
|
||||
"""Monkey-patch `ImportTaskFactory.read_item` so that paths with
|
||||
one of our configured extensions are turned into `Item`s even if
|
||||
`mediafile` does not support them. Also patch import tasks to
|
||||
skip writing tags for music videos and to avoid deleting originals
|
||||
when they are already inside the library directory.
|
||||
"""
|
||||
# Local import to avoid importing importer machinery unless needed.
|
||||
from beets.importer.tasks import ( # type: ignore[attr-defined]
|
||||
Action,
|
||||
ImportTask,
|
||||
ImportTaskFactory,
|
||||
)
|
||||
|
||||
# Only patch once, even if the plugin is re-instantiated.
|
||||
if getattr(ImportTaskFactory, "_beets_music_videos_patched", False):
|
||||
return
|
||||
|
||||
original_read_item = ImportTaskFactory.read_item
|
||||
plugin = self
|
||||
|
||||
def read_item_with_videos(
|
||||
self_: "ImportTaskFactory", path: util.PathBytes
|
||||
) -> Item | None: # type: ignore[override]
|
||||
# Determine the file extension of this path.
|
||||
str_path = util.syspath(path)
|
||||
ext = os.path.splitext(str_path)[1].lower()
|
||||
|
||||
if ext in plugin._normalized_extensions():
|
||||
# Create an Item without going through MediaFile.
|
||||
# We deliberately avoid calling Item.from_path() or
|
||||
# Item.read() because those rely on MediaFile and would
|
||||
# reject unsupported formats.
|
||||
item = Item(album_id=None)
|
||||
|
||||
# Store a normalized bytes path, as beets expects.
|
||||
item.path = util.normpath(path)
|
||||
|
||||
# Initialize mtime from the actual file.
|
||||
try:
|
||||
item.mtime = item.current_mtime()
|
||||
except OSError:
|
||||
# If we cannot stat the file for some reason, fall
|
||||
# back to 0; the file is still importable.
|
||||
item.mtime = 0
|
||||
|
||||
# Derive a simple title from the filename if none is set.
|
||||
# This mirrors what many users would expect for
|
||||
# path-based imports.
|
||||
filename = Path(str_path).stem
|
||||
if not item.get("title"): # type: ignore[no-any-return]
|
||||
item["title"] = filename
|
||||
|
||||
# Mark the item as a music video via a flexible attribute
|
||||
# so it can be queried, e.g. `media_type:music_video`.
|
||||
item["media_type"] = "music_video"
|
||||
|
||||
return item
|
||||
|
||||
# For all other paths, fall back to the normal behavior,
|
||||
# which uses Item.from_path() and MediaFile.
|
||||
return original_read_item(self_, path)
|
||||
|
||||
# Patch ImportTask.manipulate_files to skip try_write for music videos
|
||||
# (MediaFile does not support WebM etc., and writing could corrupt the file).
|
||||
_original_manipulate_files = ImportTask.manipulate_files
|
||||
|
||||
def manipulate_files_with_video_guard(
|
||||
self_task: "ImportTask",
|
||||
session: Any,
|
||||
operation: Any = None,
|
||||
write: bool = False,
|
||||
) -> None:
|
||||
items = self_task.imported_items()
|
||||
self_task.old_paths = [item.path for item in items] # type: ignore[attr-defined]
|
||||
for item in items:
|
||||
if operation is not None:
|
||||
old_path = item.path
|
||||
if (
|
||||
operation != util.MoveOperation.MOVE
|
||||
and self_task.replaced_items[item] # type: ignore[attr-defined]
|
||||
and session.lib.directory in util.ancestry(old_path)
|
||||
):
|
||||
item.move()
|
||||
self_task.old_paths.remove(old_path) # type: ignore[attr-defined]
|
||||
else:
|
||||
item.move(operation)
|
||||
# Skip writing tags for music videos; MediaFile does not support
|
||||
# video formats and could corrupt or truncate the file.
|
||||
if write and (self_task.apply or self_task.choice_flag == Action.RETAG): # type: ignore[attr-defined]
|
||||
if item.get("media_type") != "music_video":
|
||||
item.try_write()
|
||||
with session.lib.transaction():
|
||||
for item in self_task.imported_items():
|
||||
item.store()
|
||||
plugins.send("import_task_files", session=session, task=self_task)
|
||||
|
||||
ImportTask.manipulate_files = manipulate_files_with_video_guard # type: ignore[assignment]
|
||||
|
||||
# Mark the class as patched and replace the method. These runtime
|
||||
# attributes are safe but confuse static type checkers.
|
||||
ImportTaskFactory._beets_music_videos_patched = True # type: ignore[attr-defined]
|
||||
ImportTaskFactory._beets_music_videos_original_read_item = ( # type: ignore[attr-defined]
|
||||
original_read_item
|
||||
)
|
||||
ImportTaskFactory.read_item = read_item_with_videos # type: ignore[assignment]
|
||||
|
||||
def _search_api(
|
||||
self,
|
||||
query_type: Literal["album", "track"],
|
||||
filters: SearchFilter,
|
||||
query_string: str = "",
|
||||
) -> Sequence[VideoType]:
|
||||
"""Required by SearchApiMetadataSourcePlugin. Search IMVDB and return videos."""
|
||||
if query_type != "track":
|
||||
return []
|
||||
artist = (filters.get("artist") or "").strip()
|
||||
title = (query_string or "").strip()
|
||||
query = " ".join((artist, title)).strip()
|
||||
if not query:
|
||||
return []
|
||||
return self.imvdb_api.sync_video_search(query)
|
||||
|
||||
def album_for_id(self, album_id: str) -> AlbumInfo | None:
|
||||
"""We don't provide album metadata."""
|
||||
return None
|
||||
|
||||
def track_for_id(self, track_id: str) -> TrackInfo | None:
|
||||
"""Look up a single video by ID; not implemented yet."""
|
||||
return None
|
||||
|
||||
def item_candidates(self, item: Item, artist: str, title: str) -> list[TrackInfo]:
|
||||
results = self._search_api(
|
||||
"track", {"artist": artist}, query_string=title
|
||||
)
|
||||
out: list[TrackInfo] = []
|
||||
for result in results:
|
||||
artists_list = result.get("artists", [])
|
||||
artist_names = [str(a.get("name", "")) for a in artists_list]
|
||||
first_artist = artist_names[0] if artist_names else ""
|
||||
# Beets' distance code expects title/artist as strings (e.g. .lower()).
|
||||
out.append(
|
||||
TrackInfo(
|
||||
title=str(result.get("song_title", "")),
|
||||
artist=first_artist,
|
||||
artists=artist_names,
|
||||
track_id=str(result["id"]),
|
||||
year=str(result.get("year", "")),
|
||||
)
|
||||
)
|
||||
return out
|
||||
42
beetsplug/beets_music_videos/types.py
Normal file
42
beetsplug/beets_music_videos/types.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class ImageSizes(TypedDict, total=False):
|
||||
"""Image URL set; keys are size codes (o=original, l=large, b=big, t=thumb)."""
|
||||
o: str
|
||||
l: str
|
||||
b: str
|
||||
t: str
|
||||
|
||||
|
||||
class ArtistType(TypedDict):
|
||||
name: str
|
||||
slug: str
|
||||
url: str
|
||||
discogs_id: int | None
|
||||
|
||||
|
||||
class VideoType(TypedDict):
|
||||
id: int
|
||||
production_status: str
|
||||
song_title: str
|
||||
url: str
|
||||
multiple_versions: bool
|
||||
version_name: str | None
|
||||
version_number: int
|
||||
is_imvdb_pick: bool
|
||||
aspect_ratio: str | None
|
||||
year: int
|
||||
verified_credits: bool
|
||||
image: ImageSizes
|
||||
artists: list[ArtistType] # API returns "artists", not "artist"
|
||||
|
||||
|
||||
class SearchResponseType(TypedDict):
|
||||
total: int
|
||||
current_page: int
|
||||
total_pages: int
|
||||
per_page: int
|
||||
results: list[VideoType]
|
||||
27
dev-beet
Executable file
27
dev-beet
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run beets with the local development config and plugin installed
|
||||
# in this project's Poetry-managed virtualenv.
|
||||
|
||||
PROJECT_ROOT="$(
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1
|
||||
pwd
|
||||
)"
|
||||
CONFIG_FILE="$PROJECT_ROOT/dev-config.yaml"
|
||||
|
||||
if ! command -v poetry >/dev/null 2>&1; then
|
||||
echo "Poetry is required to run this command." >&2
|
||||
echo "Install Poetry from https://python-poetry.org/docs/ and run 'poetry install' in this directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# So beets only sees config under this project: user config path becomes
|
||||
# $PROJECT_ROOT/config.yaml (typically missing), so -c dev-config.yaml is the
|
||||
# only config file loaded (plus defaults). Unset so the main ~/.config/beets
|
||||
# (or system) config is not merged in.
|
||||
export BEETSDIR="$PROJECT_ROOT"
|
||||
|
||||
# Run beets inside the Poetry virtualenv, pointing at the dev config.
|
||||
exec poetry run beet -c "$CONFIG_FILE" "$@"
|
||||
|
||||
20
dev-config.yaml
Normal file
20
dev-config.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
directory: ./dev-music
|
||||
library: ./dev-library.db
|
||||
|
||||
# Enable the in-development plugin from this repo.
|
||||
plugins: beets_music_videos
|
||||
|
||||
# Remove original files after copying into the library. (Default is no.)
|
||||
import:
|
||||
delete: yes
|
||||
|
||||
# Plugin-specific configuration can go here once the plugin
|
||||
# grows real options. For now it's just a placeholder section.
|
||||
beets_music_videos: {}
|
||||
|
||||
paths:
|
||||
default: '$albumartist/$album [$year]/$track. $title'
|
||||
singleton: '$artist - $title - $year'
|
||||
|
||||
beets_music_videos:
|
||||
imvdb_api_key: mXfQwBkWPhaUE2y9NbmMEE9JbJ37gtfmRCqjkAGC
|
||||
BIN
dev-library.db
Normal file
BIN
dev-library.db
Normal file
Binary file not shown.
11
mise.toml
Normal file
11
mise.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[tasks.lint]
|
||||
description = "lint and format project"
|
||||
run = "poetry run ruff check && poetry run ruff format"
|
||||
|
||||
[tasks.build]
|
||||
description = "build project"
|
||||
run = "poetry build"
|
||||
|
||||
[tasks.dev]
|
||||
description = "run dev version"
|
||||
run = "./dev-beet"
|
||||
1395
poetry.lock
generated
Normal file
1395
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
pyproject.toml
Normal file
43
pyproject.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "beets-musicvideos"
|
||||
version = "0.0.1"
|
||||
description = "Adds facilities for managing, and tagging music videos"
|
||||
authors = [
|
||||
{name = "David Freitag",email = "david@freitag.site"}
|
||||
]
|
||||
license = {text = "Apache"}
|
||||
# Match the Python version you're actually using in this repo.
|
||||
requires-python = ">=3.12,<4"
|
||||
dependencies = [
|
||||
# Keep in sync with the beets version you have installed. 2.6.0+
|
||||
# matches your current 2.6.1 install while staying on PyPI releases.
|
||||
"beets (>=2.6.0,<3.0.0)",
|
||||
"mediainfo (>=0.0.9,<0.0.10)",
|
||||
"typing-extensions (>=4.15.0,<5.0.0)",
|
||||
"lapidary (>=0.12.3,<0.13.0)"
|
||||
]
|
||||
[tool.poetry]
|
||||
packages = [
|
||||
{include = "beetsplug"}
|
||||
]
|
||||
[tool.pyright]
|
||||
typeCheckingMode = "strict"
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest (>=9.0.2,<10.0.0)",
|
||||
"pyright (>=1.1.408,<2.0.0)",
|
||||
"ruff (>=0.15.3,<0.16.0)"
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
unsafe-fixes = false
|
||||
preview = true
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
BIN
state.pickle
Normal file
BIN
state.pickle
Normal file
Binary file not shown.
Reference in New Issue
Block a user