2026-06-08 00:01:55 +12:00
|
|
|
"""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
|
2026-06-08 21:58:16 +12:00
|
|
|
from datetime import datetime
|
2026-06-08 00:01:55 +12:00
|
|
|
|
|
|
|
|
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:
|
2026-06-08 21:58:16 +12:00
|
|
|
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
|
2026-06-08 00:01:55 +12:00
|
|
|
return {
|
|
|
|
|
"id": raw.get("id"),
|
|
|
|
|
"title": raw.get("title") or "",
|
|
|
|
|
"track": raw.get("track"),
|
|
|
|
|
"disc": raw.get("discNumber"),
|
|
|
|
|
"artist": raw.get("artist") or "",
|
2026-06-08 21:58:16 +12:00
|
|
|
"artist_id": raw.get("artistId"),
|
2026-06-08 00:01:55 +12:00
|
|
|
"album": raw.get("album") or "",
|
2026-06-08 21:58:16 +12:00
|
|
|
"album_id": raw.get("albumId") or raw.get("parent"),
|
2026-06-08 00:01:55 +12:00
|
|
|
"year": raw.get("year"),
|
2026-06-08 21:58:16 +12:00
|
|
|
"genre": raw.get("genre"),
|
2026-06-08 00:01:55 +12:00
|
|
|
"duration": raw.get("duration") or 0,
|
|
|
|
|
"bitrate": raw.get("bitRate"),
|
|
|
|
|
"suffix": raw.get("suffix"),
|
|
|
|
|
"size": raw.get("size"),
|
|
|
|
|
"path": raw.get("path"),
|
2026-06-08 21:58:16 +12:00
|
|
|
"cover_art": raw.get("coverArt"),
|
|
|
|
|
"cover_url": _cover_url(raw.get("coverArt")),
|
|
|
|
|
"play_count": play_count,
|
|
|
|
|
"created": raw.get("created"),
|
|
|
|
|
"starred": raw.get("starred"),
|
2026-06-08 00:01:55 +12:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 21:58:16 +12:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 00:01:55 +12:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 21:58:16 +12:00
|
|
|
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})
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 00:01:55 +12:00
|
|
|
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],
|
|
|
|
|
}
|
2026-06-08 21:58:16 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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],
|
|
|
|
|
}
|