Homelabtoolkit v1

This commit is contained in:
2026-06-08 00:01:55 +12:00
parent c8838a485d
commit 040fbacc70
56 changed files with 12477 additions and 151 deletions
+308
View File
@@ -0,0 +1,308 @@
"""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
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:
return {
"id": raw.get("id"),
"title": raw.get("title") or "",
"track": raw.get("track"),
"disc": raw.get("discNumber"),
"artist": raw.get("artist") or "",
"album": raw.get("album") or "",
"year": raw.get("year"),
"duration": raw.get("duration") or 0,
"bitrate": raw.get("bitRate"),
"suffix": raw.get("suffix"),
"size": raw.get("size"),
"path": raw.get("path"),
}
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 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],
}