Basics working

This commit is contained in:
2026-02-26 19:56:25 -05:00
commit ccbde9fb6e
11 changed files with 1855 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dev-music
dist
__pycache__
.cursor

0
AGENTS.MD Normal file
View File

View 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

View 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
View 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
View 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

Binary file not shown.

11
mise.toml Normal file
View 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

File diff suppressed because it is too large Load Diff

43
pyproject.toml Normal file
View 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

Binary file not shown.