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