Let a tag play several albums in order

An assignment is now an ordered list of albums (e.g. one vinyl compiling two EPs that are separate releases in the library). Albums move to their own table, with a migration for existing databases. The scan and lookup responses return an albums list instead of the single artist/album/uri fields. The tag page lists its albums with up, down and remove, and search results get an Add button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 11:44:44 +00:00
co-authored by Claude Opus 5.5
parent a2f421c272
commit 5878adb756
11 changed files with 536 additions and 259 deletions
+7 -6
View File
@@ -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**.
+4 -2
View File
@@ -21,11 +21,13 @@ Both endpoints need `Authorization: Bearer <API_TOKEN>`.
| 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.
+75 -32
View File
@@ -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)
+34 -33
View File
@@ -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
+192 -86
View File
@@ -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,))
+2
View File
@@ -0,0 +1,2 @@
{# The cover of album `a` of tag `tag_id`, or the grey placeholder. #}
<span class="cover">{% if a.has_cover %}<img src="/tags/{{ tag_id }}/albums/{{ a.position }}/cover?v={{ (a.uri or a.artist ~ '/' ~ a.album)|urlencode }}" alt="" loading="lazy" onerror="this.remove()">{% endif %}</span>
+1
View File
@@ -12,6 +12,7 @@
<input type="hidden" name="image" value="{{ a.image or '' }}">
<input type="hidden" name="sig" value="{{ sign(a.image) if a.image else '' }}">
<input type="hidden" name="uri" value="{{ a.uri or '' }}">
{% if has_albums %}<button formaction="/tags/{{ tag_id }}/add">Add</button>{% endif %}
<button class="primary">Assign</button>
</form>
</li>
+5 -3
View File
@@ -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; }
+2 -2
View File
@@ -20,9 +20,9 @@
<ul class="rows">
{% for tag in assigned %}
<li>
<span class="cover">{% if tag.has_cover %}<img src="/tags/{{ tag.tag_id }}/cover?v={{ tag.assigned_at|urlencode }}" alt="" loading="lazy" onerror="this.remove()">{% endif %}</span>
{% with a = tag.albums[0], tag_id = tag.tag_id %}{% include "_cover.html" %}{% endwith %}
<div class="grow">
<div><strong>{{ tag.album }}</strong> · {{ tag.artist }}</div>
<div>{% for a in tag.albums %}{% if not loop.first %} <span class="muted">+</span> {% endif %}<strong>{{ a.album }}</strong> · {{ a.artist }}{% endfor %}</div>
<div class="muted meta">
<code>{{ tag.tag_id }}</code>
<span>assigned {{ tag.assigned_at|local_time }}</span>
+24 -3
View File
@@ -3,9 +3,30 @@
{% block body %}
<p><a href="/">← All tags</a></p>
<h1><code>{{ tag.tag_id }}</code></h1>
<p class="muted">
{% if tag.assigned %}Currently plays <strong>{{ tag.album }}</strong> · {{ tag.artist }}.{% else %}No album assigned yet.{% endif %}
</p>
{% if tag.assigned %}
<h2>Plays, in this order</h2>
<ol class="rows">
{% for a in tag.albums %}
<li>
<span class="pos">{{ loop.index }}</span>
{% with tag_id = tag.tag_id %}{% include "_cover.html" %}{% endwith %}
<div class="grow"><strong>{{ a.album }}</strong> · {{ a.artist }}</div>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/albums/{{ a.position }}/up">
<button aria-label="Play {{ a.album }} earlier" {% if loop.first %}disabled{% endif %}>↑</button>
</form>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/albums/{{ a.position }}/down">
<button aria-label="Play {{ a.album }} later" {% if loop.last %}disabled{% endif %}>↓</button>
</form>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/albums/{{ a.position }}/remove">
<button class="danger">Remove</button>
</form>
</li>
{% endfor %}
</ol>
<p class="muted">Search below and use <strong>Add</strong> to play another album after these, or <strong>Assign</strong> to replace them all.</p>
{% else %}
<p class="muted">No album assigned yet.</p>
{% endif %}
<h2>Search the music library</h2>
<input type="search" name="q" placeholder="Album or artist…" autofocus autocomplete="off"
+190 -92
View File
@@ -8,6 +8,22 @@ from tag_albums.library import Album
from tag_albums.store import Store
def _scan(client, tag_id):
return client.post("/api/scans", json={"tag_id": tag_id}, headers=AUTH).json()
def _names(tag):
return [(a.artist, a.album) for a in tag.albums]
def _search_form(client, tag_id="AA-BB"):
"""The hidden fields of the first search result's form."""
html = client.get(f"/tags/{tag_id}/search", params={"q": "lateralus"}).text
return dict(re.findall(r'name="(\w+)" value="([^"]*)"', html))
# --- API for Home Assistant -------------------------------------------------
def test_api_requires_token(client):
assert client.post("/api/scans", json={"tag_id": "AA"}).status_code == 401
assert client.get("/api/tags/AA", headers={"Authorization": "Bearer wrong"}).status_code == 401
@@ -16,54 +32,140 @@ def test_api_requires_token(client):
def test_first_scan_creates_unassigned_tag(client, store):
r = client.post("/api/scans", json={"tag_id": "53-13-0b-2a"}, headers=AUTH)
assert r.status_code == 200
assert r.json() == {"tag_id": "53-13-0B-2A", "assigned": False}
assert r.json() == {"tag_id": "53-13-0B-2A", "assigned": False, "albums": []}
tag = store.get("53-13-0B-2A")
assert tag.scan_count == 1 and not tag.assigned
def test_scan_of_assigned_tag_returns_album(client, store):
store.assign("86-2C-1D-BD", "Tool", "Lateralus")
r = client.post("/api/scans", json={"tag_id": "86-2C-1D-BD"}, headers=AUTH)
assert r.json() == {"tag_id": "86-2C-1D-BD", "assigned": True, "artist": "Tool", "album": "Lateralus",
"uri": "library://album/1868"}
assert store.get("86-2C-1D-BD").scan_count == 1
def test_scan_returns_albums_in_play_order(client, store):
store.assign("REC", "Tool", "Lateralus", uri="library://album/1868")
store.add_album("REC", "Amenra", "De Doorn")
assert _scan(client, "REC") == {
"tag_id": "REC",
"assigned": True,
"albums": [
{"artist": "Tool", "album": "Lateralus", "uri": "library://album/1868"},
{"artist": "Amenra", "album": "De Doorn", "uri": None}, # not in the library: by name
],
}
assert store.get("REC").scan_count == 1
def test_empty_tag_id_rejected(client):
assert client.post("/api/scans", json={"tag_id": " "}, headers=AUTH).status_code == 422
def test_lookup_is_read_only(client, store):
def test_lookup_is_read_only(client, store, library):
assert client.get("/api/tags/AA-BB", headers=AUTH).status_code == 404
assert store.get("AA-BB") is None
store.record_scan("AA-BB")
r = client.get("/api/tags/aa-bb", headers=AUTH)
assert r.json() == {"tag_id": "AA-BB", "assigned": False}
assert r.json() == {"tag_id": "AA-BB", "assigned": False, "albums": []}
assert store.get("AA-BB").scan_count == 1
store.assign("AA-BB", "Tool", "Lateralus", uri="library://album/9999")
albums = client.get("/api/tags/AA-BB", headers=AUTH).json()["albums"]
assert albums == [{"artist": "Tool", "album": "Lateralus", "uri": "library://album/9999"}]
assert library.queries == [] # stored URI, unchecked
# --- Playing by URI -----------------------------------------------------------
def test_scan_links_names_only_album_to_exact_spelling(client, store, library):
# The case that broke: two library entries differing only in capitals,
# and only the one spelled like the assignment is playable.
library.library = [
Album("The Mob", "Let The Tribe Increase", None, "library://album/1183"),
Album("The Mob", "Let the Tribe Increase", None, "library://album/2846"),
]
store.assign("MOB", "The Mob", "Let the Tribe Increase")
assert _scan(client, "MOB")["albums"][0]["uri"] == "library://album/2846"
assert store.get("MOB").albums[0].uri == "library://album/2846"
def test_scan_replaces_a_uri_the_library_no_longer_has(client, store):
store.assign("A", "Tool", "Lateralus", uri="library://album/9999") # before a rebuild
assert _scan(client, "A")["albums"][0]["uri"] == "library://album/1868"
assert store.get("A").albums[0].uri == "library://album/1868"
def test_scan_falls_back_to_names_when_nothing_matches(client, store):
store.assign("A", "Amenra", "De Doorn", uri="library://album/9999")
album = _scan(client, "A")["albums"][0]
assert album["uri"] is None and album["album"] == "De Doorn"
assert store.get("A").albums[0].uri == "library://album/9999" # kept, in case it comes back
def test_scan_trusts_stored_uri_when_library_is_unreachable(client, store, library):
store.assign("A", "Tool", "Lateralus", uri="library://album/1868")
library.fail_search = True
assert _scan(client, "A")["albums"][0]["uri"] == "library://album/1868"
# --- Web page -----------------------------------------------------------------
def test_assign_unassign_delete_from_web_page(client, store):
store.record_scan("AA-BB")
r = client.post("/tags/AA-BB/assign", data={"artist": "Tool", "album": "Lateralus", "image": ""},
follow_redirects=False)
assert r.status_code == 303
tag = store.get("AA-BB")
assert (tag.artist, tag.album, tag.image) == ("Tool", "Lateralus", None)
(album,) = store.get("AA-BB").albums
assert (album.artist, album.album, album.image) == ("Tool", "Lateralus", None)
client.post("/tags/AA-BB/unassign")
assert not store.get("AA-BB").assigned
tag = store.get("AA-BB")
assert not tag.assigned and tag.assigned_at is None
client.post("/tags/AA-BB/delete")
assert store.get("AA-BB") is None
def test_add_puts_albums_after_the_others(client, store):
client.post("/tags/REC/assign", data={"artist": "Amenra", "album": "EP 1"})
r = client.post("/tags/REC/add", data={"artist": "Amenra", "album": "EP 2"}, follow_redirects=False)
assert r.headers["location"] == "/tags/REC"
client.post("/tags/REC/add", data={"artist": "Amenra", "album": "EP 3"})
assert [a.album for a in store.get("REC").albums] == ["EP 1", "EP 2", "EP 3"]
# Assign replaces the whole list.
client.post("/tags/REC/assign", data={"artist": "Tool", "album": "Lateralus"})
assert _names(store.get("REC")) == [("Tool", "Lateralus")]
def test_reorder_and_remove(client, store):
for name in ("EP 1", "EP 2", "EP 3"):
store.add_album("REC", "Amenra", name)
client.post("/tags/REC/albums/2/up")
assert [a.album for a in store.get("REC").albums] == ["EP 1", "EP 3", "EP 2"]
client.post("/tags/REC/albums/0/down")
assert [a.album for a in store.get("REC").albums] == ["EP 3", "EP 1", "EP 2"]
client.post("/tags/REC/albums/0/up") # already first: no-op
client.post("/tags/REC/albums/2/down") # already last: no-op
assert [a.album for a in store.get("REC").albums] == ["EP 3", "EP 1", "EP 2"]
client.post("/tags/REC/albums/0/remove")
tag = store.get("REC")
assert [(a.position, a.album) for a in tag.albums] == [(0, "EP 1"), (1, "EP 2")]
client.post("/tags/REC/albums/1/remove")
client.post("/tags/REC/albums/0/remove")
tag = store.get("REC")
assert not tag.assigned and tag.assigned_at is None # back to unassigned
assert "REC" in client.get("/unassigned").text
def test_pages_render(client, store):
store.record_scan("AA-BB")
store.assign("CC-DD", "Tool", "Lateralus")
home = client.get("/")
assert "AA-BB" in home.text and "Lateralus" in home.text
store.add_album("CC-DD", "Amenra", "De Doorn")
home = client.get("/").text
assert "AA-BB" in home
assert re.search(r"Lateralus</strong> · Tool\s*<span class=\"muted\">\+</span>\s*<strong>De Doorn", home)
assert "AA-BB" in client.get("/unassigned").text
page = client.get("/tags/CC-DD").text
assert "Plays, in this order" in page and page.index("Lateralus") < page.index("De Doorn")
assert client.get("/tags/AA-BB").status_code == 200
assert client.get("/tags/ZZ").status_code == 404
@@ -74,46 +176,53 @@ def test_htmx_is_served_locally(client):
assert r.status_code == 200 and r.text.startswith("var htmx=")
def test_search_results(client, library):
def test_search_results(client, store, library):
r = client.get("/tags/AA-BB/search", params={"q": " lateralus "})
assert library.queries == ["lateralus"]
assert "Lateralus" in r.text and 'action="/tags/AA-BB/assign"' in r.text
# Covers go through the service, never straight to Music Assistant's http URL.
assert 'src="http://img/1"' not in r.text and 'src="/covers?src=' in r.text
# Add only makes sense once the tag has an album.
assert 'formaction="/tags/AA-BB/add"' not in r.text
store.assign("AA-BB", "Amenra", "De Doorn")
assert 'formaction="/tags/AA-BB/add"' in client.get("/tags/AA-BB/search", params={"q": "x"}).text
def _search_form(client):
"""The hidden fields of the first search result's Assign form."""
html = client.get("/tags/AA-BB/search", params={"q": "lateralus"}).text
return dict(re.findall(r'name="(\w+)" value="([^"]*)"', html))
def test_assign_from_search_stores_cover(client, store, library):
def test_assign_from_search_stores_cover_and_uri(client, store):
store.record_scan("AA-BB")
form = _search_form(client)
assert form["image"] == "http://img/1" and form["sig"]
assert form["image"] == "http://img/1" and form["sig"] and form["uri"] == "library://album/1868"
client.post("/tags/AA-BB/assign", data=form)
tag = store.get("AA-BB")
assert tag.has_cover and tag.image == "http://img/1"
r = client.get("/tags/AA-BB/cover")
(album,) = store.get("AA-BB").albums
assert album.has_cover and album.image == "http://img/1" and album.uri == "library://album/1868"
r = client.get("/tags/AA-BB/albums/0/cover")
assert (r.status_code, r.content, r.headers["content-type"]) == (200, b"\x89PNG-lateralus", "image/png")
assert f'src="/tags/AA-BB/cover?v=' in client.get("/").text
assert 'src="/tags/AA-BB/albums/0/cover?v=' in client.get("/").text
def test_added_album_gets_its_own_cover(client, store):
client.post("/tags/REC/assign", data={"artist": "Amenra", "album": "De Doorn"})
client.post("/tags/REC/add", data=_search_form(client, "REC"))
first, second = store.get("REC").albums
assert not first.has_cover and second.has_cover
assert client.get("/tags/REC/albums/1/cover").status_code == 200
assert client.get("/tags/REC/albums/0/cover").status_code == 404
def test_assign_ignores_unsigned_image(client, store, library):
client.post("/tags/AA-BB/assign",
data={"artist": "Tool", "album": "Lateralus", "image": "http://evil/x", "sig": "nope"})
tag = store.get("AA-BB")
assert tag.image is None and not tag.has_cover and library.fetched == []
(album,) = store.get("AA-BB").albums
assert album.image is None and not album.has_cover and library.fetched == []
def test_reassigning_drops_the_old_cover(client, store):
client.post("/tags/AA-BB/assign", data=_search_form(client))
assert store.get("AA-BB").has_cover
assert store.get("AA-BB").albums[0].has_cover
client.post("/tags/AA-BB/assign", data={"artist": "Mogwai", "album": "Come On Die Young"})
assert not store.get("AA-BB").has_cover
assert client.get("/tags/AA-BB/cover").status_code == 404
assert not store.get("AA-BB").albums[0].has_cover
assert client.get("/tags/AA-BB/albums/0/cover").status_code == 404
def test_cover_preview_only_serves_signed_urls(client):
@@ -124,9 +233,10 @@ def test_cover_preview_only_serves_signed_urls(client):
assert client.get("/covers", params={"src": "http://evil/x"}).status_code == 403
def test_assignments_sort(client, store):
def test_assignments_sort_by_first_album(client, store):
store.assign("A", "Tool", "Lateralus")
store.assign("B", "Amenra", "De Doorn")
store.add_album("B", "Zzz", "Last") # only the first album counts
store._conn.execute("UPDATE tags SET assigned_at = '2026-01-01T00:00:00+00:00' WHERE tag_id = 'B'")
store.record_scan("B")
@@ -135,7 +245,7 @@ def test_assignments_sort(client, store):
assert order("artist") == ["B", "A"]
assert order("assigned") == ["A", "B"]
assert order("scanned") == ["B", "A"] # never-scanned tags last
assert order("scanned") == ["B", "A"] # never-scanned tags last
page = client.get("/", params={"sort": "assigned"}).text
assert page.index("Lateralus") < page.index("De Doorn")
assert '<strong aria-current="true">Recently assigned</strong>' in page
@@ -143,8 +253,37 @@ def test_assignments_sort(client, store):
assert client.get("/", params={"sort": "bogus"}).status_code == 200
def test_old_database_is_migrated(tmp_path):
db = str(tmp_path / "old.sqlite3")
# --- Storage, import, sync ----------------------------------------------------
def test_database_from_before_multiple_albums_is_migrated(tmp_path):
db = str(tmp_path / "v1.sqlite3")
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE 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)")
conn.execute("INSERT INTO tags VALUES ('AA', '2026-01-01', '2026-02-01', 3, 'Tool', 'Lateralus',"
" 'http://img/1', 'library://album/1868', '2026-01-02', X'89', 'image/png')")
conn.execute("INSERT INTO tags (tag_id, first_seen) VALUES ('BB', '2026-01-01')")
conn.commit()
conn.close()
store = Store(db)
tag = store.get("AA")
assert (tag.scan_count, tag.last_seen, tag.assigned_at) == (3, "2026-02-01", "2026-01-02")
(album,) = tag.albums
assert (album.position, album.artist, album.album, album.image, album.uri, album.has_cover) == (
0, "Tool", "Lateralus", "http://img/1", "library://album/1868", True)
assert store.get_cover("AA", 0) == (b"\x89", "image/png")
assert not store.get("BB").assigned
store.close()
store = Store(db) # reopening doesn't migrate again
assert len(store.get("AA").albums) == 1
store.close()
def test_even_older_database_without_uri_or_cover_is_migrated(tmp_path):
db = str(tmp_path / "v0.sqlite3")
conn = sqlite3.connect(db)
conn.execute("CREATE TABLE 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, assigned_at TEXT)")
@@ -153,21 +292,29 @@ def test_old_database_is_migrated(tmp_path):
conn.commit()
conn.close()
store = Store(db)
tag = store.get("AA")
assert tag.album == "Lateralus" and not tag.has_cover
store.set_cover("AA", b"img", "image/jpeg")
assert store.get_cover("AA") == (b"img", "image/jpeg")
(album,) = store.get("AA").albums
assert album.album == "Lateralus" and album.uri is None and not album.has_cover
store.close()
def test_import_yaml(tmp_path):
src = tmp_path / "tag_albums.yaml"
src.write_text('"29-92-68-C5":\n artist: ONRUST\n album: Van Woede Tot Wanhoop\n')
db = str(tmp_path / "db.sqlite3")
assert import_yaml(str(src), db) == 1
store = Store(db)
assert _names(store.get("29-92-68-C5")) == [("ONRUST", "Van Woede Tot Wanhoop")]
store.close()
def test_sync_library_links_and_fetches_covers(store, library):
store.assign("A", "Tool", "Lateralus") # imported: names only
store.assign("B", "Amenra", "De Doorn") # not in the library
store.add_album("A", "Amenra", "De Doorn") # not in the library
report = sync_library(store, library)
assert report.linked == ["Tool – Lateralus"] and report.unmatched == ["Amenra – De Doorn"]
assert report.covers == ["Tool – Lateralus"] and report.coverless == ["Amenra – De Doorn"]
tag = store.get("A")
assert tag.uri == "library://album/1868" and tag.has_cover and tag.image == "http://img/1"
first = store.get("A").albums[0]
assert first.uri == "library://album/1868" and first.has_cover and first.image == "http://img/1"
again = sync_library(store, library) # done ones are skipped
assert (again.linked, again.covers) == ([], [])
@@ -193,52 +340,3 @@ def test_settings_read_ma_url(monkeypatch):
assert Settings.from_env().ma_url == "https://ma.example"
monkeypatch.delenv("MA_URL")
assert Settings.from_env().ma_url is None
# --- Playing by URI -----------------------------------------------------------
def _scan(client, tag_id):
return client.post("/api/scans", json={"tag_id": tag_id}, headers=AUTH).json()
def test_assign_from_search_stores_the_uri(client, store):
client.post("/tags/AA-BB/assign", data=_search_form(client))
assert store.get("AA-BB").uri == "library://album/1868"
assert _scan(client, "AA-BB")["uri"] == "library://album/1868"
def test_scan_links_names_only_assignment_to_exact_spelling(client, store, library):
# The case that broke: two library entries differing only in capitals,
# and only the one spelled like the assignment is playable.
library.library = [
Album("The Mob", "Let The Tribe Increase", None, "library://album/1183"),
Album("The Mob", "Let the Tribe Increase", None, "library://album/2846"),
]
store.assign("MOB", "The Mob", "Let the Tribe Increase")
assert _scan(client, "MOB")["uri"] == "library://album/2846"
assert store.get("MOB").uri == "library://album/2846"
def test_scan_replaces_a_uri_the_library_no_longer_has(client, store, library):
store.assign("A", "Tool", "Lateralus", uri="library://album/9999") # before a rebuild
assert _scan(client, "A")["uri"] == "library://album/1868"
assert store.get("A").uri == "library://album/1868"
def test_scan_falls_back_to_names_when_nothing_matches(client, store, library):
store.assign("A", "Amenra", "De Doorn", uri="library://album/9999")
body = _scan(client, "A")
assert body["uri"] is None and body["album"] == "De Doorn"
assert store.get("A").uri == "library://album/9999" # kept, in case it comes back
def test_scan_trusts_stored_uri_when_library_is_unreachable(client, store, library):
store.assign("A", "Tool", "Lateralus", uri="library://album/1868")
library.fail_search = True
assert _scan(client, "A")["uri"] == "library://album/1868"
def test_lookup_returns_stored_uri_without_searching(client, store, library):
store.assign("A", "Tool", "Lateralus", uri="library://album/1868")
r = client.get("/api/tags/A", headers=AUTH).json()
assert r["uri"] == "library://album/1868" and library.queries == []