Homelabtoolkit v1
This commit is contained in:
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user