Tag albums service: album lookup API for Home Assistant and assignment web page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 16:56:21 +00:00
co-authored by Claude Opus 5.5
commit eaf77dd0de
20 changed files with 1358 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tells Home Assistant which album to play for a scanned RFID tag."""
+123
View File
@@ -0,0 +1,123 @@
"""HTTP API for Home Assistant and the web page for assigning albums."""
import secrets
from datetime import datetime
from pathlib import Path
import httpx
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from .config import Settings
from .library import MusicLibrary
from .store import Store, Tag
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
def _local_time(value: str | None) -> str:
if not value:
return ""
return datetime.fromisoformat(value).astimezone().strftime("%Y-%m-%d %H:%M")
templates.env.filters["local_time"] = _local_time
class ScanRequest(BaseModel):
tag_id: str
def _tag_response(tag: Tag) -> 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}
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)
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):
raise HTTPException(status_code=401, detail="Invalid or missing token")
# --- API for Home Assistant ---------------------------------------------
@app.post("/api/scans", dependencies=[Depends(require_token)])
def record_scan(scan: ScanRequest) -> dict:
"""Record a Scan and return the Tag's Assignment, if any."""
if not scan.tag_id.strip():
raise HTTPException(status_code=422, detail="tag_id is empty")
return _tag_response(store.record_scan(scan.tag_id))
@app.get("/api/tags/{tag_id}", dependencies=[Depends(require_token)])
def get_tag(tag_id: str) -> dict:
"""Read-only lookup: doesn't count as a Scan."""
tag = store.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail="Unknown tag")
return _tag_response(tag)
# --- Web page -------------------------------------------------------------
@app.get("/", response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(
request,
"index.html",
{"unassigned": store.unassigned(), "assigned": store.assigned()},
)
@app.get("/unassigned", response_class=HTMLResponse)
def unassigned(request: Request):
return templates.TemplateResponse(
request, "_unassigned.html", {"unassigned": store.unassigned()}
)
@app.get("/tags/{tag_id}", response_class=HTMLResponse)
def tag_page(request: Request, tag_id: str):
tag = store.get(tag_id)
if tag is None:
raise HTTPException(status_code=404, detail="Unknown tag")
return templates.TemplateResponse(request, "tag.html", {"tag": tag})
@app.get("/tags/{tag_id}/search", response_class=HTMLResponse)
def search(request: Request, tag_id: str, q: str = ""):
albums, error = [], None
if q.strip():
try:
albums = library.search_albums(q.strip())
except (httpx.HTTPError, RuntimeError) as e:
error = f"Search failed: {e}"
return templates.TemplateResponse(
request,
"_results.html",
{"tag_id": tag_id, "albums": albums, "error": error, "q": q},
)
@app.post("/tags/{tag_id}/assign")
def assign(tag_id: str, artist: str = Form(), album: str = Form(), image: str = Form(default="")):
store.assign(tag_id, artist, album, image)
return RedirectResponse("/", status_code=303)
@app.post("/tags/{tag_id}/unassign")
def unassign(tag_id: str):
store.unassign(tag_id)
return RedirectResponse("/", status_code=303)
@app.post("/tags/{tag_id}/delete")
def delete(tag_id: str):
store.delete(tag_id)
return RedirectResponse("/", status_code=303)
return app
def app_from_env() -> FastAPI:
"""Entry point for uvicorn: `uvicorn --factory tag_albums.app:app_from_env`."""
return create_app(Settings.from_env())
+42
View File
@@ -0,0 +1,42 @@
"""Command line: run the service, or import Assignments from tag_albums.yaml."""
import argparse
import os
import yaml
from .store import Store
def import_yaml(path: str, db_path: str) -> int:
with open(path) as f:
entries = yaml.safe_load(f) or {}
store = Store(db_path)
try:
for tag_id, entry in entries.items():
store.assign(str(tag_id), entry["artist"], entry["album"])
finally:
store.close()
return len(entries)
def main() -> None:
parser = argparse.ArgumentParser(prog="tag-albums")
sub = parser.add_subparsers(dest="command", required=True)
serve = sub.add_parser("serve", help="Run the web service")
serve.add_argument("--host", default="0.0.0.0")
serve.add_argument("--port", type=int, default=8087)
imp = sub.add_parser("import-yaml", help="Import Assignments from a tag_albums.yaml file")
imp.add_argument("path")
args = parser.parse_args()
if args.command == "serve":
import uvicorn
uvicorn.run("tag_albums.app:app_from_env", factory=True, host=args.host, port=args.port)
elif args.command == "import-yaml":
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}")
+22
View File
@@ -0,0 +1,22 @@
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
db_path: str
api_token: str
ha_url: str
ha_token: str
@classmethod
def from_env(cls) -> "Settings":
missing = [k for k in ("API_TOKEN", "HA_URL", "HA_TOKEN") if not os.environ.get(k)]
if missing:
raise RuntimeError(f"Missing environment variables: {', '.join(missing)}")
return cls(
db_path=os.environ.get("DB_PATH", "tag_albums.sqlite3"),
api_token=os.environ["API_TOKEN"],
ha_url=os.environ["HA_URL"].rstrip("/"),
ha_token=os.environ["HA_TOKEN"],
)
+62
View File
@@ -0,0 +1,62 @@
"""Album search in the Music Assistant library, through Home Assistant."""
import ssl
from dataclasses import dataclass
import httpx
@dataclass(frozen=True)
class Album:
artist: str
album: str
image: str | None
class MusicLibrary:
def __init__(self, ha_url: str, ha_token: str, client: httpx.Client | None = None):
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(),
)
self._entry_id: str | None = None
def _music_assistant_entry(self) -> str:
if self._entry_id is None:
r = self._client.get(
"/api/config/config_entries/entry", params={"domain": "music_assistant"}
)
r.raise_for_status()
entries = r.json()
if not entries:
raise RuntimeError("Music Assistant is not set up in Home Assistant")
self._entry_id = entries[0]["entry_id"]
return self._entry_id
def search_albums(self, query: str, limit: int = 12) -> list[Album]:
r = self._client.post(
"/api/services/music_assistant/search",
params={"return_response": ""},
json={
"config_entry_id": self._music_assistant_entry(),
"name": query,
"media_type": ["album"],
"limit": limit,
},
)
r.raise_for_status()
albums = r.json().get("service_response", {}).get("albums", [])
return [
Album(
# Music Assistant's play_media takes a single artist name
# to disambiguate the album, so keep the primary one.
artist=next((a["name"] for a in item.get("artists", [])), ""),
album=item["name"],
image=item.get("image"),
)
for item in albums
]
+116
View File
@@ -0,0 +1,116 @@
"""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.
"""
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
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,
assigned_at TEXT
)
"""
@dataclass(frozen=True)
class Tag:
tag_id: str
first_seen: str
last_seen: str | None
scan_count: int
artist: str | None
album: str | None
image: str | None
assigned_at: str | None
@property
def assigned(self) -> bool:
return self.album is not None
def normalize_tag_id(tag_id: str) -> str:
return tag_id.strip().upper()
def _now() -> str:
return datetime.now(UTC).isoformat(timespec="seconds")
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)
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),)
).fetchone()
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."""
tag_id = normalize_tag_id(tag_id)
now = _now()
self._conn.execute(
"""
INSERT INTO tags (tag_id, first_seen, last_seen, scan_count)
VALUES (?, ?, ?, 1)
ON CONFLICT (tag_id) DO UPDATE
SET last_seen = excluded.last_seen, scan_count = scan_count + 1
""",
(tag_id, now, now),
)
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."""
tag_id = normalize_tag_id(tag_id)
now = _now()
self._conn.execute(
"""
INSERT INTO tags (tag_id, first_seen, artist, album, image, assigned_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tag_id) DO UPDATE
SET artist = excluded.artist, album = excluded.album,
image = excluded.image, assigned_at = excluded.assigned_at
""",
(tag_id, now, artist, album, image or None, now),
)
return self.get(tag_id)
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 = ?",
(normalize_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(
"SELECT * FROM tags WHERE album IS NULL ORDER BY last_seen DESC, first_seen DESC"
)
return [Tag(**r) for r in rows]
def assigned(self) -> list[Tag]:
rows = self._conn.execute(
"SELECT * FROM tags WHERE album IS NOT NULL ORDER BY artist COLLATE NOCASE, album COLLATE NOCASE"
)
return [Tag(**r) for r in rows]
+20
View File
@@ -0,0 +1,20 @@
{% if error %}
<p class="muted">{{ error }}</p>
{% elif albums %}
<ul class="rows">
{% for a in albums %}
<li>
{% if a.image %}<img class="cover" src="{{ a.image }}" alt="" loading="lazy">{% else %}<span class="cover"></span>{% endif %}
<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 '' }}">
<button class="primary">Assign</button>
</form>
</li>
{% endfor %}
</ul>
{% elif q %}
<p class="muted">No albums found for “{{ q }}”.</p>
{% endif %}
+19
View File
@@ -0,0 +1,19 @@
<ul class="rows">
{% for tag in unassigned %}
<li>
<div class="grow">
<div><code>{{ tag.tag_id }}</code></div>
<div class="muted">
{% if tag.last_seen %}last scanned {{ tag.last_seen|local_time }} · {{ tag.scan_count }} scan{{ "s" if tag.scan_count != 1 }}{% else %}never scanned{% endif %}
</div>
</div>
<a href="/tags/{{ tag.tag_id }}">Assign</a>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/delete"
onsubmit="return confirm('Delete {{ tag.tag_id }}? It comes back if it is scanned again.')">
<button class="danger">Delete</button>
</form>
</li>
{% else %}
<li class="empty muted">No unassigned tags.</li>
{% endfor %}
</ul>
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Tag albums{% endblock %}</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.4/dist/htmx.min.js"></script>
<style>
:root { --bg: #fafafa; --fg: #1d1d1f; --muted: #6e6e73; --line: #e2e2e6; --card: #fff; --accent: #2f6fde; --danger: #c0392b; }
@media (prefers-color-scheme: dark) {
:root { --bg: #151517; --fg: #f2f2f4; --muted: #9a9aa1; --line: #2c2c31; --card: #1e1e22; --accent: #6c9cff; --danger: #ff6b5e; }
}
* { box-sizing: border-box; }
body { margin: 0 auto; max-width: 860px; padding: 24px 16px; background: var(--bg); color: var(--fg);
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
h1 { font-size: 1.4rem; margin: 0 0 20px; }
h2 { font-size: 1.05rem; margin: 28px 0 10px; }
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; }
.grow { flex: 1; min-width: 0; }
img.cover { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; background: var(--line); flex: none; }
form.inline { display: inline; }
button { font: inherit; padding: 5px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); color: var(--fg); cursor: pointer; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
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; }
</style>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block body %}
<h1>Tag albums</h1>
<h2>Unassigned tags</h2>
<p class="muted">Scan a new card on the reader and it shows up here.</p>
<div hx-get="/unassigned" hx-trigger="every 3s" hx-swap="innerHTML">
{% include "_unassigned.html" %}
</div>
<h2>Assignments <span class="muted">({{ assigned|length }})</span></h2>
<ul class="rows">
{% for tag in assigned %}
<li>
{% if tag.image %}<img class="cover" src="{{ tag.image }}" alt="" loading="lazy">{% else %}<span class="cover"></span>{% endif %}
<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>
<a href="/tags/{{ tag.tag_id }}">Change</a>
<form class="inline" method="post" action="/tags/{{ tag.tag_id }}/unassign"
onsubmit="return confirm('Remove the album from {{ tag.tag_id }}?')">
<button class="danger">Unassign</button>
</form>
</li>
{% else %}
<li class="empty muted">No assignments yet.</li>
{% endfor %}
</ul>
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}{{ tag.tag_id }} · Tag albums{% endblock %}
{% 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>
<h2>Search the music library</h2>
<input type="search" name="q" placeholder="Album or artist…" autofocus autocomplete="off"
hx-get="/tags/{{ tag.tag_id }}/search" hx-trigger="input changed delay:400ms, search"
hx-target="#results" hx-indicator="#searching">
<p id="searching" class="muted htmx-indicator">Searching…</p>
<div id="results"></div>
{% endblock %}