Add MA_URL to download covers through a reverse proxy

The jail can't reach Music Assistant's port 8095. MA_URL replaces the host of Music Assistant's image URLs when downloading. fetch-covers now stops at the first connection failure instead of stalling on every album, and image connections time out after 3 seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 21:34:41 +00:00
co-authored by Claude Opus 5.5
parent 1abba34cfe
commit 91e4519503
7 changed files with 100 additions and 11 deletions
+15
View File
@@ -38,6 +38,7 @@ Environment variables:
| `HA_URL` | Home Assistant base URL, e.g. `https://ha.home.knbg` |
| `HA_TOKEN` | HA long-lived access token, used to search the Music Assistant library |
| `DB_PATH` | SQLite file (default `tag_albums.sqlite3`); its directory must be writable |
| `MA_URL` | Optional. Address to download Music Assistant cover images from, e.g. `https://ma.home.knbg`, when Music Assistant's own address (`http://<host>:8095`) isn't reachable from the service. See [Album covers](#album-covers). |
HTTPS to HA is verified against the system trust store, so a home CA must be installed on the host. If it isn't, point `SSL_CERT_FILE` at the CA bundle.
@@ -112,6 +113,20 @@ su -m tagalbums -c 'set -a; . /usr/local/etc/tag_albums.env; set +a; .venv/bin/t
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.
The service downloads covers from the address Music Assistant reports: its own host and port 8095, over plain http. If the jail can't reach that, put Music Assistant's image proxy behind the reverse proxy and set `MA_URL` to it. For example, in Caddy (use the same TLS setup as your other sites):
```caddyfile
ma.home.knbg {
@images path /imageproxy/*
handle @images {
reverse_proxy 172.16.40.250:8095
}
respond 404
}
```
Then add `MA_URL=https://ma.home.knbg` to the env file and restart the service. Only `/imageproxy/*` is exposed, not the Music Assistant interface. If `fetch-covers` can't connect, it stops at the first cover and says so.
### Updating
```sh
+15 -3
View File
@@ -43,10 +43,22 @@ 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."""
def store_cover(
store: Store, library: MusicLibrary, tag_id: str, 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.
With raise_unreachable, failing to connect to Music Assistant at all
raises instead, since then no other cover will download either.
"""
try:
data, content_type = library.fetch_image(image)
except httpx.TransportError:
if raise_unreachable:
raise
log.warning("Can't reach Music Assistant for the cover of %s at %s", tag_id, image)
return False
except (httpx.HTTPError, ValueError) as e:
log.info("No cover for %s from %s: %s", tag_id, image, e)
return False
@@ -56,7 +68,7 @@ def store_cover(store: Store, library: MusicLibrary, tag_id: str, image: str, sa
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)
library = library or MusicLibrary(settings.ha_url, settings.ha_token, ma_url=settings.ma_url)
app = FastAPI(title="Tag albums", docs_url=None, redoc_url=None)
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static")
+13 -5
View File
@@ -42,10 +42,17 @@ def fetch_covers(store: Store, library) -> tuple[list[str], list[str]]:
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)
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)."
)
(found if stored else missing).append(label)
return found, missing
@@ -78,7 +85,8 @@ def main() -> None:
settings = Settings.from_env()
store = Store(settings.db_path)
try:
found, missing = fetch_covers(store, MusicLibrary(settings.ha_url, settings.ha_token))
library = MusicLibrary(settings.ha_url, settings.ha_token, ma_url=settings.ma_url)
found, missing = fetch_covers(store, library)
finally:
store.close()
print(f"Stored {len(found)} covers.")
+5
View File
@@ -8,6 +8,10 @@ class Settings:
api_token: str
ha_url: str
ha_token: str
# Where to download Music Assistant cover images from, when the address
# Music Assistant reports (its own host and port) isn't reachable from
# here, e.g. a reverse proxy in front of it. None: use the address as-is.
ma_url: str | None = None
@classmethod
def from_env(cls) -> "Settings":
@@ -19,4 +23,5 @@ class Settings:
api_token=os.environ["API_TOKEN"],
ha_url=os.environ["HA_URL"].rstrip("/"),
ha_token=os.environ["HA_TOKEN"],
ma_url=os.environ.get("MA_URL", "").rstrip("/") or None,
)
+15 -3
View File
@@ -24,6 +24,7 @@ class MusicLibrary:
ha_token: str,
client: httpx.Client | None = None,
image_client: httpx.Client | None = None,
ma_url: str | 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.
@@ -35,10 +36,21 @@ class MusicLibrary:
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)
# never carries the HA token. A short connect timeout, so an
# unreachable Music Assistant fails fast instead of stalling.
self._images = image_client or httpx.Client(
timeout=httpx.Timeout(10, connect=3), verify=verify
)
self._ma_url = httpx.URL(ma_url) if ma_url else None
self._entry_id: str | None = None
def image_download_url(self, url: str) -> str:
"""Where to download a Music Assistant image from: MA_URL replaces its host."""
if self._ma_url is None:
return url
u = httpx.URL(url)
return str(u.copy_with(scheme=self._ma_url.scheme, host=self._ma_url.host, port=self._ma_url.port))
def _music_assistant_entry(self) -> str:
if self._entry_id is None:
r = self._client.get(
@@ -84,7 +96,7 @@ class MusicLibrary:
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 = self._images.get(self.image_download_url(url))
r.raise_for_status()
content_type = r.headers.get("content-type", "").split(";")[0].strip()
if not content_type.startswith("image/"):
+25
View File
@@ -176,3 +176,28 @@ def test_import_yaml(tmp_path):
store = Store(db)
assert store.get("29-92-68-C5").album == "Van Woede Tot Wanhoop"
store.close()
def test_fetch_covers_stops_when_music_assistant_is_unreachable(store, library):
import httpx
import pytest
def unreachable(url):
raise httpx.ConnectTimeout("timed out")
library.fetch_image = unreachable
library.image_download_url = lambda url: url
store.assign("A", "Tool", "Lateralus")
store.assign("B", "Kiasmos", "Kiasmos")
with pytest.raises(SystemExit, match="Set MA_URL"):
fetch_covers(store, library)
def test_settings_read_ma_url(monkeypatch):
from tag_albums.config import Settings
for k, v in {"API_TOKEN": "t", "HA_URL": "https://ha/", "HA_TOKEN": "h", "MA_URL": "https://ma.example/"}.items():
monkeypatch.setenv(k, v)
assert Settings.from_env().ma_url == "https://ma.example"
monkeypatch.delenv("MA_URL")
assert Settings.from_env().ma_url is None
+12
View File
@@ -63,3 +63,15 @@ def test_find_album_needs_exact_names():
]
assert library.find_album("Tool", "Lateralus").image == "http://img/1"
assert library.find_album("Mogwai", "Lateralus") is None
def test_ma_url_replaces_music_assistant_host_for_downloads():
library, seen = _library_with_images({
"https://ma.example/imageproxy/abc?size=0":
httpx.Response(200, content=b"jpg", headers={"content-type": "image/jpeg"}),
})
library._ma_url = httpx.URL("https://ma.example")
assert library.fetch_image("http://172.16.40.250:8095/imageproxy/abc?size=0") == (b"jpg", "image/jpeg")
assert str(seen[0].url) == "https://ma.example/imageproxy/abc?size=0"
# Without MA_URL the address is used as Music Assistant reports it.
assert MusicLibrary("http://ha", "t", client=httpx.Client()).image_download_url("http://x:8095/a") == "http://x:8095/a"