"""Navidrome integration via the Subsonic API. Navidrome speaks the Subsonic/OpenSubsonic REST API. Authentication uses the salted-token scheme (``t = md5(password + salt)``) so the password never travels in the clear. All functions take an ``httpx.AsyncClient`` so they share the app-wide client and stay easy to test. """ from __future__ import annotations import hashlib import os import secrets from datetime import datetime import httpx NAVIDROME_URL = os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533") NAVIDROME_USER = os.environ.get("NAVIDROME_USER", "") NAVIDROME_PASSWORD = os.environ.get("NAVIDROME_PASSWORD", "") NAVIDROME_CLIENT = "HomelabToolkit" SUBSONIC_API_VERSION = "1.16.1" class NavidromeError(Exception): def __init__(self, message: str, status: int = 502): self.message = message self.status = status super().__init__(message) def is_configured() -> bool: return bool(NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD) def _require_configured() -> None: if not is_configured(): raise NavidromeError( "Navidrome is not configured. Set NAVIDROME_URL, NAVIDROME_USER and " "NAVIDROME_PASSWORD.", status=503, ) def _auth_params() -> dict: salt = secrets.token_hex(8) token = hashlib.md5((NAVIDROME_PASSWORD + salt).encode("utf-8")).hexdigest() return { "u": NAVIDROME_USER, "t": token, "s": salt, "v": SUBSONIC_API_VERSION, "c": NAVIDROME_CLIENT, "f": "json", } def _base_url() -> str: return NAVIDROME_URL.rstrip("/") async def _call(client: httpx.AsyncClient, method: str, params: dict | None = None) -> dict: _require_configured() url = f"{_base_url()}/rest/{method}.view" request_params = {**_auth_params(), **(params or {})} try: response = await client.get(url, params=request_params) except httpx.RequestError as exc: raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc try: response.raise_for_status() except httpx.HTTPStatusError as exc: raise NavidromeError( f"Navidrome returned HTTP {response.status_code} for {method}.", status=502 ) from exc try: payload = response.json().get("subsonic-response", {}) except ValueError as exc: raise NavidromeError("Navidrome returned an invalid response.", status=502) from exc if payload.get("status") != "ok": error = payload.get("error") or {} message = error.get("message") or "Navidrome request failed." code = error.get("code") # Subsonic auth failures (codes 40/41) are the user's credentials, not a # server error — surface as 401 so the UI can prompt for setup. status = 401 if code in (40, 41, 44) else 502 raise NavidromeError(message, status=status) return payload def _cover_url(cover_art: str | None) -> str | None: if not cover_art: return None return f"/api/navidrome/cover/{cover_art}" def _map_album(raw: dict) -> dict: return { "id": raw.get("id"), "name": raw.get("name") or raw.get("album") or "", "artist": raw.get("artist") or "", "artist_id": raw.get("artistId"), "year": raw.get("year"), "genre": raw.get("genre"), "song_count": raw.get("songCount") or 0, "duration": raw.get("duration") or 0, "cover_art": raw.get("coverArt"), "cover_url": _cover_url(raw.get("coverArt")), "created": raw.get("created"), "starred": bool(raw.get("starred")), } def _map_song(raw: dict) -> dict: play_count = raw.get("playCount") try: play_count = int(play_count) if play_count is not None else 0 except (TypeError, ValueError): play_count = 0 return { "id": raw.get("id"), "title": raw.get("title") or "", "track": raw.get("track"), "disc": raw.get("discNumber"), "artist": raw.get("artist") or "", "artist_id": raw.get("artistId"), "album": raw.get("album") or "", "album_id": raw.get("albumId") or raw.get("parent"), "year": raw.get("year"), "genre": raw.get("genre"), "duration": raw.get("duration") or 0, "bitrate": raw.get("bitRate"), "suffix": raw.get("suffix"), "size": raw.get("size"), "path": raw.get("path"), "cover_art": raw.get("coverArt"), "cover_url": _cover_url(raw.get("coverArt")), "play_count": play_count, "created": raw.get("created"), "starred": raw.get("starred"), } def _parse_dt(value: str | None) -> datetime | None: if not value: return None try: return datetime.fromisoformat(value.replace("Z", "+00:00")) except ValueError: return None def _dt_timestamp(value: str | None) -> float: parsed = _parse_dt(value) return parsed.timestamp() if parsed else 0.0 async def ping(client: httpx.AsyncClient) -> dict: if not is_configured(): return {"connected": False, "configured": False, "url": NAVIDROME_URL} try: payload = await _call(client, "ping") return { "connected": True, "configured": True, "url": NAVIDROME_URL, "version": payload.get("version"), "server": payload.get("type") or payload.get("serverVersion"), } except NavidromeError as exc: return { "connected": False, "configured": True, "url": NAVIDROME_URL, "error": exc.message, } async def get_artists(client: httpx.AsyncClient) -> list[dict]: payload = await _call(client, "getArtists") indexes = ((payload.get("artists") or {}).get("index")) or [] artists: list[dict] = [] for index in indexes: for artist in index.get("artist") or []: artists.append( { "id": artist.get("id"), "name": artist.get("name") or "", "album_count": artist.get("albumCount") or 0, "cover_art": artist.get("coverArt"), "cover_url": _cover_url(artist.get("coverArt")), } ) artists.sort(key=lambda entry: entry["name"].lower()) return artists async def get_albums( client: httpx.AsyncClient, *, list_type: str = "alphabeticalByName", size: int = 100, offset: int = 0, ) -> list[dict]: payload = await _call( client, "getAlbumList2", {"type": list_type, "size": size, "offset": offset}, ) raw_albums = ((payload.get("albumList2") or {}).get("album")) or [] return [_map_album(album) for album in raw_albums] async def search_albums(client: httpx.AsyncClient, query: str, *, count: int = 60) -> list[dict]: payload = await _call( client, "search3", {"query": query, "albumCount": count, "artistCount": 0, "songCount": 0}, ) raw_albums = ((payload.get("searchResult3") or {}).get("album")) or [] return [_map_album(album) for album in raw_albums] async def get_album(client: httpx.AsyncClient, album_id: str) -> dict: payload = await _call(client, "getAlbum", {"id": album_id}) raw = payload.get("album") or {} album = _map_album(raw) album["songs"] = [_map_song(song) for song in (raw.get("song") or [])] return album async def search_songs(client: httpx.AsyncClient, query: str, *, count: int = 100, offset: int = 0) -> list[dict]: payload = await _call( client, "search3", {"query": query, "artistCount": 0, "albumCount": 0, "songCount": count, "songOffset": offset}, ) raw_songs = ((payload.get("searchResult3") or {}).get("song")) or [] return [_map_song(song) for song in raw_songs] async def get_now_playing(client: httpx.AsyncClient) -> list[dict]: payload = await _call(client, "getNowPlaying") raw = ((payload.get("nowPlaying") or {}).get("entry")) or [] return [_map_song(song) for song in raw] async def get_starred(client: httpx.AsyncClient) -> dict: payload = await _call(client, "getStarred2") raw = payload.get("starred2") or {} return { "songs": [_map_song(song) for song in (raw.get("song") or [])], "albums": [_map_album(album) for album in (raw.get("album") or [])], "artists": [ { "id": artist.get("id"), "name": artist.get("name") or "", "cover_art": artist.get("coverArt"), "cover_url": _cover_url(artist.get("coverArt")), "starred": artist.get("starred"), } for artist in (raw.get("artist") or []) ], } async def get_playlists(client: httpx.AsyncClient) -> list[dict]: payload = await _call(client, "getPlaylists") raw = ((payload.get("playlists") or {}).get("playlist")) or [] playlists = [ { "id": p.get("id"), "name": p.get("name") or "", "song_count": p.get("songCount") or 0, "owner": p.get("owner") or "", "public": bool(p.get("public")), "duration": p.get("duration") or 0, "changed": p.get("changed"), } for p in raw ] playlists.sort(key=lambda entry: entry["name"].lower()) return playlists async def delete_playlist(client: httpx.AsyncClient, playlist_id: str) -> None: await _call(client, "deletePlaylist", {"id": playlist_id}) async def get_cover_art( client: httpx.AsyncClient, cover_id: str, size: int | None = None ) -> tuple[bytes, str]: _require_configured() url = f"{_base_url()}/rest/getCoverArt.view" params = {**_auth_params(), "id": cover_id} if size: params["size"] = size try: response = await client.get(url, params=params) except httpx.RequestError as exc: raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc if response.status_code != 200: raise NavidromeError("Cover art not found.", status=404) content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0] if not content_type.startswith("image/"): # Subsonic returns a JSON error document on failure. raise NavidromeError("Cover art not found.", status=404) return response.content, content_type async def start_scan(client: httpx.AsyncClient, *, full: bool = False) -> dict: """Trigger a Navidrome library scan (Subsonic ``startScan`` extension).""" payload = await _call(client, "startScan", {"fullScan": "true" if full else "false"}) status = payload.get("scanStatus") or {} return {"scanning": bool(status.get("scanning")), "count": status.get("count")} async def get_genres(client: httpx.AsyncClient) -> list[dict]: payload = await _call(client, "getGenres") raw = ((payload.get("genres") or {}).get("genre")) or [] genres = [ { "name": g.get("value") or g.get("name") or "Unknown", "song_count": g.get("songCount") or 0, "album_count": g.get("albumCount") or 0, } for g in raw ] genres.sort(key=lambda g: g["song_count"], reverse=True) return genres async def get_format_breakdown( client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400 ) -> dict: """Count tracks by file format (flac, mp3, m4a, …) by paging all songs. Subsonic has no aggregate format endpoint, so we walk ``search3`` with an empty query (Navidrome returns the whole library) and tally each song's ``suffix``. Cap the page count so a runaway library can't loop forever. """ counts: dict[str, int] = {} offset = 0 for _ in range(max_pages): payload = await _call( client, "search3", { "query": "", "artistCount": 0, "albumCount": 0, "songCount": page_size, "songOffset": offset, }, ) songs = ((payload.get("searchResult3") or {}).get("song")) or [] if not songs: break for song in songs: suffix = (song.get("suffix") or "").lower() or "other" counts[suffix] = counts.get(suffix, 0) + 1 if len(songs) < page_size: break offset += page_size total = sum(counts.values()) formats = sorted( ({"format": fmt, "count": count} for fmt, count in counts.items()), key=lambda entry: entry["count"], reverse=True, ) return {"total": total, "formats": formats} async def get_stats(client: httpx.AsyncClient) -> dict: """Library stats for the dashboard: artists, albums, tracks and genres.""" artists = await get_artists(client) album_count = sum(artist["album_count"] for artist in artists) try: genres = await get_genres(client) except NavidromeError: genres = [] song_count = sum(g["song_count"] for g in genres) return { "artist_count": len(artists), "album_count": album_count, "song_count": song_count, "genre_count": len(genres), "top_genres": genres[:8], } async def get_reporting_snapshot( client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400 ) -> dict: stats = await get_stats(client) # Keep the API calls explicit so they remain easy to debug. top_albums = await get_albums(client, list_type="frequent", size=12) recent_albums = await get_albums(client, list_type="recent", size=12) newest_albums = await get_albums(client, list_type="newest", size=12) starred = await get_starred(client) now_playing = await get_now_playing(client) all_songs: list[dict] = [] offset = 0 scanned_pages = 0 truncated = False for _ in range(max_pages): scanned_pages += 1 songs = await search_songs(client, "", count=page_size, offset=offset) if not songs: break all_songs.extend(songs) if len(songs) < page_size: break offset += page_size else: truncated = True songs_with_plays = [song for song in all_songs if (song.get("play_count") or 0) > 0] total_play_count = sum(song.get("play_count") or 0 for song in songs_with_plays) top_tracks = sorted( songs_with_plays, key=lambda song: ((song.get("play_count") or 0), song.get("title") or "", song.get("artist") or ""), reverse=True, )[:25] recently_added_tracks = sorted( [song for song in all_songs if song.get("created")], key=lambda song: _dt_timestamp(song.get("created")), reverse=True, )[:25] favorite_tracks = sorted( starred["songs"], key=lambda song: ((song.get("play_count") or 0), _dt_timestamp(song.get("starred"))), reverse=True, )[:25] return { "summary": { **stats, "library_tracks_scanned": len(all_songs), "tracks_with_plays": len(songs_with_plays), "total_play_count": total_play_count, "favorite_song_count": len(starred["songs"]), "favorite_album_count": len(starred["albums"]), "favorite_artist_count": len(starred["artists"]), "now_playing_count": len(now_playing), "scan_pages": scanned_pages, "truncated": truncated, }, "top_tracks": top_tracks, "favorite_tracks": favorite_tracks, "recently_added_tracks": recently_added_tracks, "top_albums": top_albums, "recent_albums": recent_albums, "newest_albums": newest_albums, "top_genres": stats["top_genres"], "now_playing": now_playing, "favorite_albums": starred["albums"][:20], "favorite_artists": starred["artists"][:20], }