diff --git a/CONTEXT.md b/CONTEXT.md index 3973b63..fd1ac09 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,11 +5,11 @@ A small service that tells Home Assistant which album to play for a scanned RFID ## Language **Tag**: -A physical RFID card, identified by the ID the reader reports (e.g. `53-13-0B-2A-34-00-01`). Nothing is ever written to it. -_Avoid_: card (in code), NFC tag +An RFID sticker on a vinyl record, identified by the ID the reader reports (e.g. `53-13-0B-2A-34-00-01`). Nothing is ever written to it. +_Avoid_: card, NFC tag **Assignment**: -The link from one **Tag** to one album (artist + album) in the music library. +The link from one **Tag** to an ordered list of one or more albums (artist + album) in the music library, played one after the other. Usually one album; more when one physical record holds several digital releases (e.g. a vinyl compiling two EPs). _Avoid_: encoding, mapping, tag map **Unassigned tag**: @@ -22,14 +22,15 @@ One report that a **Tag** was placed on a reader, as relayed by Home Assistant. ## Relationships - A **Tag** has zero or one **Assignment** -- An **Assignment** points to exactly one album +- An **Assignment** holds one or more albums, in play order - Every **Scan** is of exactly one **Tag**; the first **Scan** of an unseen **Tag** creates it as an **Unassigned tag** ## Example dialogue -> **Dev:** "Someone put a new card on the reader — do we need to encode it?" -> **Domain expert:** "It's an **Unassigned tag** now; open the page and give it an **Assignment**. Nothing gets written to the card." +> **Dev:** "I stuck a tag on a new record and put it on the reader. Do we need to encode it?" +> **Domain expert:** "It's an **Unassigned tag** now; open the page and give it an **Assignment**. Nothing gets written to the tag." ## Flagged ambiguities +- "Album" in an **Assignment** means a release in the digital library (album or EP), not the physical record: one record can map to several. - "Encode the tags" meant assigning an album to a **Tag**, not writing data onto it. Resolved: **Assignment**. diff --git a/README.md b/README.md index c715afe..4c9583e 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,13 @@ Both endpoints need `Authorization: Bearer `. | Endpoint | Purpose | |---|---| -| `POST /api/scans` `{"tag_id": "53-13-0B-2A"}` | Record a Scan; creates the Tag on first sight. Returns `{"tag_id", "assigned": true, "artist", "album", "uri"}` or `{"tag_id", "assigned": false}`. `uri` is the album's Music Assistant URI (e.g. `library://album/2846`), checked against the library on every scan; `null` means play by name. | -| `GET /api/tags/{tag_id}` | Read-only lookup, same response, with the stored `uri` unchecked. `404` if the tag has never been seen. | +| `POST /api/scans` `{"tag_id": "53-13-0B-2A"}` | Record a Scan; creates the Tag on first sight. Returns `{"tag_id", "assigned", "albums": [{"artist", "album", "uri"}, …]}`, the albums in play order (empty when unassigned). Each `uri` is the album's Music Assistant URI (e.g. `library://album/2846`), checked against the library on every scan; `null` means play by name. | +| `GET /api/tags/{tag_id}` | Read-only lookup, same response, with the stored URIs unchecked. `404` if the tag has never been seen. | Tag IDs are normalised to upper case. +A tag usually plays one album, but can play several in order, e.g. a vinyl that compiles two EPs the library has as separate releases. HA plays the first album (replacing the queue) and queues the others after it. On the web page, **Add** appends an album to a tag and **Assign** replaces all of them; the tag's page reorders and removes them. + HA plays by `uri` when there is one, because a library can hold near-duplicate albums whose names differ only in capitals, and only one of them may be playable. On each scan the service checks the stored URI is still in the library. If it's gone (e.g. after a library rebuild), it finds the album by name again, preferring the exact spelling, and stores the new URI. The web page (`/`) has no login: keep the service on the LAN. diff --git a/src/tag_albums/app.py b/src/tag_albums/app.py index 21aabd3..a28ae17 100644 --- a/src/tag_albums/app.py +++ b/src/tag_albums/app.py @@ -17,7 +17,7 @@ from pydantic import BaseModel from .config import Settings from .library import MusicLibrary -from .store import SORTS, Store, Tag +from .store import SORTS, Store, Tag, TagAlbum log = logging.getLogger(__name__) @@ -37,39 +37,47 @@ class ScanRequest(BaseModel): tag_id: str -def _tag_response(tag: Tag, uri: str | None = None) -> dict: - if not tag.assigned: - return {"tag_id": tag.tag_id, "assigned": False} - return {"tag_id": tag.tag_id, "assigned": True, "artist": tag.artist, "album": tag.album, "uri": uri} +def _tag_response(tag: Tag, uris: list[str | None]) -> dict: + """The Tag's Assignment for HA: its albums in play order (empty if unassigned). + + Each album's `uri` is what HA plays it by; None means play by names. + """ + return { + "tag_id": tag.tag_id, + "assigned": tag.assigned, + "albums": [ + {"artist": a.artist, "album": a.album, "uri": uri} for a, uri in zip(tag.albums, uris) + ], + } -def resolve_uri(store: Store, library: MusicLibrary, tag: Tag) -> str | None: - """The Music Assistant URI HA should play this Assignment by, or None for by-name. +def resolve_uri(store: Store, library: MusicLibrary, tag_id: str, album: TagAlbum) -> str | None: + """The Music Assistant URI HA should play this album by, or None for by-name. Checks the stored URI against the library, and finds (and stores) a new one when it's missing or gone. If the library can't be searched, trusts the stored URI. """ try: - if tag.uri: - uri = library.current_uri(tag.uri, tag.artist, tag.album) + if album.uri: + uri = library.current_uri(album.uri, album.artist, album.album) else: - match = library.find_album(tag.artist, tag.album) + match = library.find_album(album.artist, album.album) uri = match.uri if match else None except (httpx.HTTPError, RuntimeError) as e: - log.warning("Couldn't check %s against the library: %s", tag.tag_id, e) - return tag.uri - if uri and uri != tag.uri: - log.info("%s now plays %s (was %s)", tag.tag_id, uri, tag.uri) - store.set_uri(tag.tag_id, uri) + log.warning("Couldn't check %s #%d against the library: %s", tag_id, album.position, e) + return album.uri + if uri and uri != album.uri: + log.info("%s #%d now plays %s (was %s)", tag_id, album.position, uri, album.uri) + store.set_uri(tag_id, album.position, uri) return uri def store_cover( - store: Store, library: MusicLibrary, tag_id: str, image: str, + store: Store, library: MusicLibrary, tag_id: str, position: int, image: str, save_url: bool = False, raise_unreachable: bool = False, ) -> bool: - """Download a cover and store it with the Assignment; False if it can't be had. + """Download an album's cover and store it; False if it can't be had. With raise_unreachable, failing to connect to Music Assistant at all raises instead, since then no other cover will download either. @@ -79,12 +87,12 @@ def store_cover( except httpx.TransportError: if raise_unreachable: raise - log.warning("Can't reach Music Assistant for the cover of %s at %s", tag_id, image) + log.warning("Can't reach Music Assistant for the cover of %s #%d at %s", tag_id, position, image) return False except (httpx.HTTPError, ValueError) as e: - log.info("No cover for %s from %s: %s", tag_id, image, e) + log.info("No cover for %s #%d from %s: %s", tag_id, position, image, e) return False - store.set_cover(tag_id, data, content_type, image if save_url else None) + store.set_cover(tag_id, position, data, content_type, image if save_url else None) return True @@ -120,7 +128,7 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib if not scan.tag_id.strip(): raise HTTPException(status_code=422, detail="tag_id is empty") tag = store.record_scan(scan.tag_id) - return _tag_response(tag, resolve_uri(store, library, tag) if tag.assigned else None) + return _tag_response(tag, [resolve_uri(store, library, tag.tag_id, a) for a in tag.albums]) @app.get("/api/tags/{tag_id}", dependencies=[Depends(require_token)]) def get_tag(tag_id: str) -> dict: @@ -128,7 +136,7 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib tag = store.get(tag_id) if tag is None: raise HTTPException(status_code=404, detail="Unknown tag") - return _tag_response(tag, tag.uri) + return _tag_response(tag, [a.uri for a in tag.albums]) # --- Web page ------------------------------------------------------------- @@ -154,13 +162,14 @@ 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) + @app.get("/tags/{tag_id}/albums/{position}/cover") + def album_cover(tag_id: str, position: int): + cover = store.get_cover(tag_id, position) 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. + # The URL carries the album's URI or name, so a different album at + # this position gets a different URL. return Response(data, media_type=content_type, headers={"Cache-Control": "max-age=31536000, immutable"}) @app.get("/covers") @@ -181,12 +190,21 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib albums = library.search_albums(q.strip()) except (httpx.HTTPError, RuntimeError) as e: error = f"Search failed: {e}" + tag = store.get(tag_id) return templates.TemplateResponse( request, "_results.html", - {"tag_id": tag_id, "albums": albums, "error": error, "q": q}, + {"tag_id": tag_id, "albums": albums, "error": error, "q": q, + "has_albums": bool(tag and tag.assigned)}, ) + def _save_album(tag_id: str, artist: str, album: str, image: str, sig: str, uri: str, replace: bool) -> None: + # Only keep an image URL that came from our own search results. + image = image if signed(image, sig) else "" + tag = (store.assign if replace else store.add_album)(tag_id, artist, album, image, uri) + if image: + store_cover(store, library, tag.tag_id, tag.albums[-1].position, image) + @app.post("/tags/{tag_id}/assign") def assign( tag_id: str, @@ -196,13 +214,38 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib sig: str = Form(default=""), uri: 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, uri) - if image: - store_cover(store, library, tag_id, image) + """Make this album the Tag's whole Assignment.""" + _save_album(tag_id, artist, album, image, sig, uri, replace=True) return RedirectResponse("/", status_code=303) + @app.post("/tags/{tag_id}/add") + def add( + tag_id: str, + artist: str = Form(), + album: str = Form(), + image: str = Form(default=""), + sig: str = Form(default=""), + uri: str = Form(default=""), + ): + """Add this album after the Tag's other albums.""" + _save_album(tag_id, artist, album, image, sig, uri, replace=False) + return RedirectResponse(f"/tags/{tag_id}", status_code=303) + + @app.post("/tags/{tag_id}/albums/{position}/up") + def move_up(tag_id: str, position: int): + store.move_album(tag_id, position, -1) + return RedirectResponse(f"/tags/{tag_id}", status_code=303) + + @app.post("/tags/{tag_id}/albums/{position}/down") + def move_down(tag_id: str, position: int): + store.move_album(tag_id, position, +1) + return RedirectResponse(f"/tags/{tag_id}", status_code=303) + + @app.post("/tags/{tag_id}/albums/{position}/remove") + def remove(tag_id: str, position: int): + store.remove_album(tag_id, position) + return RedirectResponse(f"/tags/{tag_id}", status_code=303) + @app.post("/tags/{tag_id}/unassign") def unassign(tag_id: str): store.unassign(tag_id) diff --git a/src/tag_albums/cli.py b/src/tag_albums/cli.py index b3ca14a..7889a5f 100644 --- a/src/tag_albums/cli.py +++ b/src/tag_albums/cli.py @@ -31,46 +31,47 @@ class SyncReport: def sync_library(store: Store, library) -> SyncReport: - """Fill in what Assignments are missing from the music library. + """Fill in what Assignments' albums are missing from the music library. - Imported and older Assignments have only names: this finds their album - (exact spelling first, then ignoring case), stores its URI so HA plays - exactly that album, and stores its cover. Assignments that already have - both are left alone. + Imported and older albums have only names: this finds each one (exact + spelling first, then ignoring case), stores its URI so HA plays exactly + that album, and stores its cover. Albums that already have both are left + alone. """ from .app import store_cover report = SyncReport() for tag in store.assigned(): - if tag.uri and tag.has_cover: - continue - label = f"{tag.artist} – {tag.album}" - match = None - if not tag.uri or not (tag.has_cover or tag.image): + for a in tag.albums: + if a.uri and a.has_cover: + continue + label = f"{a.artist} – {a.album}" + match = None + if not a.uri or not (a.has_cover or a.image): + try: + match = library.find_album(a.artist, a.album) + except (httpx.HTTPError, RuntimeError) as e: + raise SystemExit(f"Library search failed: {e}") + if not a.uri: + if match and match.uri: + store.set_uri(tag.tag_id, a.position, match.uri) + report.linked.append(label) + else: + report.unmatched.append(label) + if a.has_cover: + continue + image, save_url = (a.image, False) if a.image else ((match.image if match else None), True) try: - match = library.find_album(tag.artist, tag.album) - except (httpx.HTTPError, RuntimeError) as e: - raise SystemExit(f"Library search failed: {e}") - if not tag.uri: - if match and match.uri: - store.set_uri(tag.tag_id, match.uri) - report.linked.append(label) - else: - report.unmatched.append(label) - if tag.has_cover: - continue - image, save_url = (tag.image, False) if tag.image else ((match.image if match else None), True) - try: - stored = bool(image) and store_cover( - store, library, tag.tag_id, image, save_url=save_url, raise_unreachable=True - ) - except httpx.TransportError as e: - raise SystemExit( - f"Can't reach Music Assistant at {library.image_download_url(image)}: {e}\n" - "Set MA_URL to an address of Music Assistant this host can reach " - "(e.g. a reverse proxy in front of its port 8095)." - ) - (report.covers if stored else report.coverless).append(label) + stored = bool(image) and store_cover( + store, library, tag.tag_id, a.position, image, save_url=save_url, raise_unreachable=True + ) + except httpx.TransportError as e: + raise SystemExit( + f"Can't reach Music Assistant at {library.image_download_url(image)}: {e}\n" + "Set MA_URL to an address of Music Assistant this host can reach " + "(e.g. a reverse proxy in front of its port 8095)." + ) + (report.covers if stored else report.coverless).append(label) return report diff --git a/src/tag_albums/store.py b/src/tag_albums/store.py index 7237d00..23ac857 100644 --- a/src/tag_albums/store.py +++ b/src/tag_albums/store.py @@ -1,45 +1,63 @@ """SQLite storage for Tags and their Assignments. -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. +A Tag row always exists once the Tag has been scanned (or imported). Its +Assignment is its rows in `albums`, in play order (`position` 0, 1, …); a +Tag with no albums is an Unassigned tag. Each album's cover is stored with +it so the web page can serve it itself: Music Assistant's image URLs are +plain http on another host. """ import sqlite3 from dataclasses import dataclass from datetime import UTC, datetime +SCHEMA_VERSION = 2 + SCHEMA = """ CREATE TABLE IF NOT EXISTS tags ( tag_id TEXT PRIMARY KEY, first_seen TEXT NOT NULL, last_seen TEXT, scan_count INTEGER NOT NULL DEFAULT 0, - artist TEXT, - album TEXT, - image TEXT, - uri TEXT, - assigned_at TEXT, - cover BLOB, - cover_type TEXT -) + assigned_at TEXT +); +CREATE TABLE IF NOT EXISTS albums ( + tag_id TEXT NOT NULL REFERENCES tags (tag_id) ON DELETE CASCADE, + position INTEGER NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + image TEXT, + uri TEXT, + cover BLOB, + cover_type TEXT, + PRIMARY KEY (tag_id, position) +); """ -# Columns added after the first release, for databases created before them. -MIGRATIONS = {"cover": "BLOB", "cover_type": "TEXT", "uri": "TEXT"} - -TAG_COLUMNS = ( - "tag_id, first_seen, last_seen, scan_count, artist, album, image, uri, assigned_at," - " cover IS NOT NULL AS has_cover" -) - +# The Assignment's first album decides where the Tag sorts. 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", + "artist": "first.artist COLLATE NOCASE, first.album COLLATE NOCASE", + "assigned": "t.assigned_at DESC, first.artist COLLATE NOCASE", + "scanned": "t.last_seen IS NULL, t.last_seen DESC, first.artist COLLATE NOCASE", } +TAG_COLUMNS = "t.tag_id, t.first_seen, t.last_seen, t.scan_count, t.assigned_at" +ALBUM_COLUMNS = "tag_id, position, artist, album, image, uri, cover IS NOT NULL AS has_cover" + + +@dataclass(frozen=True) +class TagAlbum: + """One album (or EP) of an Assignment.""" + + position: int + artist: str + album: str + image: str | None + # The album's Music Assistant URI (e.g. library://album/2846). Names can + # match more than one library entry; the URI can't. + uri: str | None + has_cover: bool + @dataclass(frozen=True) class Tag: @@ -47,18 +65,12 @@ class Tag: first_seen: str last_seen: str | None scan_count: int - artist: str | None - album: str | None - image: str | None - # The album's Music Assistant URI (e.g. library://album/2846). Names can - # match more than one library entry; the URI can't. - uri: str | None assigned_at: str | None - has_cover: bool + albums: tuple[TagAlbum, ...] = () @property def assigned(self) -> bool: - return self.album is not None + return bool(self.albums) def normalize_tag_id(tag_id: str) -> str: @@ -69,8 +81,11 @@ 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"])}) +def _album(row: sqlite3.Row) -> TagAlbum: + return TagAlbum( + position=row["position"], artist=row["artist"], album=row["album"], + image=row["image"], uri=row["uri"], has_cover=bool(row["has_cover"]), + ) class Store: @@ -78,20 +93,90 @@ class Store: 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) + self._conn.execute("PRAGMA foreign_keys=ON") + self._migrate() + + def _migrate(self) -> None: + version = self._conn.execute("PRAGMA user_version").fetchone()[0] + if version >= SCHEMA_VERSION: + return 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}") + self._conn.execute("BEGIN") + try: + for statement in SCHEMA.split(";"): + if statement.strip(): + self._conn.execute(statement) + if "album" in existing: + # Version 1 kept a single album in columns of `tags`. + for column in ("image", "uri", "cover", "cover_type"): + if column not in existing: + self._conn.execute(f"ALTER TABLE tags ADD COLUMN {column}") + self._conn.execute( + "INSERT INTO albums (tag_id, position, artist, album, image, uri, cover, cover_type)" + " SELECT tag_id, 0, artist, album, image, uri, cover, cover_type" + " FROM tags WHERE album IS NOT NULL" + ) + for column in ("artist", "album", "image", "uri", "cover", "cover_type"): + self._conn.execute(f"ALTER TABLE tags DROP COLUMN {column}") + self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + self._conn.execute("COMMIT") + except Exception: + self._conn.execute("ROLLBACK") + raise def close(self) -> None: self._conn.close() + # --- Reading --------------------------------------------------------------- + + def _albums_of(self, tag_ids: list[str]) -> dict[str, tuple[TagAlbum, ...]]: + if not tag_ids: + return {} + marks = ",".join("?" * len(tag_ids)) + rows = self._conn.execute( + f"SELECT {ALBUM_COLUMNS} FROM albums WHERE tag_id IN ({marks}) ORDER BY tag_id, position", + tag_ids, + ) + grouped: dict[str, list[TagAlbum]] = {} + for row in rows: + grouped.setdefault(row["tag_id"], []).append(_album(row)) + return {k: tuple(v) for k, v in grouped.items()} + + def _tags(self, rows: list[sqlite3.Row]) -> list[Tag]: + albums = self._albums_of([r["tag_id"] for r in rows]) + return [Tag(**dict(r), albums=albums.get(r["tag_id"], ())) for r in rows] + def get(self, tag_id: str) -> Tag | None: + rows = self._conn.execute( + f"SELECT {TAG_COLUMNS} FROM tags t WHERE t.tag_id = ?", (normalize_tag_id(tag_id),) + ).fetchall() + return self._tags(rows)[0] if rows else None + + def unassigned(self) -> list[Tag]: + rows = self._conn.execute( + f"SELECT {TAG_COLUMNS} FROM tags t" + " WHERE NOT EXISTS (SELECT 1 FROM albums a WHERE a.tag_id = t.tag_id)" + " ORDER BY t.last_seen DESC, t.first_seen DESC" + ).fetchall() + return self._tags(rows) + + def assigned(self, sort: str = "artist") -> list[Tag]: + order = SORTS.get(sort, SORTS["artist"]) + rows = self._conn.execute( + f"SELECT {TAG_COLUMNS} FROM tags t" + " JOIN albums first ON first.tag_id = t.tag_id AND first.position = 0" + f" ORDER BY {order}" + ).fetchall() + return self._tags(rows) + + def get_cover(self, tag_id: str, position: int) -> tuple[bytes, str] | None: row = self._conn.execute( - f"SELECT {TAG_COLUMNS} FROM tags WHERE tag_id = ?", (normalize_tag_id(tag_id),) + "SELECT cover, cover_type FROM albums WHERE tag_id = ? AND position = ? AND cover IS NOT NULL", + (normalize_tag_id(tag_id), position), ).fetchone() - return _tag(row) if row else None + return (row["cover"], row["cover_type"]) if row else None + + # --- Writing --------------------------------------------------------------- def record_scan(self, tag_id: str) -> Tag: """Record a Scan, creating the Tag as an Unassigned tag on first sight.""" @@ -108,65 +193,86 @@ class Store: ) return self.get(tag_id) + def _ensure_tag(self, tag_id: str) -> None: + self._conn.execute( + "INSERT INTO tags (tag_id, first_seen) VALUES (?, ?) ON CONFLICT (tag_id) DO NOTHING", + (tag_id, _now()), + ) + + def _touch(self, tag_id: str) -> None: + self._conn.execute("UPDATE tags SET assigned_at = ? WHERE tag_id = ?", (_now(), tag_id)) + def assign( self, tag_id: str, artist: str, album: str, image: str | None = None, uri: str | None = None ) -> Tag: - """Create or replace the Tag's Assignment, creating the Tag if needed. - - Any stored cover belongs to the previous Assignment, so it's dropped. - """ + """Make this album the Tag's whole Assignment, replacing any albums it had.""" tag_id = normalize_tag_id(tag_id) - now = _now() + self._ensure_tag(tag_id) + self._conn.execute("DELETE FROM albums WHERE tag_id = ?", (tag_id,)) + return self.add_album(tag_id, artist, album, image, uri) + + def add_album( + self, tag_id: str, artist: str, album: str, image: str | None = None, uri: str | None = None + ) -> Tag: + """Append an album to the Tag's Assignment, to play after the others.""" + tag_id = normalize_tag_id(tag_id) + self._ensure_tag(tag_id) self._conn.execute( - """ - INSERT INTO tags (tag_id, first_seen, artist, album, image, uri, assigned_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (tag_id) DO UPDATE - SET artist = excluded.artist, album = excluded.album, - image = excluded.image, uri = excluded.uri, assigned_at = excluded.assigned_at, - cover = NULL, cover_type = NULL - """, - (tag_id, now, artist, album, image or None, uri or None, now), + "INSERT INTO albums (tag_id, position, artist, album, image, uri)" + " VALUES (?, (SELECT COUNT(*) FROM albums WHERE tag_id = ?), ?, ?, ?, ?)", + (tag_id, tag_id, artist, album, image or None, uri or None), ) + self._touch(tag_id) 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.""" + def move_album(self, tag_id: str, position: int, offset: int) -> None: + """Swap an album with its neighbour (offset -1: earlier, +1: later).""" + tag_id = normalize_tag_id(tag_id) + other = position + offset + count = self._conn.execute("SELECT COUNT(*) FROM albums WHERE tag_id = ?", (tag_id,)).fetchone()[0] + if not (0 <= position < count and 0 <= other < count): + return + # Through -1, since (tag_id, position) is unique. + for old, new in ((position, -1), (other, position), (-1, other)): + self._conn.execute( + "UPDATE albums SET position = ? WHERE tag_id = ? AND position = ?", (new, tag_id, old) + ) + self._touch(tag_id) + + def remove_album(self, tag_id: str, position: int) -> None: + tag_id = normalize_tag_id(tag_id) + self._conn.execute("DELETE FROM albums WHERE tag_id = ? AND position = ?", (tag_id, position)) self._conn.execute( - "UPDATE tags SET cover = ?, cover_type = ?, image = COALESCE(?, image) WHERE tag_id = ?", - (data, content_type, image, normalize_tag_id(tag_id)), + "UPDATE albums SET position = position - 1 WHERE tag_id = ? AND position > ?", (tag_id, position) + ) + remaining = self._conn.execute("SELECT COUNT(*) FROM albums WHERE tag_id = ?", (tag_id,)).fetchone()[0] + if remaining: + self._touch(tag_id) + else: + self._conn.execute("UPDATE tags SET assigned_at = NULL WHERE tag_id = ?", (tag_id,)) + + def set_cover( + self, tag_id: str, position: int, data: bytes, content_type: str, image: str | None = None + ) -> None: + """Store an album's cover, and the URL it came from when it's newly found.""" + self._conn.execute( + "UPDATE albums SET cover = ?, cover_type = ?, image = COALESCE(?, image)" + " WHERE tag_id = ? AND position = ?", + (data, content_type, image, normalize_tag_id(tag_id), position), ) - def set_uri(self, tag_id: str, uri: str | None) -> None: - self._conn.execute("UPDATE tags SET uri = ? WHERE tag_id = ?", (uri, 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 set_uri(self, tag_id: str, position: int, uri: str | None) -> None: + self._conn.execute( + "UPDATE albums SET uri = ? WHERE tag_id = ? AND position = ?", + (uri, normalize_tag_id(tag_id), position), + ) def unassign(self, tag_id: str) -> None: - self._conn.execute( - "UPDATE tags SET artist = NULL, album = NULL, image = NULL, uri = NULL, assigned_at = NULL," - " cover = NULL, cover_type = NULL WHERE tag_id = ?", - (normalize_tag_id(tag_id),), - ) + tag_id = normalize_tag_id(tag_id) + self._conn.execute("DELETE FROM albums WHERE tag_id = ?", (tag_id,)) + self._conn.execute("UPDATE tags SET assigned_at = NULL WHERE tag_id = ?", (tag_id,)) def delete(self, tag_id: str) -> None: - self._conn.execute("DELETE FROM tags WHERE tag_id = ?", (normalize_tag_id(tag_id),)) - - def unassigned(self) -> list[Tag]: - rows = self._conn.execute( - 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] - - def assigned(self, sort: str = "artist") -> list[Tag]: - order = SORTS.get(sort, SORTS["artist"]) - rows = self._conn.execute( - f"SELECT {TAG_COLUMNS} FROM tags WHERE album IS NOT NULL ORDER BY {order}" - ) - return [_tag(r) for r in rows] + tag_id = normalize_tag_id(tag_id) + self._conn.execute("DELETE FROM albums WHERE tag_id = ?", (tag_id,)) + self._conn.execute("DELETE FROM tags WHERE tag_id = ?", (tag_id,)) diff --git a/src/tag_albums/templates/_cover.html b/src/tag_albums/templates/_cover.html new file mode 100644 index 0000000..2390f09 --- /dev/null +++ b/src/tag_albums/templates/_cover.html @@ -0,0 +1,2 @@ +{# The cover of album `a` of tag `tag_id`, or the grey placeholder. #} +{% if a.has_cover %}{% endif %} diff --git a/src/tag_albums/templates/_results.html b/src/tag_albums/templates/_results.html index f234d1c..e9ef6bb 100644 --- a/src/tag_albums/templates/_results.html +++ b/src/tag_albums/templates/_results.html @@ -12,6 +12,7 @@ + {% if has_albums %}{% endif %} diff --git a/src/tag_albums/templates/base.html b/src/tag_albums/templates/base.html index ee4af24..d5e10c0 100644 --- a/src/tag_albums/templates/base.html +++ b/src/tag_albums/templates/base.html @@ -18,9 +18,11 @@ a { color: var(--accent); } .muted { color: var(--muted); font-size: .9em; } code { font-size: .9em; } - ul.rows { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); } - ul.rows li { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border-top: 1px solid var(--line); } - ul.rows li:first-child { border-top: 0; } + ul.rows, ol.rows { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--card); } + ul.rows li, ol.rows li { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border-top: 1px solid var(--line); } + ul.rows li:first-child, ol.rows li:first-child { border-top: 0; } + .pos { width: 1.2em; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; } + button:disabled { opacity: .35; cursor: default; } .grow { flex: 1; min-width: 0; } .cover { width: 44px; height: 44px; border-radius: 6px; overflow: hidden; background: var(--line); flex: none; } .cover img { display: block; width: 100%; height: 100%; object-fit: cover; } diff --git a/src/tag_albums/templates/index.html b/src/tag_albums/templates/index.html index 78892e1..503296f 100644 --- a/src/tag_albums/templates/index.html +++ b/src/tag_albums/templates/index.html @@ -20,9 +20,9 @@