Homelabtoolkit v1

This commit is contained in:
2026-06-08 00:01:55 +12:00
parent c8838a485d
commit 040fbacc70
56 changed files with 12477 additions and 151 deletions
+520 -27
View File
@@ -15,15 +15,25 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("embytoolkit")
logger = logging.getLogger("homelabtoolkit")
import httpx
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError
from services import db as db_service
from services import favorites as favorites_service
from services import music_covers as music_service
from services import music_library as library_service
from services import navidrome as navidrome_service
from services import settings as settings_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_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
@@ -290,8 +300,21 @@ async def get_cached_emby_source_image(
# --- Emby API helpers ---
def apply_settings(values: dict) -> None:
"""Push effective settings into the live module globals the app reads."""
global EMBY_URL, EMBY_API_KEY
EMBY_URL = values["emby_url"]
EMBY_API_KEY = values["emby_api_key"]
navidrome_service.NAVIDROME_URL = values["navidrome_url"]
navidrome_service.NAVIDROME_USER = values["navidrome_user"]
navidrome_service.NAVIDROME_PASSWORD = values["navidrome_password"]
music_service.MUSIC_ROOT = Path(values["music_root"])
@asynccontextmanager
async def lifespan(app: FastAPI):
apply_settings(settings_service.load())
db_service.init_db()
get_http_client()
try:
yield
@@ -302,9 +325,14 @@ async def lifespan(app: FastAPI):
http_client = None
app = FastAPI(title="EmbyToolkit", lifespan=lifespan)
app = FastAPI(title="HomelabToolkit", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# The React SPA is built to frontend/dist. When present we serve its assets and
# fall back to index.html for client-side routes (see the catch-all near the end).
FRONTEND_DIST = Path("frontend/dist")
if (FRONTEND_DIST / "assets").exists():
app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets")
def get_http_client() -> httpx.AsyncClient:
global http_client
@@ -1983,25 +2011,6 @@ async def render_collection_art_preview(options: dict) -> tuple[str, bytes]:
# --- API Routes ---
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse(request, "index.html")
@app.get("/collections", response_class=HTMLResponse)
async def collections_page(request: Request):
return templates.TemplateResponse(request, "collections.html")
@app.get("/airing", response_class=HTMLResponse)
async def airing_page(request: Request):
return templates.TemplateResponse(request, "airing.html")
@app.get("/bulk-assign", response_class=HTMLResponse)
async def bulk_assign_page(request: Request):
return templates.TemplateResponse(request, "bulk_assign.html")
@app.get("/api/bulk-assign/series")
async def get_bulk_assign_series(
@@ -2209,7 +2218,7 @@ async def bulk_apply_new_season_banner(request: Request):
skipped_missing_assets: list[str] = []
failed: list[dict] = []
logger.info("=== Bulk Apply Selected: %d %s item(s) ===", len(item_ids), emby_type)
logger.info("=== Bulk Apply Selected: %d item(s) ===", len(item_ids))
for i, item_id in enumerate(item_ids):
item = items_by_id.get(item_id)
if item is None:
@@ -2892,11 +2901,495 @@ async def bulk_apply_category(request: Request):
@app.get("/api/config")
async def get_config():
return {
"emby_url": EMBY_URL,
"connected": bool(EMBY_API_KEY),
"app_name": "HomelabToolkit",
"emby": {
"url": EMBY_URL,
"connected": bool(EMBY_API_KEY),
},
"navidrome": {
"url": navidrome_service.NAVIDROME_URL,
"configured": navidrome_service.is_configured(),
},
"music": {
"root": str(music_service.MUSIC_ROOT),
"available": music_service.MUSIC_ROOT.exists(),
},
}
# ── Dashboard overview ───────────────────────────────────────────────────────
@app.get("/api/dashboard")
async def get_dashboard():
"""Aggregate library counts for the homepage. Resilient: any failing source
degrades to null/0 rather than failing the whole response."""
client = get_http_client()
async def emby_count(item_types: str) -> int:
data = await emby_get("/Items", {
"IncludeItemTypes": item_types,
"Recursive": "true",
"Limit": "1",
"ImageTypeLimit": "0",
})
return int(data.get("TotalRecordCount", 0))
async def latest_added() -> str | None:
data = await emby_get("/Items", {
"IncludeItemTypes": "Movie,Episode",
"Recursive": "true",
"SortBy": "DateCreated",
"SortOrder": "Descending",
"Limit": "1",
"Fields": "DateCreated",
})
items = data.get("Items") or []
return items[0].get("DateCreated") if items else None
async def user_count() -> int:
data = await emby_get("/Users")
return len(data) if isinstance(data, list) else 0
async def favorites_count() -> int:
return len(await favorites_service.list_favorites_users(emby_client_adapter))
async def navidrome_stats() -> dict:
status = await navidrome_service.ping(client)
if not status.get("connected"):
return {"connected": False, "configured": status.get("configured", False)}
stats = await navidrome_service.get_stats(client)
return {"connected": True, "configured": True, **stats}
(
movies, series, episodes, collections, users, favorites, last_added, navidrome,
) = await asyncio.gather(
emby_count("Movie"),
emby_count("Series"),
emby_count("Episode"),
emby_count("BoxSet"),
user_count(),
favorites_count(),
latest_added(),
navidrome_stats(),
return_exceptions=True,
)
def safe(value, default=0):
return default if isinstance(value, Exception) else value
return {
"emby": {
"connected": bool(EMBY_API_KEY),
"url": EMBY_URL,
"movies": safe(movies),
"series": safe(series),
"episodes": safe(episodes),
"collections": safe(collections),
"users": safe(users),
"favorites_collections": safe(favorites),
"last_added": safe(last_added, None),
},
"navidrome": navidrome if not isinstance(navidrome, Exception) else {"connected": False},
"music": {
"available": music_service.MUSIC_ROOT.exists(),
"root": str(music_service.MUSIC_ROOT),
},
}
@app.post("/api/emby/refresh-libraries")
async def emby_refresh_libraries():
"""Trigger a scan of all Emby libraries."""
response = await emby_request(
"POST", "/Library/Refresh", headers={"X-Emby-Token": EMBY_API_KEY}
)
if response.status_code not in (200, 204):
raise HTTPException(
status_code=502,
detail=f"Emby library refresh failed ({response.status_code}).",
)
return {"status": "started"}
@app.post("/api/navidrome/scan")
async def navidrome_scan(full: bool = Query(False)):
"""Trigger a Navidrome library scan."""
try:
result = await navidrome_service.start_scan(get_http_client(), full=full)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
return {"status": "started", **result}
@app.get("/api/emby/user-activity")
async def emby_user_activity():
"""Per-user last login + last activity, enriched with the most recent
session's IP/device. ``/Users`` carries login timestamps; ``/Sessions``
carries the remote endpoint (IP)."""
users, sessions = await asyncio.gather(
emby_get("/Users"),
emby_get("/Sessions"),
return_exceptions=True,
)
if isinstance(users, Exception):
raise HTTPException(status_code=502, detail="Could not load Emby users.")
if isinstance(sessions, Exception) or not isinstance(sessions, list):
sessions = []
def clean_ip(value: str | None) -> str | None:
if not value:
return None
value = value.strip()
if value.startswith("::ffff:"):
value = value[len("::ffff:"):]
return value or None
def platform_of(client: str | None, device: str | None) -> str:
text = f"{client or ''} {device or ''}".lower()
if "android" in text:
return "android"
if any(k in text for k in ("ios", "iphone", "ipad", "apple tv", "tvos")):
return "ios"
if any(k in text for k in ("web", "browser", "chrome", "firefox", "safari", "edge")):
return "web"
return "other"
latest_session: dict[str, dict] = {}
devices: dict[str, str] = {} # device id -> platform
for session in sessions:
client = session.get("Client")
device = session.get("DeviceName")
device_id = session.get("DeviceId") or f"{device}::{client}"
if device_id:
devices[device_id] = platform_of(client, device)
user_id = session.get("UserId")
if not user_id:
continue
last = session.get("LastActivityDate") or ""
existing = latest_session.get(user_id)
if existing is None or last > existing["last"]:
latest_session[user_id] = {
"last": last,
"ip": clean_ip(session.get("RemoteEndPoint")),
"device": device,
"client": client,
}
rows = []
for user in users if isinstance(users, list) else []:
user_id = user.get("Id")
session = latest_session.get(user_id, {})
rows.append({
"id": user_id,
"name": user.get("Name", ""),
"last_login": user.get("LastLoginDate"),
"last_activity": user.get("LastActivityDate"),
"ip": session.get("ip"),
"device": session.get("device"),
"client": session.get("client"),
})
rows.sort(key=lambda r: (r["last_activity"] or r["last_login"] or ""), reverse=True)
platform_counts = {"android": 0, "ios": 0, "web": 0, "other": 0}
for platform in devices.values():
platform_counts[platform] += 1
device_count = len(devices)
def pct(value: int) -> int:
return round(value / device_count * 100) if device_count else 0
summary = {
"user_count": len(rows),
"device_count": device_count,
"platforms": platform_counts,
"platform_pct": {key: pct(value) for key, value in platform_counts.items()},
}
return {"users": rows, "summary": summary}
# ── Settings ─────────────────────────────────────────────────────────────────
@app.get("/api/settings")
async def get_settings():
return settings_service.load()
@app.post("/api/settings")
async def update_settings(request: Request):
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Settings payload must be an object.")
values = settings_service.save(body)
apply_settings(values)
navidrome_status = await navidrome_service.ping(get_http_client())
return {
"settings": values,
"navidrome": navidrome_status,
"music_available": music_service.MUSIC_ROOT.exists(),
}
# ── Navidrome (Subsonic API) ─────────────────────────────────────────────────
def _handle_navidrome_error(exc: NavidromeError) -> HTTPException:
return HTTPException(status_code=exc.status, detail=exc.message)
@app.get("/api/navidrome/status")
async def navidrome_status():
return await navidrome_service.ping(get_http_client())
@app.get("/api/navidrome/artists")
async def navidrome_artists():
try:
return {"items": await navidrome_service.get_artists(get_http_client())}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/albums")
async def navidrome_albums(
q: str = Query(""),
type: str = Query("alphabeticalByName"),
size: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
client = get_http_client()
try:
if q.strip():
items = await navidrome_service.search_albums(client, q.strip(), count=size)
else:
items = await navidrome_service.get_albums(client, list_type=type, size=size, offset=offset)
return {"items": items, "offset": offset, "size": size, "has_more": len(items) >= size}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache: dict = {"data": None, "expires": 0.0}
NAVIDROME_FORMATS_TTL = 1800
@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:
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)
return data
@app.get("/api/navidrome/album/{album_id}")
async def navidrome_album(album_id: str):
try:
return await navidrome_service.get_album(get_http_client(), album_id)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/cover/{cover_id}")
async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
try:
image_bytes, content_type = await navidrome_service.get_cover_art(
get_http_client(), cover_id, size or None
)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
return Response(content=image_bytes, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
# ── Music library maintenance (music-covers) ─────────────────────────────────
@app.get("/api/music/scan")
async def music_scan():
return await asyncio.to_thread(music_service.scan_library)
# ── Music Collection Completeness ────────────────────────────────────────────
@app.post("/api/music-collection/scan")
async def music_collection_scan():
return library_service.start_scan_job()
@app.post("/api/music-collection/refresh-metadata")
async def music_collection_refresh_metadata():
return library_service.start_metadata_job()
@app.get("/api/music-collection/status")
async def music_collection_status():
return await asyncio.to_thread(library_service.get_status)
@app.get("/api/music-collection/overview")
async def music_collection_overview():
return await asyncio.to_thread(library_service.get_overview)
@app.get("/api/music-collection/artists")
async def music_collection_artists(q: str = Query("")):
return {"artists": await asyncio.to_thread(library_service.get_artists_completeness, q)}
@app.get("/api/music-collection/artist/{artist_id}/albums")
async def music_collection_artist_albums(artist_id: int):
return await asyncio.to_thread(library_service.get_artist_albums, artist_id)
@app.post("/api/music-collection/album/{completeness_id}/decision")
async def music_collection_decision(completeness_id: int, request: Request):
try:
body = await request.json()
except Exception:
body = {}
action = (body or {}).get("action", "")
try:
return await asyncio.to_thread(library_service.set_album_decision, completeness_id, action)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/music/process")
async def music_process(request: Request):
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
result = await asyncio.to_thread(
music_service.process_library, options, album_paths=album_paths
)
return result
# ── User Favourites ──────────────────────────────────────────────────────────
class EmbyClientAdapter:
"""Adapts the module-level Emby helpers to the services' client protocol.
Write operations carry the ``X-Emby-Token`` header (as the image write paths
do) and surface Emby errors as HTTPExceptions via ``ensure_emby_success``.
"""
async def get(self, path: str, params: dict | None = None):
return await emby_get(path, params)
async def get_all(self, path: str, params: dict | None = None):
return await emby_get_all(path, params)
async def post(self, path: str, params: dict | None = None, **kwargs):
resp = await emby_request("POST", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
return ensure_emby_success(resp, context=f"Emby POST {path}")
async def delete(self, path: str, params: dict | None = None, **kwargs):
resp = await emby_request("DELETE", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
return ensure_emby_success(resp, context=f"Emby DELETE {path}")
emby_client_adapter = EmbyClientAdapter()
async def _favorites_body(request: Request) -> dict:
try:
body = await request.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
@app.get("/api/favorites/users")
async def favorites_users():
return {"users": await favorites_service.list_favorites_users(emby_client_adapter)}
@app.get("/api/favorites/collections")
async def favorites_collections_overview():
return await favorites_service.list_collections_overview(emby_client_adapter)
@app.get("/api/favorites/collection/{collection_id}")
async def favorites_collection_items(collection_id: str, user_id: str = Query(...)):
try:
return await favorites_service.get_collection_items_view(emby_client_adapter, collection_id, user_id)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@app.post("/api/favorites/collection/{collection_id}/cleanup")
async def favorites_cleanup(collection_id: str, request: Request):
body = await _favorites_body(request)
user_id = body.get("userId", "")
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
try:
return await favorites_service.cleanup_watched(
emby_client_adapter, collection_id, user_id, dry_run=dry_run
)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@app.post("/api/favorites/collection/{collection_id}/regenerate")
async def favorites_regenerate(collection_id: str, request: Request):
body = await _favorites_body(request)
user_id = body.get("userId", "")
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
try:
target_size = int(body.get("targetSize", DEFAULT_TARGET_SIZE))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="targetSize must be an integer.") from exc
try:
return await favorites_service.regenerate(
emby_client_adapter, collection_id, user_id, dry_run=dry_run, target_size=target_size
)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
# ── React SPA (must stay last so /api routes win) ────────────────────────────
@app.get("/", response_class=HTMLResponse)
@app.get("/{full_path:path}", response_class=HTMLResponse)
async def serve_spa(full_path: str = ""):
"""Serve the built React app, falling back to index.html for client routes."""
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not found")
index_file = FRONTEND_DIST / "index.html"
if not index_file.exists():
return HTMLResponse(
"<h1>HomelabToolkit</h1><p>Frontend not built. Run "
"<code>npm install &amp;&amp; npm run build</code> in <code>frontend/</code>.</p>",
status_code=200,
)
# Serve real files (favicon, etc.) directly when they exist.
if full_path:
candidate = FRONTEND_DIST / full_path
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index_file)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8500)