Vendor htmx, add Dockerfile, show a placeholder for missing covers

Serve htmx 2.0.4 from the package instead of the jsdelivr CDN. Add a Dockerfile for local testing. Cover images now sit in a styled placeholder box that stays visible when Music Assistant has no cover for an album.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 17:42:46 +00:00
co-authored by Claude Opus 5.5
parent eaf77dd0de
commit 8ef3f13069
10 changed files with 70 additions and 4 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.venv
**/__pycache__
*.sqlite3
*.sqlite3-*
.env
.envrc
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /usr/local/bin/uv
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
# Dependencies first, so code changes don't reinstall them.
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY src ./src
RUN uv sync --locked --no-dev
RUN useradd --system --no-create-home app && mkdir /data && chown app /data
USER app
ENV PATH=/app/.venv/bin:$PATH \
DB_PATH=/data/tag_albums.sqlite3
VOLUME /data
EXPOSE 8087
CMD ["tag-albums", "serve", "--port", "8087"]
+15
View File
@@ -43,6 +43,21 @@ uv run pytest
API_TOKEN=dev HA_URL=https://ha.home.knbg HA_TOKEN=... uv run tag-albums serve --port 8087
```
## Running with Docker
For local testing. The container has to trust the home CA to reach HA, so mount the CA certificate and point `SSL_CERT_FILE` at it:
```sh
docker build -t tag-albums .
docker run --rm -p 8087:8087 -v tag-albums-data:/data \
-e API_TOKEN=dev -e HA_URL=https://ha.home.knbg -e HA_TOKEN=... \
-v /usr/local/share/ca-certificates/KNBG_Root_CA_306526670621344964268144771797781855543.crt:/ca.crt:ro \
-e SSL_CERT_FILE=/ca.crt \
tag-albums
```
To import assignments, run `tag-albums import-yaml` inside the container, e.g. `docker run --rm -v tag-albums-data:/data -v $PWD/tag_albums.yaml:/import.yaml:ro tag-albums tag-albums import-yaml /import.yaml`.
## Deploying in a FreeBSD jail
```sh
+2
View File
@@ -7,6 +7,7 @@ from pathlib import Path
import httpx
from fastapi import Depends, FastAPI, Form, Header, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
@@ -40,6 +41,7 @@ def create_app(settings: Settings, store: Store | None = None, library: MusicLib
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")
def require_token(authorization: str = Header(default="")) -> None:
scheme, _, token = authorization.partition(" ")
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+1 -1
View File
@@ -4,7 +4,7 @@
<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 %}
<span class="cover">{% if a.image %}<img src="{{ 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 }}">
+3 -2
View File
@@ -4,7 +4,7 @@
<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>
<script src="/static/htmx-2.0.4.min.js"></script>
<style>
:root { --bg: #fafafa; --fg: #1d1d1f; --muted: #6e6e73; --line: #e2e2e6; --card: #fff; --accent: #2f6fde; --danger: #c0392b; }
@media (prefers-color-scheme: dark) {
@@ -22,7 +22,8 @@
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; }
.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; }
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; }
+1 -1
View File
@@ -12,7 +12,7 @@
<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 %}
<span class="cover">{% if tag.image %}<img src="{{ tag.image }}" 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>
+6
View File
@@ -63,6 +63,12 @@ def test_pages_render(client, store):
assert client.get("/tags/ZZ").status_code == 404
def test_htmx_is_served_locally(client):
assert 'src="/static/htmx-2.0.4.min.js"' in client.get("/").text
r = client.get("/static/htmx-2.0.4.min.js")
assert r.status_code == 200 and r.text.startswith("var htmx=")
def test_search_results(client, library):
r = client.get("/tags/AA-BB/search", params={"q": " lateralus "})
assert library.queries == ["lateralus"]