Public Access
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:
@@ -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
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user