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 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 21:21:05 +00:00
co-authored by Claude Opus 5.5
parent 8b8a3f6184
commit 1abba34cfe
11 changed files with 388 additions and 28 deletions
+20
View File
@@ -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:
+65 -5
View File
@@ -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")
+47 -1
View File
@@ -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}")
+36 -4
View File
@@ -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
+59 -12
View File
@@ -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]
+2 -1
View File
@@ -4,12 +4,13 @@
<ul class="rows">
{% for a in albums %}
<li>
<span class="cover">{% if a.image %}<img src="{{ a.image }}" alt="" loading="lazy" onerror="this.remove()">{% endif %}</span>
<span class="cover">{% if a.image %}<img src="{{ cover_preview(a.image) }}" alt="" loading="lazy" onerror="this.remove()">{% endif %}</span>
<div class="grow"><strong>{{ a.album }}</strong> · {{ a.artist }}</div>
<form class="inline" method="post" action="/tags/{{ tag_id }}/assign">
<input type="hidden" name="artist" value="{{ a.artist }}">
<input type="hidden" name="album" value="{{ a.album }}">
<input type="hidden" name="image" value="{{ a.image or '' }}">
<input type="hidden" name="sig" value="{{ sign(a.image) if a.image else '' }}">
<button class="primary">Assign</button>
</form>
</li>
+4
View File
@@ -30,6 +30,10 @@
button.danger { color: var(--danger); }
input[type=search] { font: inherit; width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line); background: var(--card); color: var(--fg); }
.empty { padding: 14px 12px; }
.list-head { display: flex; flex-wrap: wrap; gap: 4px 16px; align-items: baseline; justify-content: space-between; margin: 28px 0 10px; }
.list-head h2 { margin: 0; }
.sort { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: .9em; }
.meta { display: flex; flex-wrap: wrap; gap: 0 12px; }
</style>
</head>
<body>
+15 -3
View File
@@ -8,14 +8,26 @@
{% include "_unassigned.html" %}
</div>
<h2>Assignments <span class="muted">({{ assigned|length }})</span></h2>
<div class="list-head">
<h2>Assignments <span class="muted">({{ assigned|length }})</span></h2>
<nav class="sort" aria-label="Sort assignments">
<span class="muted">Sort by</span>
{% for key, label in [("artist", "Artist"), ("assigned", "Recently assigned"), ("scanned", "Recently scanned")] %}
{% if key == sort %}<strong aria-current="true">{{ label }}</strong>{% else %}<a href="/?sort={{ key }}">{{ label }}</a>{% endif %}
{% endfor %}
</nav>
</div>
<ul class="rows">
{% for tag in assigned %}
<li>
<span class="cover">{% if tag.image %}<img src="{{ tag.image }}" alt="" loading="lazy" onerror="this.remove()">{% endif %}</span>
<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>
<div class="grow">
<div><strong>{{ tag.album }}</strong> · {{ tag.artist }}</div>
<div class="muted"><code>{{ tag.tag_id }}</code>{% if tag.last_seen %} · last scanned {{ tag.last_seen|local_time }}{% endif %}</div>
<div class="muted meta">
<code>{{ tag.tag_id }}</code>
<span>assigned {{ tag.assigned_at|local_time }}</span>
<span>{% if tag.last_seen %}last scanned {{ tag.last_seen|local_time }}{% else %}not scanned yet{% endif %}</span>
</div>
</div>
<a href="/tags/{{ tag.tag_id }}">Change</a>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/unassign"
+12
View File
@@ -13,11 +13,23 @@ AUTH = {"Authorization": f"Bearer {TOKEN}"}
class FakeLibrary:
def __init__(self):
self.queries = []
self.fetched = []
self.images = {"http://img/1": (b"\x89PNG-lateralus", "image/png")}
def search_albums(self, query, limit=12):
self.queries.append(query)
return [Album(artist="Tool", album="Lateralus", image="http://img/1")]
def find_album(self, artist, album):
found = self.search_albums(album)[0]
return found if (found.artist, found.album) == (artist, album) else None
def fetch_image(self, url):
self.fetched.append(url)
if url not in self.images:
raise ValueError("no such image")
return self.images[url]
@pytest.fixture
def store(tmp_path):
+94 -1
View File
@@ -1,6 +1,9 @@
import re
import sqlite3
from conftest import AUTH
from tag_albums.cli import import_yaml
from tag_albums.cli import fetch_covers, import_yaml
from tag_albums.store import Store
@@ -73,6 +76,96 @@ def test_search_results(client, 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
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):
store.record_scan("AA-BB")
form = _search_form(client)
assert form["image"] == "http://img/1" and form["sig"]
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")
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
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 == []
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
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
def test_cover_preview_only_serves_signed_urls(client):
form = _search_form(client)
ok = client.get("/covers", params={"src": form["image"], "sig": form["sig"]})
assert ok.status_code == 200 and ok.content == b"\x89PNG-lateralus"
assert client.get("/covers", params={"src": "http://evil/x", "sig": form["sig"]}).status_code == 403
assert client.get("/covers", params={"src": "http://evil/x"}).status_code == 403
def test_assignments_sort(client, store):
store.assign("A", "Tool", "Lateralus")
store.assign("B", "Amenra", "De Doorn")
store._conn.execute("UPDATE tags SET assigned_at = '2026-01-01T00:00:00+00:00' WHERE tag_id = 'B'")
store.record_scan("B")
def order(sort):
return [t.tag_id for t in store.assigned(sort)]
assert order("artist") == ["B", "A"]
assert order("assigned") == ["A", "B"]
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
assert "not scanned yet" in page and "last scanned" in page
assert client.get("/", params={"sort": "bogus"}).status_code == 200
def test_old_database_is_migrated(tmp_path):
db = str(tmp_path / "old.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)")
conn.execute("INSERT INTO tags (tag_id, first_seen, artist, album, assigned_at)"
" VALUES ('AA', '2026-01-01', 'Tool', 'Lateralus', '2026-01-01')")
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")
store.close()
def test_fetch_covers_for_imported_assignments(store, library):
store.assign("A", "Tool", "Lateralus") # imported: no image URL
store.assign("B", "Amenra", "De Doorn") # not in the library
found, missing = fetch_covers(store, library)
assert found == ["Tool – Lateralus"] and missing == ["Amenra – De Doorn"]
tag = store.get("A")
assert tag.has_cover and tag.image == "http://img/1"
assert fetch_covers(store, library) == ([], ["Amenra – De Doorn"]) # already stored: skipped
def test_import_yaml(tmp_path):
+34 -1
View File
@@ -1,8 +1,9 @@
import json
import httpx
import pytest
from tag_albums.library import MusicLibrary
from tag_albums.library import Album, MusicLibrary
def test_search_albums_parses_music_assistant_response():
@@ -30,3 +31,35 @@ def test_search_albums_parses_music_assistant_response():
library.search_albums("lateralus")
# The Music Assistant config entry is looked up once, then cached.
assert sum(c.url.path == "/api/config/config_entries/entry" for c in calls) == 1
def _library_with_images(responses):
seen = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return responses[str(request.url)]
images = httpx.Client(transport=httpx.MockTransport(handler))
return MusicLibrary("http://ha", "ha-token", client=httpx.Client(), image_client=images), seen
def test_fetch_image_checks_type_and_never_sends_ha_token():
library, seen = _library_with_images({
"http://ma/cover": httpx.Response(200, content=b"jpg", headers={"content-type": "image/jpeg"}),
"http://ma/page": httpx.Response(200, content=b"<html>", headers={"content-type": "text/html"}),
})
assert library.fetch_image("http://ma/cover") == (b"jpg", "image/jpeg")
assert "authorization" not in seen[0].headers
with pytest.raises(ValueError):
library.fetch_image("http://ma/page")
def test_find_album_needs_exact_names():
library = MusicLibrary("http://ha", "t", client=httpx.Client())
library.search_albums = lambda query, limit=12: [
Album("Tool", "Lateralus (Live)", "http://img/live"),
Album("TOOL", "lateralus", "http://img/1"),
]
assert library.find_album("Tool", "Lateralus").image == "http://img/1"
assert library.find_album("Mogwai", "Lateralus") is None