Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+157
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import os
import secrets
from datetime import datetime
import httpx
@@ -115,22 +116,49 @@ def _map_album(raw: dict) -> dict:
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}
@@ -205,6 +233,64 @@ async def get_album(client: httpx.AsyncClient, album_id: str) -> dict:
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]:
@@ -306,3 +392,74 @@ async def get_stats(client: httpx.AsyncClient) -> dict:
"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],
}