From 91e4519503fb9797083899f726a0710623adf408 Mon Sep 17 00:00:00 2001 From: twisla Date: Fri, 25 Sep 2026 21:34:41 +0000 Subject: [PATCH] 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 --- README.md | 15 +++++++++++++++ src/tag_albums/app.py | 18 +++++++++++++++--- src/tag_albums/cli.py | 18 +++++++++++++----- src/tag_albums/config.py | 5 +++++ src/tag_albums/library.py | 18 +++++++++++++++--- tests/test_app.py | 25 +++++++++++++++++++++++++ tests/test_library.py | 12 ++++++++++++ 7 files changed, 100 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 514ceb1..8e6ebd9 100644 --- a/README.md +++ b/README.md @@ -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://: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 diff --git a/src/tag_albums/app.py b/src/tag_albums/app.py index 9b852be..1413c3e 100644 --- a/src/tag_albums/app.py +++ b/src/tag_albums/app.py @@ -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") diff --git a/src/tag_albums/cli.py b/src/tag_albums/cli.py index 3bfeed5..a5f7d2a 100644 --- a/src/tag_albums/cli.py +++ b/src/tag_albums/cli.py @@ -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.") diff --git a/src/tag_albums/config.py b/src/tag_albums/config.py index 5ddb52b..cb99fdf 100644 --- a/src/tag_albums/config.py +++ b/src/tag_albums/config.py @@ -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, ) diff --git a/src/tag_albums/library.py b/src/tag_albums/library.py index 9be1bb9..3730c1e 100644 --- a/src/tag_albums/library.py +++ b/src/tag_albums/library.py @@ -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/"): diff --git a/tests/test_app.py b/tests/test_app.py index ef7cc55..1f750e3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_library.py b/tests/test_library.py index 3305083..8777e5d 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -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"