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
+718 -7
View File
@@ -1,5 +1,6 @@
import asyncio
import io
import json
import logging
import os
import hashlib
@@ -9,6 +10,9 @@ import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import rotate_preroll
logging.basicConfig(
level=logging.INFO,
@@ -23,18 +27,27 @@ from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingRes
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError
from services import audiobookshelf as abs_service
from services import cache_maintenance
from services import db as db_service
from services import emby_tasks as emby_tasks_service
from services import favorites as favorites_service
from services import homescreen_editor as homescreen_service
from services import music_covers as music_service
from services import music_library as library_service
from services import music_metadata as metadata_service
from services import navidrome as navidrome_service
from services import settings as settings_service
from services import self_update as self_update_service
from services.favorites import FavoritesError
from services.music_covers import ProcessOptions
from services.navidrome import NavidromeError
from services.recommendations import DEFAULT_TARGET_SIZE
Image.MAX_IMAGE_PIXELS = None # Emby backdrops can exceed PIL's default bomb threshold; source is trusted
# Emby backdrops can exceed PIL's default ~89MP bomb threshold, but leaving it
# unbounded lets one pathological image OOM the container. Cap at a generous but
# finite ceiling (≈178MP, 2× PIL's default) so memory per decode stays bounded.
Image.MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", 178_956_970))
EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a")
@@ -66,6 +79,18 @@ NEW_SEASON_MAX_AGE_DAYS = 21
SEASON_INFERENCE_LOOKBACK_DAYS = 180
airing_lookup_cache: dict[int, dict] = {}
airing_lookup_lock = asyncio.Lock()
preroll_task_lock = asyncio.Lock()
preroll_scheduler: asyncio.Task | None = None
emby_tasks_lock = asyncio.Lock()
emby_tasks_scheduler: asyncio.Task | None = None
preroll_runtime = {
"running": False,
"last_started_at": None,
"last_finished_at": None,
"last_status": "idle",
"last_message": None,
"last_result": None,
}
# ── Studio logo file map ─────────────────────────────────────────────────────
STUDIOS_DIR = Path("static/studios")
@@ -308,17 +333,164 @@ def apply_settings(values: dict) -> None:
navidrome_service.NAVIDROME_URL = values["navidrome_url"]
navidrome_service.NAVIDROME_USER = values["navidrome_user"]
navidrome_service.NAVIDROME_PASSWORD = values["navidrome_password"]
abs_service.ABS_URL = values["audiobookshelf_url"]
abs_service.ABS_TOKEN = values["audiobookshelf_token"]
music_service.MUSIC_ROOT = Path(values["music_root"])
PREROLL_WEEKDAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
def preroll_config_from_settings(values: dict | None = None) -> rotate_preroll.PrerollConfig:
values = values or settings_service.load()
return rotate_preroll.PrerollConfig(
active_dir=Path(values["preroll_active_dir"]),
inactive_dir=Path(values["preroll_inactive_dir"]),
state_file=Path(values["preroll_state_file"]),
rotate_weekday=int(values["preroll_weekday"]),
schedule_time=str(values["preroll_time"]).strip(),
)
def preroll_task_due(config: rotate_preroll.PrerollConfig) -> bool:
hour, minute = rotate_preroll.parse_schedule_time(config.schedule_time)
current = rotate_preroll.now()
if current.weekday() != config.rotate_weekday:
return False
scheduled = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
return current >= scheduled
def preroll_status_payload(values: dict | None = None) -> dict:
values = values or settings_service.load()
config = preroll_config_from_settings(values)
next_run_at = None
schedule_error = None
due_now = False
try:
rotate_preroll.parse_schedule_time(config.schedule_time)
due_now = bool(values["preroll_enabled"]) and preroll_task_due(config) and rotate_preroll.should_rotate(config, quiet=True)
next_run = rotate_preroll.now() if due_now else rotate_preroll.next_run_after(config)
next_run_at = next_run.isoformat(timespec="seconds")
except Exception as exc:
schedule_error = str(exc)
return {
"enabled": bool(values["preroll_enabled"]),
"weekday_label": PREROLL_WEEKDAYS[int(values["preroll_weekday"]) % 7],
"next_run_at": next_run_at,
"due_now": due_now,
"schedule_error": schedule_error,
"runtime": dict(preroll_runtime),
"state": rotate_preroll.read_rotation_state(config),
}
async def run_preroll_task(*, force: bool) -> dict:
async with preroll_task_lock:
values = settings_service.load()
config = preroll_config_from_settings(values)
preroll_runtime["running"] = True
preroll_runtime["last_started_at"] = rotate_preroll.now().isoformat(timespec="seconds")
preroll_runtime["last_message"] = None
result = await asyncio.to_thread(rotate_preroll.run_rotation, config, force=force)
preroll_runtime["running"] = False
preroll_runtime["last_finished_at"] = rotate_preroll.now().isoformat(timespec="seconds")
preroll_runtime["last_status"] = "ok" if result.get("ok") else "error"
preroll_runtime["last_message"] = result.get("message")
preroll_runtime["last_result"] = result
return result
async def _preroll_scheduler_loop() -> None:
while True:
try:
values = settings_service.load()
if values["preroll_enabled"]:
config = preroll_config_from_settings(values)
if preroll_task_due(config) and rotate_preroll.should_rotate(config, quiet=True):
result = await run_preroll_task(force=False)
if result.get("ok"):
logger.info("Preroll rotation completed: %s", result.get("message"))
else:
logger.warning("Preroll rotation failed: %s", result.get("message"))
except Exception as exc: # pragma: no cover - keep scheduler alive
logger.warning("Preroll scheduler failed: %s", exc)
await asyncio.sleep(30)
async def run_library_task(task_id: str, *, dry_run: bool, automated: bool = False) -> dict:
async with emby_tasks_lock:
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
result = await emby_tasks_service.run_task(task_id, emby_client_adapter, task_settings, dry_run=dry_run)
emby_tasks_service.record_run(task_id, result, automated=automated)
return result
async def _emby_tasks_scheduler_loop() -> None:
while True:
try:
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
state = emby_tasks_service.load_state()
for task in emby_tasks_service.describe_tasks(task_settings):
if not task["supports_run"] or not task["supports_automation"]:
continue
if emby_tasks_service.is_due(task["id"], task["settings"], state):
result = await run_library_task(task["id"], dry_run=False, automated=True)
if result.get("ok"):
logger.info("Automated %s task %s completed: %s", task["section"], task["id"], result.get("message"))
else:
logger.warning("Automated %s task %s failed: %s", task["section"], task["id"], result.get("message"))
state = emby_tasks_service.load_state()
except Exception as exc: # pragma: no cover - keep scheduler alive
logger.warning("Cleanup task scheduler failed: %s", exc)
await asyncio.sleep(45)
async def _cache_sweeper() -> None:
"""Periodically evict old/oversized cache files so disk stays bounded."""
interval = max(5, cache_maintenance.SWEEP_INTERVAL_MIN) * 60
while True:
try:
result = await asyncio.to_thread(cache_maintenance.prune, CACHE_DIR)
if result["removed"]:
logger.info(
"Cache prune: removed %d file(s), freed %.1f MB (kept %.1f MB)",
result["removed"], result["freed_mb"], result["kept_mb"],
)
except Exception as exc: # pragma: no cover - never let the sweeper crash
logger.warning("Cache prune failed: %s", exc)
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings_service.init_store()
apply_settings(settings_service.load())
db_service.init_db()
get_http_client()
sweeper = asyncio.create_task(_cache_sweeper())
global preroll_scheduler
global emby_tasks_scheduler
preroll_scheduler = asyncio.create_task(_preroll_scheduler_loop())
emby_tasks_scheduler = asyncio.create_task(_emby_tasks_scheduler_loop())
try:
yield
finally:
sweeper.cancel()
if preroll_scheduler is not None:
preroll_scheduler.cancel()
if emby_tasks_scheduler is not None:
emby_tasks_scheduler.cancel()
global http_client
if http_client is not None:
await http_client.aclose()
@@ -337,7 +509,11 @@ if (FRONTEND_DIST / "assets").exists():
def get_http_client() -> httpx.AsyncClient:
global http_client
if http_client is None:
http_client = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
# Bound the connection pool so idle keep-alives don't accumulate memory.
http_client = httpx.AsyncClient(
timeout=HTTP_TIMEOUT,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=5),
)
return http_client
@@ -2910,6 +3086,10 @@ async def get_config():
"url": navidrome_service.NAVIDROME_URL,
"configured": navidrome_service.is_configured(),
},
"audiobookshelf": {
"url": abs_service.ABS_URL,
"configured": abs_service.is_configured(),
},
"music": {
"root": str(music_service.MUSIC_ROOT),
"available": music_service.MUSIC_ROOT.exists(),
@@ -2917,6 +3097,13 @@ async def get_config():
}
def _request_client_host(request: Request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded
return request.client.host if request.client else None
# ── Dashboard overview ───────────────────────────────────────────────────────
@@ -3125,6 +3312,18 @@ async def update_settings(request: Request):
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Settings payload must be an object.")
if "preroll_weekday" in body:
try:
weekday = int(body["preroll_weekday"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Preroll weekday must be a number from 0 to 6.") from exc
if weekday < 0 or weekday > 6:
raise HTTPException(status_code=400, detail="Preroll weekday must be between 0 and 6.")
if "preroll_time" in body:
try:
rotate_preroll.parse_schedule_time(str(body["preroll_time"]).strip())
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid preroll time: {exc}") from exc
values = settings_service.save(body)
apply_settings(values)
navidrome_status = await navidrome_service.ping(get_http_client())
@@ -3135,6 +3334,375 @@ async def update_settings(request: Request):
}
@app.get("/api/update/status")
async def get_update_status(request: Request):
values = settings_service.load()
return self_update_service.status_payload(values, _request_client_host(request))
@app.post("/api/update/run")
async def run_update(request: Request):
values = settings_service.load()
status = self_update_service.status_payload(values, _request_client_host(request))
if not status["allowed"]:
raise HTTPException(status_code=403, detail=status["reason"] or "Updates are only available from the local network.")
result = await self_update_service.start_update(values, _request_client_host(request))
if not result.get("ok"):
raise HTTPException(status_code=409, detail=result.get("message") or "Deployment failed to start.")
return {
"result": result,
"status": self_update_service.status_payload(values, _request_client_host(request)),
}
# ── Homescreen Editor ────────────────────────────────────────────────────────
@app.get("/api/homescreen/enums")
async def homescreen_enums():
return homescreen_service.enums_payload()
@app.get("/api/homescreen/db-source")
async def homescreen_db_source():
return {"upload": homescreen_service.get_active_upload()}
@app.post("/api/homescreen/db-upload")
async def homescreen_db_upload(request: Request):
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(status_code=400, detail="No database file uploaded.")
filename = getattr(upload, "filename", "") or "users.db"
try:
content = await upload.read()
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not read uploaded file: {exc}") from exc
try:
meta = await asyncio.to_thread(homescreen_service.save_uploaded_db, filename, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not store uploaded database: {exc}") from exc
return {"upload": meta}
@app.post("/api/homescreen/db-read")
async def homescreen_db_read(request: Request):
try:
body = await request.json()
except Exception:
body = {}
values = settings_service.load()
requested_path = str((body or {}).get("dbPath") or values.get("homescreen_db_path") or "").strip()
upload_id = str((body or {}).get("uploadId") or "").strip() or None
try:
db_path, upload_meta = await asyncio.to_thread(homescreen_service.resolve_db_source, requested_path, upload_id)
result = await asyncio.to_thread(homescreen_service.read_db, db_path)
enriched = homescreen_service.apply_cached_emby_names(result["users"])
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
**result,
"users": enriched["users"],
"validation": {
**result["validation"],
"embyCacheMatchedUsers": enriched["cache"]["matchedCount"],
"embyCacheUserCount": enriched["cache"]["totalCachedUsers"],
"embyCacheLastSyncedAt": enriched["cache"]["lastSyncedAt"],
},
"source": {
"mode": "upload" if upload_meta else "path",
"db_path": db_path,
"upload": upload_meta,
},
}
@app.post("/api/homescreen/db-write")
async def homescreen_db_write(request: Request):
try:
body = await request.json()
except Exception:
body = {}
values = settings_service.load()
requested_path = str((body or {}).get("dbPath") or values.get("homescreen_db_path") or "").strip()
upload_id = str((body or {}).get("uploadId") or "").strip() or None
changes = (body or {}).get("changes") if isinstance(body, dict) else None
if not isinstance(changes, list) or not changes:
return {"ok": True, "count": 0, "normalizedSections": 0}
try:
db_path, upload_meta = await asyncio.to_thread(homescreen_service.resolve_db_source, requested_path, upload_id)
result = await asyncio.to_thread(homescreen_service.write_db, db_path, changes)
return {**result, "source": {"mode": "upload" if upload_meta else "path", "db_path": db_path, "upload": upload_meta}}
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/homescreen/sql-preview")
async def homescreen_sql_preview(request: Request):
try:
body = await request.json()
except Exception:
body = {}
users = (body or {}).get("users") if isinstance(body, dict) else None
original_users = (body or {}).get("originalUsers") if isinstance(body, dict) else None
if not isinstance(users, list) or not isinstance(original_users, list):
raise HTTPException(status_code=400, detail="Provide users and originalUsers arrays.")
return {"sql": homescreen_service.generate_sql(users, original_users)}
@app.get("/api/homescreen/emby-users")
async def homescreen_emby_users():
values = settings_service.load()
if not values.get("emby_url") or not values.get("emby_api_key"):
raise HTTPException(status_code=400, detail="Emby URL and API key not configured.")
try:
users = await emby_get("/Users")
cached = homescreen_service.write_cached_emby_users(
[{"embyGuid": user.get("Id"), "name": user.get("Name")} for user in users if user.get("Id") and user.get("Name")]
)
return {
"users": cached["users"],
"source": "live",
"lastSyncedAt": cached["lastSyncedAt"],
}
except Exception as exc:
cached = homescreen_service.read_cached_emby_users()
if cached["users"]:
return {
"users": cached["users"],
"source": "cache",
"lastSyncedAt": cached["lastSyncedAt"],
"message": f"Using cached Emby users because the server could not be reached: {exc}",
}
raise HTTPException(status_code=502, detail=f"Could not reach Emby server: {exc}") from exc
async def _homescreen_fetch_all_user_items(emby_guid: str, params: dict[str, Any]) -> list[dict]:
all_items: list[dict] = []
start_index = 0
page_size = 200
while True:
payload = await emby_get(
f"/Users/{emby_guid}/Items",
{
"Recursive": "true",
"GroupItemsIntoCollections": "false",
"Limit": str(page_size),
"StartIndex": str(start_index),
**{key: str(value) for key, value in params.items()},
},
)
page_items = payload.get("Items") or payload or []
all_items.extend(page_items)
total = int(payload.get("TotalRecordCount") or 0)
if not page_items or (total and len(all_items) >= total) or len(page_items) < page_size:
break
start_index += len(page_items)
return all_items
@app.get("/api/homescreen/user-context")
async def homescreen_user_context(embyGuid: str = Query(""), excludedIds: str = Query("")):
emby_guid = homescreen_service.normalize_guid(embyGuid)
excluded_ids = [item.strip() for item in excludedIds.split(",") if item.strip()]
if not emby_guid:
raise HTTPException(status_code=400, detail="Missing embyGuid.")
cached = homescreen_service.read_cached_user_context(emby_guid)
try:
views_payload, recently_played_payload = await asyncio.gather(
emby_get(f"/Users/{emby_guid}/Views"),
_homescreen_fetch_all_user_items(
emby_guid,
{
"Filters": "IsPlayed",
"IncludeItemTypes": "Movie,Series",
"SortBy": "DatePlayed",
"SortOrder": "Descending",
"Fields": "UserData",
},
),
)
excluded_lookup: dict[str, Any] = {}
if excluded_ids:
excluded_payload = await emby_get(
f"/Users/{emby_guid}/Items",
{"Ids": ",".join(excluded_ids), "Fields": "Path"},
)
for item in excluded_payload.get("Items") or excluded_payload or []:
if item.get("Id"):
excluded_lookup[str(item["Id"])] = {
"name": item.get("Name") or item.get("Path") or f"Item {item['Id']}",
"type": item.get("CollectionType") or item.get("Type") or "Item",
}
context = homescreen_service.write_cached_user_context(
emby_guid,
{
"views": [
{
"id": str(item.get("Id") or ""),
"name": item.get("Name") or "Unnamed view",
"type": item.get("CollectionType") or item.get("Type") or "View",
}
for item in (views_payload.get("Items") or views_payload or [])
],
"recentlyPlayed": [
{
"id": str(item.get("Id") or ""),
"name": item.get("Name") or item.get("SeriesName") or "Unknown item",
"type": item.get("Type") or "Item",
"seriesName": item.get("SeriesName"),
"datePlayed": ((item.get("UserData") or {}).get("LastPlayedDate")) or item.get("DateLastMediaAdded"),
"isPlayed": (item.get("UserData") or {}).get("Played", True),
}
for item in recently_played_payload
],
"excludedFolderLookup": excluded_lookup,
"lastSyncedAt": datetime.now().astimezone().isoformat(timespec="seconds"),
},
)
return {**context, "source": "live"}
except Exception as exc:
if cached:
filtered_lookup = (
{key: value for key, value in (cached.get("excludedFolderLookup") or {}).items() if key in excluded_ids}
if excluded_ids
else (cached.get("excludedFolderLookup") or {})
)
return {
**cached,
"excludedFolderLookup": filtered_lookup,
"source": "cache",
"message": f"Using cached Emby user context because live fetch failed: {exc}",
}
raise HTTPException(status_code=502, detail=f"Could not load Emby user context: {exc}") from exc
@app.get("/api/tasks/preroll")
async def get_preroll_task():
values = settings_service.load()
return {
"settings": {
"preroll_enabled": values["preroll_enabled"],
"preroll_active_dir": values["preroll_active_dir"],
"preroll_inactive_dir": values["preroll_inactive_dir"],
"preroll_state_file": values["preroll_state_file"],
"preroll_weekday": values["preroll_weekday"],
"preroll_time": values["preroll_time"],
},
"status": preroll_status_payload(values),
}
@app.post("/api/tasks/preroll/run")
async def run_preroll_task_now():
values = settings_service.load()
try:
rotate_preroll.parse_schedule_time(str(values["preroll_time"]).strip())
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid preroll schedule time: {exc}") from exc
result = await run_preroll_task(force=True)
if not result.get("ok"):
raise HTTPException(status_code=500, detail=result.get("message") or "Preroll rotation failed.")
return {
"result": result,
"status": preroll_status_payload(values),
}
@app.get("/api/tasks/cleanup")
async def get_cleanup_tasks():
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
return {"tasks": emby_tasks_service.describe_tasks(task_settings)}
@app.post("/api/tasks/cleanup/settings")
async def update_cleanup_tasks_settings(request: Request):
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Task settings payload must be an object.")
normalized = emby_tasks_service.normalize_settings(body.get("emby_tasks"))
values = settings_service.save({"emby_tasks": normalized})
return {"tasks": emby_tasks_service.describe_tasks(values["emby_tasks"])}
@app.post("/api/tasks/cleanup/{task_id}/run")
async def run_cleanup_task(task_id: str, request: Request):
try:
body = await request.json()
except Exception:
body = {}
if task_id not in emby_tasks_service.TASK_DEFS:
raise HTTPException(status_code=404, detail="Unknown cleanup task.")
dry_run = True if not isinstance(body, dict) else bool(body.get("dryRun", True))
result = await run_library_task(task_id, dry_run=dry_run, automated=False)
status_code = 200 if result.get("ok") else 409
payload = {
"result": result,
"tasks": emby_tasks_service.describe_tasks(emby_tasks_service.normalize_settings(settings_service.load().get("emby_tasks"))),
}
if status_code != 200:
raise HTTPException(status_code=status_code, detail=result.get("message") or "Task failed.")
return payload
@app.get("/api/tasks/emby")
async def get_emby_tasks():
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
return {"tasks": [task for task in emby_tasks_service.describe_tasks(task_settings) if task["section"] == "emby"]}
@app.post("/api/tasks/emby/settings")
async def update_emby_tasks_settings(request: Request):
return await update_cleanup_tasks_settings(request)
@app.post("/api/tasks/emby/{task_id}/run")
async def run_emby_task(task_id: str, request: Request):
if task_id not in emby_tasks_service.TASK_DEFS or emby_tasks_service.TASK_DEFS[task_id]["section"] != "emby":
raise HTTPException(status_code=404, detail="Unknown Emby task.")
return await run_cleanup_task(task_id, request)
# ── Audiobookshelf ───────────────────────────────────────────────────────────
def _handle_abs_error(exc: abs_service.AudiobookshelfError) -> HTTPException:
return HTTPException(status_code=exc.status, detail=exc.message)
@app.get("/api/audiobookshelf/status")
async def audiobookshelf_status():
return await abs_service.ping(get_http_client())
@app.get("/api/audiobookshelf/stats")
async def audiobookshelf_stats():
try:
return await abs_service.get_stats(get_http_client())
except abs_service.AudiobookshelfError as exc:
raise _handle_abs_error(exc) from exc
@app.get("/api/audiobookshelf/libraries")
async def audiobookshelf_libraries():
try:
return {"items": await abs_service.get_libraries(get_http_client())}
except abs_service.AudiobookshelfError as exc:
raise _handle_abs_error(exc) from exc
# ── Navidrome (Subsonic API) ─────────────────────────────────────────────────
@@ -3173,21 +3741,36 @@ async def navidrome_albums(
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache: dict = {"data": None, "expires": 0.0}
NAVIDROME_FORMATS_TTL = 1800
# The format breakdown pages the entire song list, so it's expensive. Cache it
# for the whole server session — recompute only when the caller passes
# ?refresh=true (Dashboard's Refresh / rescan buttons), not on every page load.
_navidrome_formats_cache: dict = {"data": None, "fetched_at": None}
_navidrome_reporting_cache: dict = {"data": None, "fetched_at": None}
@app.get("/api/navidrome/formats")
async def navidrome_formats(refresh: bool = Query(False)):
now = time.time()
cached = _navidrome_formats_cache
if not refresh and cached["data"] is not None and cached["expires"] > now:
if not refresh and cached["data"] is not None:
return cached["data"]
try:
data = await navidrome_service.get_format_breakdown(get_http_client())
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache.update(data=data, expires=now + NAVIDROME_FORMATS_TTL)
_navidrome_formats_cache.update(data=data, fetched_at=time.time())
return data
@app.get("/api/navidrome/reporting")
async def navidrome_reporting(refresh: bool = Query(False)):
cached = _navidrome_reporting_cache
if not refresh and cached["data"] is not None:
return cached["data"]
try:
data = await navidrome_service.get_reporting_snapshot(get_http_client())
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_reporting_cache.update(data=data, fetched_at=time.time())
return data
@@ -3199,6 +3782,38 @@ async def navidrome_album(album_id: str):
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/playlists")
async def navidrome_playlists():
try:
return {"items": await navidrome_service.get_playlists(get_http_client())}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.post("/api/navidrome/playlists/delete")
async def navidrome_delete_playlists(request: Request):
try:
body = await request.json()
except Exception:
body = {}
ids = (body or {}).get("ids") if isinstance(body, dict) else None
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="Provide a non-empty list of playlist ids.")
client = get_http_client()
deleted: list[str] = []
failed: list[dict] = []
for raw_id in ids:
playlist_id = str(raw_id).strip()
if not playlist_id:
continue
try:
await navidrome_service.delete_playlist(client, playlist_id)
deleted.append(playlist_id)
except NavidromeError as exc:
failed.append({"id": playlist_id, "error": exc.message})
return {"deleted_count": len(deleted), "failed_count": len(failed), "failed": failed[:12]}
@app.get("/api/navidrome/cover/{cover_id}")
async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
try:
@@ -3213,11 +3828,107 @@ async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
# ── Music library maintenance (music-covers) ─────────────────────────────────
def _ndjson_stream(generator):
"""Wrap a sync generator of dicts as newline-delimited JSON.
Starlette iterates a sync generator in its threadpool, so the blocking NAS
walk and tag reads never block the event loop while results stream out.
"""
for obj in generator:
yield json.dumps(obj) + "\n"
@app.get("/api/music/scan")
async def music_scan():
return await asyncio.to_thread(music_service.scan_library)
@app.get("/api/music/scan/stream")
async def music_scan_stream():
"""Disk-efficient streaming scan: emits one album at a time as NDJSON."""
return StreamingResponse(
_ndjson_stream(music_service.scan_library_stream()),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/process/stream")
async def music_process_stream(request: Request):
"""Streaming maintenance run: emits each action live as NDJSON."""
try:
body = await request.json()
except Exception:
body = {}
options = ProcessOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return StreamingResponse(
_ndjson_stream(music_service.process_library_stream(options, album_paths=album_paths)),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/metadata/process/stream")
async def music_metadata_process_stream(request: Request):
"""Streaming tag-maintenance run (genres / junk / track numbers) as NDJSON."""
try:
body = await request.json()
except Exception:
body = {}
options = metadata_service.MetadataOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return StreamingResponse(
_ndjson_stream(metadata_service.process_library_stream(options, album_paths=album_paths)),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/metadata/process")
async def music_metadata_process(request: Request):
try:
body = await request.json()
except Exception:
body = {}
options = metadata_service.MetadataOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return await asyncio.to_thread(
metadata_service.process_library, options, album_paths=album_paths
)
@app.get("/api/music/metadata/overrides")
async def music_metadata_overrides():
return {"overrides": await asyncio.to_thread(metadata_service.list_genre_overrides)}
@app.post("/api/music/metadata/overrides")
async def music_metadata_set_override(request: Request):
try:
body = await request.json()
except Exception:
body = {}
artist = (body or {}).get("artist", "")
genre = (body or {}).get("genre", "")
try:
result = await asyncio.to_thread(metadata_service.set_genre_override, artist, genre)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@app.post("/api/music/metadata/overrides/delete")
async def music_metadata_delete_override(request: Request):
try:
body = await request.json()
except Exception:
body = {}
artist = (body or {}).get("artist", "")
await asyncio.to_thread(metadata_service.delete_genre_override, artist)
return {"status": "ok"}
# ── Music Collection Completeness ────────────────────────────────────────────