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