From 1abba34cfe3c3b64b14b3ab57039df177a2aef36 Mon Sep 17 00:00:00 2001 From: twisla Date: Fri, 25 Sep 2026 21:21:05 +0000 Subject: [PATCH] Serve album covers from the service, show and sort by dates Covers are stored with the assignment and served by the service, since Music Assistant's plain-http image URLs are blocked on an https page. Search results load covers through a signed /covers proxy. Adds a fetch-covers command for imported assignments, a migration for existing databases, and sorting assignments by artist, recently assigned or recently scanned, with both dates shown. Co-Authored-By: Claude Opus 5.5 --- README.md | 20 ++++++ src/tag_albums/app.py | 70 +++++++++++++++++-- src/tag_albums/cli.py | 48 ++++++++++++- src/tag_albums/library.py | 40 +++++++++-- src/tag_albums/store.py | 71 +++++++++++++++---- src/tag_albums/templates/_results.html | 3 +- src/tag_albums/templates/base.html | 4 ++ src/tag_albums/templates/index.html | 18 ++++- tests/conftest.py | 12 ++++ tests/test_app.py | 95 +++++++++++++++++++++++++- tests/test_library.py | 35 +++++++++- 11 files changed, 388 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8372768..514ceb1 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,26 @@ env DB_PATH=/var/db/tag_albums/tag_albums.sqlite3 \ su -m tagalbums -c '.venv/bin/tag-albums import-yaml /path/to/tag_albums.yaml' ``` +### Album covers + +The service stores each assignment's cover and serves it itself. Music Assistant's image URLs are plain http on another host, which browsers block on an https page. Albums assigned from the web page get their cover right away. Imported assignments have none, so fetch them once (this reads the env file for HA access): + +```sh +cd /usr/local/tag-albums +su -m tagalbums -c 'set -a; . /usr/local/etc/tag_albums.env; set +a; .venv/bin/tag-albums fetch-covers' +``` + +It lists the albums Music Assistant has no cover for; those keep the grey placeholder. Running it again only looks at assignments still missing a cover. + +### Updating + +```sh +cd /usr/local/tag-albums && git pull && uv sync --no-dev +service tag_albums restart +``` + +Database changes are applied automatically at startup. + ## Home Assistant side These pieces live in the `home-assistant-config` repo: diff --git a/src/tag_albums/app.py b/src/tag_albums/app.py index 7c9a124..9b852be 100644 --- a/src/tag_albums/app.py +++ b/src/tag_albums/app.py @@ -1,19 +1,25 @@ """HTTP API for Home Assistant and the web page for assigning albums.""" +import hashlib +import hmac +import logging import secrets from datetime import datetime from pathlib import Path +from urllib.parse import urlencode import httpx from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.responses import HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from pydantic import BaseModel from .config import Settings from .library import MusicLibrary -from .store import Store, Tag +from .store import SORTS, Store, Tag + +log = logging.getLogger(__name__) templates = Jinja2Templates(directory=Path(__file__).parent / "templates") @@ -37,12 +43,36 @@ def _tag_response(tag: Tag) -> dict: return {"tag_id": tag.tag_id, "assigned": True, "artist": tag.artist, "album": tag.album} +def store_cover(store: Store, library: MusicLibrary, tag_id: str, image: str, save_url: bool = False) -> bool: + """Download a cover and store it with the Assignment; False if it can't be had.""" + try: + data, content_type = library.fetch_image(image) + except (httpx.HTTPError, ValueError) as e: + log.info("No cover for %s from %s: %s", tag_id, image, e) + return False + store.set_cover(tag_id, data, content_type, image if save_url else None) + return True + + def create_app(settings: Settings, store: Store | None = None, library: MusicLibrary | None = None) -> FastAPI: store = store or Store(settings.db_path) library = library or MusicLibrary(settings.ha_url, settings.ha_token) app = FastAPI(title="Tag albums", docs_url=None, redoc_url=None) app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") + # Search results show covers straight from Music Assistant, which serves + # plain http that browsers block on an https page. The page gets them + # through /covers instead, and only for URLs this service signed, so it + # can't be used to fetch arbitrary addresses. + def sign(url: str) -> str: + return hmac.new(settings.api_token.encode(), url.encode(), hashlib.sha256).hexdigest()[:32] + + def signed(url: str, sig: str) -> bool: + return bool(url) and hmac.compare_digest(sign(url), sig) + + templates.env.globals["cover_preview"] = lambda url: "/covers?" + urlencode({"src": url, "sig": sign(url)}) + templates.env.globals["sign"] = sign + def require_token(authorization: str = Header(default="")) -> None: scheme, _, token = authorization.partition(" ") if scheme.lower() != "bearer" or not secrets.compare_digest(token, settings.api_token): @@ -68,11 +98,12 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib # --- Web page ------------------------------------------------------------- @app.get("/", response_class=HTMLResponse) - def index(request: Request): + def index(request: Request, sort: str = "artist"): + sort = sort if sort in SORTS else "artist" return templates.TemplateResponse( request, "index.html", - {"unassigned": store.unassigned(), "assigned": store.assigned()}, + {"unassigned": store.unassigned(), "assigned": store.assigned(sort), "sort": sort}, ) @app.get("/unassigned", response_class=HTMLResponse) @@ -88,6 +119,25 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib raise HTTPException(status_code=404, detail="Unknown tag") return templates.TemplateResponse(request, "tag.html", {"tag": tag}) + @app.get("/tags/{tag_id}/cover") + def tag_cover(tag_id: str): + cover = store.get_cover(tag_id) + if cover is None: + raise HTTPException(status_code=404, detail="No cover") + data, content_type = cover + # The URL carries the assignment time, so a new album gets a new URL. + return Response(data, media_type=content_type, headers={"Cache-Control": "max-age=31536000, immutable"}) + + @app.get("/covers") + def cover_preview(src: str = "", sig: str = ""): + if not signed(src, sig): + raise HTTPException(status_code=403, detail="Unsigned cover URL") + try: + data, content_type = library.fetch_image(src) + except (httpx.HTTPError, ValueError): + raise HTTPException(status_code=404, detail="No cover") + return Response(data, media_type=content_type, headers={"Cache-Control": "max-age=86400"}) + @app.get("/tags/{tag_id}/search", response_class=HTMLResponse) def search(request: Request, tag_id: str, q: str = ""): albums, error = [], None @@ -103,8 +153,18 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib ) @app.post("/tags/{tag_id}/assign") - def assign(tag_id: str, artist: str = Form(), album: str = Form(), image: str = Form(default="")): + def assign( + tag_id: str, + artist: str = Form(), + album: str = Form(), + image: str = Form(default=""), + sig: str = Form(default=""), + ): + # Only keep an image URL that came from our own search results. + image = image if signed(image, sig) else "" store.assign(tag_id, artist, album, image) + if image: + store_cover(store, library, tag_id, image) return RedirectResponse("/", status_code=303) @app.post("/tags/{tag_id}/unassign") diff --git a/src/tag_albums/cli.py b/src/tag_albums/cli.py index a430727..3bfeed5 100644 --- a/src/tag_albums/cli.py +++ b/src/tag_albums/cli.py @@ -1,8 +1,9 @@ -"""Command line: run the service, or import Assignments from tag_albums.yaml.""" +"""Command line: run the service, import Assignments, or fill in missing covers.""" import argparse import os +import httpx import yaml from .store import Store @@ -20,6 +21,34 @@ def import_yaml(path: str, db_path: str) -> int: return len(entries) +def fetch_covers(store: Store, library) -> tuple[list[str], list[str]]: + """Store a cover for every Assignment that has none. + + Uses the image URL saved at assignment time, or else looks the album up + in the library by its exact artist and album names (imported Assignments + have no URL). Returns the albums that got a cover and those that didn't. + """ + from .app import store_cover + + found, missing = [], [] + for tag in store.assigned(): + if tag.has_cover: + continue + label = f"{tag.artist} – {tag.album}" + image, save_url = tag.image, False + if not image: + try: + match = library.find_album(tag.artist, tag.album) + except (httpx.HTTPError, RuntimeError) as e: + raise SystemExit(f"Library search failed: {e}") + image, save_url = (match.image if match else None), True + if image and store_cover(store, library, tag.tag_id, image, save_url=save_url): + found.append(label) + else: + missing.append(label) + return found, missing + + def main() -> None: parser = argparse.ArgumentParser(prog="tag-albums") sub = parser.add_subparsers(dest="command", required=True) @@ -31,6 +60,8 @@ def main() -> None: imp = sub.add_parser("import-yaml", help="Import Assignments from a tag_albums.yaml file") imp.add_argument("path") + sub.add_parser("fetch-covers", help="Download covers for Assignments that have none") + args = parser.parse_args() if args.command == "serve": import uvicorn @@ -40,3 +71,18 @@ def main() -> None: db_path = os.environ.get("DB_PATH", "tag_albums.sqlite3") count = import_yaml(args.path, db_path) print(f"Imported {count} assignments into {db_path}") + elif args.command == "fetch-covers": + from .config import Settings + from .library import MusicLibrary + + settings = Settings.from_env() + store = Store(settings.db_path) + try: + found, missing = fetch_covers(store, MusicLibrary(settings.ha_url, settings.ha_token)) + finally: + store.close() + print(f"Stored {len(found)} covers.") + if missing: + print(f"No cover found for {len(missing)}:") + for label in missing: + print(f" {label}") diff --git a/src/tag_albums/library.py b/src/tag_albums/library.py index e3a5d07..9be1bb9 100644 --- a/src/tag_albums/library.py +++ b/src/tag_albums/library.py @@ -13,16 +13,30 @@ class Album: image: str | None +# Covers from Music Assistant are well under this; anything bigger isn't a cover. +MAX_COVER_BYTES = 5 * 1024 * 1024 + + class MusicLibrary: - def __init__(self, ha_url: str, ha_token: str, client: httpx.Client | None = None): + def __init__( + self, + ha_url: str, + ha_token: str, + client: httpx.Client | None = None, + image_client: httpx.Client | None = None, + ): + # System trust store rather than certifi, so a home CA installed + # on the host (or pointed to by SSL_CERT_FILE) is trusted. + verify = ssl.create_default_context() self._client = client or httpx.Client( base_url=ha_url, headers={"Authorization": f"Bearer {ha_token}"}, timeout=10, - # System trust store rather than certifi, so a home CA installed - # on the host (or pointed to by SSL_CERT_FILE) is trusted. - verify=ssl.create_default_context(), + verify=verify, ) + # Cover images are served by Music Assistant, not HA: this client + # never carries the HA token. + self._images = image_client or httpx.Client(timeout=10, verify=verify) self._entry_id: str | None = None def _music_assistant_entry(self) -> str: @@ -60,3 +74,21 @@ class MusicLibrary: ) for item in albums ] + + def find_album(self, artist: str, album: str) -> Album | None: + """The library album matching an Assignment's names exactly (ignoring case).""" + for found in self.search_albums(album, limit=25): + if found.album.casefold() == album.casefold() and found.artist.casefold() == artist.casefold(): + return found + return None + + def fetch_image(self, url: str) -> tuple[bytes, str]: + """Download a cover image; raises if it isn't an image or is too big.""" + r = self._images.get(url) + r.raise_for_status() + content_type = r.headers.get("content-type", "").split(";")[0].strip() + if not content_type.startswith("image/"): + raise ValueError(f"not an image: {content_type or 'no content type'}") + if len(r.content) > MAX_COVER_BYTES: + raise ValueError("image too large") + return r.content, content_type diff --git a/src/tag_albums/store.py b/src/tag_albums/store.py index e901af6..5b0c01f 100644 --- a/src/tag_albums/store.py +++ b/src/tag_albums/store.py @@ -2,6 +2,8 @@ A Tag row always exists once the Tag has been scanned (or imported); its Assignment is the artist/album columns, all NULL for an Unassigned tag. +The album cover is stored with the Assignment so the web page can serve it +itself: Music Assistant's image URLs are plain http on another host. """ import sqlite3 @@ -17,10 +19,26 @@ CREATE TABLE IF NOT EXISTS tags ( artist TEXT, album TEXT, image TEXT, - assigned_at TEXT + assigned_at TEXT, + cover BLOB, + cover_type TEXT ) """ +# Columns added after the first release, for databases created before them. +MIGRATIONS = {"cover": "BLOB", "cover_type": "TEXT"} + +TAG_COLUMNS = ( + "tag_id, first_seen, last_seen, scan_count, artist, album, image, assigned_at," + " cover IS NOT NULL AS has_cover" +) + +SORTS = { + "artist": "artist COLLATE NOCASE, album COLLATE NOCASE", + "assigned": "assigned_at DESC, artist COLLATE NOCASE", + "scanned": "last_seen IS NULL, last_seen DESC, artist COLLATE NOCASE", +} + @dataclass(frozen=True) class Tag: @@ -32,6 +50,7 @@ class Tag: album: str | None image: str | None assigned_at: str | None + has_cover: bool @property def assigned(self) -> bool: @@ -46,21 +65,29 @@ def _now() -> str: return datetime.now(UTC).isoformat(timespec="seconds") +def _tag(row: sqlite3.Row) -> Tag: + return Tag(**{**dict(row), "has_cover": bool(row["has_cover"])}) + + class Store: def __init__(self, path: str): self._conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute(SCHEMA) + existing = {r["name"] for r in self._conn.execute("PRAGMA table_info(tags)")} + for column, kind in MIGRATIONS.items(): + if column not in existing: + self._conn.execute(f"ALTER TABLE tags ADD COLUMN {column} {kind}") def close(self) -> None: self._conn.close() def get(self, tag_id: str) -> Tag | None: row = self._conn.execute( - "SELECT * FROM tags WHERE tag_id = ?", (normalize_tag_id(tag_id),) + f"SELECT {TAG_COLUMNS} FROM tags WHERE tag_id = ?", (normalize_tag_id(tag_id),) ).fetchone() - return Tag(**row) if row else None + return _tag(row) if row else None def record_scan(self, tag_id: str) -> Tag: """Record a Scan, creating the Tag as an Unassigned tag on first sight.""" @@ -78,7 +105,10 @@ class Store: return self.get(tag_id) def assign(self, tag_id: str, artist: str, album: str, image: str | None = None) -> Tag: - """Create or replace the Tag's Assignment, creating the Tag if needed.""" + """Create or replace the Tag's Assignment, creating the Tag if needed. + + Any stored cover belongs to the previous Assignment, so it's dropped. + """ tag_id = normalize_tag_id(tag_id) now = _now() self._conn.execute( @@ -87,16 +117,31 @@ class Store: VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (tag_id) DO UPDATE SET artist = excluded.artist, album = excluded.album, - image = excluded.image, assigned_at = excluded.assigned_at + image = excluded.image, assigned_at = excluded.assigned_at, + cover = NULL, cover_type = NULL """, (tag_id, now, artist, album, image or None, now), ) return self.get(tag_id) + def set_cover(self, tag_id: str, data: bytes, content_type: str, image: str | None = None) -> None: + """Store the album cover, and the URL it came from when it's newly found.""" + self._conn.execute( + "UPDATE tags SET cover = ?, cover_type = ?, image = COALESCE(?, image) WHERE tag_id = ?", + (data, content_type, image, normalize_tag_id(tag_id)), + ) + + def get_cover(self, tag_id: str) -> tuple[bytes, str] | None: + row = self._conn.execute( + "SELECT cover, cover_type FROM tags WHERE tag_id = ? AND cover IS NOT NULL", + (normalize_tag_id(tag_id),), + ).fetchone() + return (row["cover"], row["cover_type"]) if row else None + def unassign(self, tag_id: str) -> None: self._conn.execute( - "UPDATE tags SET artist = NULL, album = NULL, image = NULL, assigned_at = NULL" - " WHERE tag_id = ?", + "UPDATE tags SET artist = NULL, album = NULL, image = NULL, assigned_at = NULL," + " cover = NULL, cover_type = NULL WHERE tag_id = ?", (normalize_tag_id(tag_id),), ) @@ -105,12 +150,14 @@ class Store: def unassigned(self) -> list[Tag]: rows = self._conn.execute( - "SELECT * FROM tags WHERE album IS NULL ORDER BY last_seen DESC, first_seen DESC" + f"SELECT {TAG_COLUMNS} FROM tags WHERE album IS NULL" + " ORDER BY last_seen DESC, first_seen DESC" ) - return [Tag(**r) for r in rows] + return [_tag(r) for r in rows] - def assigned(self) -> list[Tag]: + def assigned(self, sort: str = "artist") -> list[Tag]: + order = SORTS.get(sort, SORTS["artist"]) rows = self._conn.execute( - "SELECT * FROM tags WHERE album IS NOT NULL ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE" + f"SELECT {TAG_COLUMNS} FROM tags WHERE album IS NOT NULL ORDER BY {order}" ) - return [Tag(**r) for r in rows] + return [_tag(r) for r in rows] diff --git a/src/tag_albums/templates/_results.html b/src/tag_albums/templates/_results.html index d846b20..3de538b 100644 --- a/src/tag_albums/templates/_results.html +++ b/src/tag_albums/templates/_results.html @@ -4,12 +4,13 @@