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