diff --git a/.dockerignore b/.dockerignore index a86594a..4ac6755 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,12 +1,36 @@ -__pycache__/ -*.pyc +# VCS & tooling .git/ +.gitignore +.dockerignore .pytest_cache/ +.claude/ +*.code-workspace + +# Python bytecode +__pycache__/ +**/__pycache__/ +*.pyc +*.pyo + +# Runtime data — provided via mounted volumes, never baked into the image cache/ output/ logs/ .app.out.log .app.err.log + +# Frontend build artifacts — regenerated in the build stage frontend/node_modules/ frontend/dist/ + +# Dev-only / legacy files not needed by any build stage +tests/ templates/ +music-covers.py +deploy.ps1 +AGENT.MD +DESIGN.md + +# Unused root-level marketing/legacy images (real assets live under static/) +*.png +*.jpg diff --git a/Dockerfile b/Dockerfile index 19868f5..59097b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-slim AS frontend WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json* ./ -RUN npm install +RUN npm ci || npm install COPY frontend/ ./ RUN npm run build @@ -21,16 +21,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt ./ -RUN python -m pip install --upgrade pip \ - && python -m pip install -r requirements.txt +RUN python -m pip install --no-cache-dir -r requirements.txt -COPY . . +# Copy only what the server actually runs — keeps the final image lean and avoids +# baking in frontend sources, tests, legacy scripts and unused root assets. +COPY app.py ./ +COPY rotate_preroll.py ./ +COPY services/ ./services/ +COPY static/ ./static/ # Bring in the built SPA from the frontend stage. COPY --from=frontend /frontend/dist ./frontend/dist -RUN mkdir -p /app/cache /app/output +RUN mkdir -p /app/cache /app/output /app/logs EXPOSE 8500 -CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8500"] +# Single worker (lean memory) and no access log (less CPU + log noise). +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8500", "--no-access-log"] diff --git a/README.md b/README.md index d214770..bd128e6 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ UI, served by a FastAPI backend. per-studio artwork resets. - **User Favorites** — browse collections with per-user watched status, prune watched items, and top up with recommendations (dry-run by default). +- **Weekly Preroll Rotation** — schedule your Emby preroll folder to rotate once + per week from the Settings page, with a manual "Run now" action for testing. ### Navidrome - **Music Library** — browse artists and albums over the Subsonic API. @@ -41,13 +43,19 @@ UI, served by a FastAPI backend. | `NAVIDROME_USER` | Navidrome username | | `NAVIDROME_PASSWORD` | Navidrome password | | `MUSIC_ROOT` | Path to the music library for the Cover Manager | +| `PREROLL_ENABLED` | Enable the weekly preroll task by default | +| `PREROLL_ACTIVE_DIR` / `PREROLL_INACTIVE_DIR` | Mounted preroll folders to rotate between | +| `PREROLL_STATE_FILE` | Where weekly rotation state is stored | +| `PREROLL_WEEKDAY` / `PREROLL_TIME` | Default weekly schedule for the preroll task | | `TMDB_BEARER_TOKEN` / `TMDB_API_KEY` | Optional artwork providers | | `GOOGLE_CUSTOM_SEARCH_API_KEY` / `..._ENGINE_ID` | Optional artwork search | ## Run with Docker (recommended) 1. Edit `docker-compose.yml` with your Emby/Navidrome details and mount your music - share to match `MUSIC_ROOT`. + share to match `MUSIC_ROOT`. If you want weekly preroll rotation, also mount + the active/inactive preroll folders and either set the `PREROLL_*` defaults + there or configure the task in the app's Settings page. 2. Build and run: ```bash docker compose up -d --build diff --git a/app.py b/app.py index bf8cdff..faea3b4 100644 --- a/app.py +++ b/app.py @@ -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 ──────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index d488378..27121a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,11 +1,27 @@ services: + homelabtoolkit-db: + image: postgres:16-alpine + container_name: homelabtoolkit-db + environment: + - POSTGRES_DB=homelabtoolkit + - POSTGRES_USER=homelabtoolkit + - POSTGRES_PASSWORD=homelabtoolkit + volumes: + - homelabtoolkit_postgres:/var/lib/postgresql/data + restart: unless-stopped + networks: + - npm_network + homelabtoolkit: build: . container_name: homelabtoolkit + depends_on: + - homelabtoolkit-db ports: - "8500:8500" environment: - TZ=Pacific/Auckland + - DATABASE_URL=postgresql://homelabtoolkit:homelabtoolkit@homelabtoolkit-db:5432/homelabtoolkit # Emby - EMBY_URL=http://10.0.0.2:8096 - EMBY_API_KEY=b9af54b630f6448289ab96422add567a @@ -15,6 +31,17 @@ services: - NAVIDROME_PASSWORD= # Music library root (for the Cover Manager). Mount the share below to match. - MUSIC_ROOT=/music + # Optional weekly Emby preroll rotation task: + - PREROLL_ENABLED=false + - PREROLL_ACTIVE_DIR=/media/Prerolls + - PREROLL_INACTIVE_DIR=/media/Prerolls - Not Active + - PREROLL_STATE_FILE=/app/cache/preroll-state.json + - PREROLL_WEEKDAY=0 + - PREROLL_TIME=02:00 + # On-disk cache bounds (keeps the container lean; all cache is regenerable): + - CACHE_MAX_MB=512 + - CACHE_MAX_AGE_DAYS=14 + - CACHE_SWEEP_INTERVAL_MIN=60 # Optional external artwork providers: # - TMDB_BEARER_TOKEN= # - TMDB_API_KEY= @@ -25,10 +52,18 @@ services: - /share/Docker/homelabtoolkit/cache:/app/cache # Mount your music library so the Cover Manager can scan/maintain it: - /share/Music:/music + # Mount preroll folders if you want the weekly rotation task to manage them: + # - /share/Media/Prerolls:/media/Prerolls + # - /share/Media/Prerolls - Not Active:/media/Prerolls - Not Active restart: unless-stopped + # Lean resource ceiling. Raise if you process very large Emby backdrops. + mem_limit: 768m networks: - npm_network networks: npm_network: external: true + +volumes: + homelabtoolkit_postgres: diff --git a/emby-avatar-generator.py b/emby-avatar-generator.py new file mode 100644 index 0000000..4ee99c3 --- /dev/null +++ b/emby-avatar-generator.py @@ -0,0 +1,397 @@ +import os +import math +import hashlib +import random +from typing import List, Tuple + +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +# ========================================================= +# CONFIG +# ========================================================= + +OUTPUT_DIR = "emby_user_thumbs" +IMAGE_SIZE = 512 # final image size (square) +CORNER_RADIUS = 22 # rounded corners +FONT_SIZE_RATIO = 0.34 # relative to image size +USE_GRADIENTS = True # True = soft gradient backgrounds +ADD_SUBTLE_SHADOW = False # set True if you want slight depth +TEXT_COLOUR = (245, 245, 245, 255) + +# Optional: point this to a nicer font installed on your system. +# Windows examples: +# r"C:\Windows\Fonts\segoeuib.ttf" +# r"C:\Windows\Fonts\bahnschrift.ttf" +# r"C:\Windows\Fonts\arialbd.ttf" +# Leave as None to use Pillow default fallback search. +FONT_PATH = r"C:\Windows\Fonts\segoeuib.ttf" + +# Example user list. Replace with your own, or load from Emby. +USERS = [ + "AC", + "AV", + "AS", + "DN", + "DC", + "FTV", + "LB", + "MC", + "PB", + "PR", + "PRXX", + "PC", + "RH", + "SC", + "TH", + "X", +] + +# Background palette pairs for gradients / solids. +# These are chosen to feel fairly modern and similar in spirit +# to app profile tiles. +PALETTE: List[Tuple[Tuple[int, int, int], Tuple[int, int, int]]] = [ + ((28, 148, 33), (33, 120, 39)), # green + ((85, 65, 201), (102, 45, 184)), # purple + ((170, 72, 157), (141, 60, 134)), # magenta + ((91, 116, 139), (111, 128, 149)), # slate blue + ((86, 47, 88), (106, 60, 110)), # plum + ((54, 108, 63), (63, 120, 71)), # deep green + ((41, 58, 73), (52, 70, 85)), # charcoal blue + ((238, 146, 12), (113, 56, 180)), # orange to violet + ((255, 93, 39), (45, 144, 241)), # orange to blue + ((25, 26, 34), (38, 40, 51)), # dark neutral +] + + +# ========================================================= +# HELPERS +# ========================================================= + +def ensure_output_dir(path: str) -> None: + os.makedirs(path, exist_ok=True) + + +def safe_filename(name: str) -> str: + cleaned = "".join(c for c in name if c.isalnum() or c in ("-", "_", " ")).strip() + cleaned = cleaned.replace(" ", "_") + return cleaned or "user" + + +def initials_from_name(name: str) -> str: + name = (name or "").strip() + if not name: + return "?" + + # Split on spaces first + parts = [p for p in name.replace("_", " ").replace("-", " ").split() if p] + + if len(parts) >= 2: + return (parts[0][0] + parts[1][0]).upper() + + # Handle camel-ish or single-token names like DaveN, PaulR, RobH + token = parts[0] if parts else name + uppers = [c for c in token[1:] if c.isupper()] + + if len(token) == 1: + return token.upper() + + if uppers: + return (token[0] + uppers[0]).upper() + + return token[:2].upper() + + +def hash_to_palette_index(name: str, palette_size: int) -> int: + digest = hashlib.sha256(name.encode("utf-8")).hexdigest() + return int(digest[:8], 16) % palette_size + + +def seeded_rng(name: str) -> random.Random: + digest = hashlib.sha256(name.encode("utf-8")).digest() + return random.Random(int.from_bytes(digest[:8], "big")) + + +def load_font(image_size: int) -> ImageFont.FreeTypeFont: + font_size = int(image_size * FONT_SIZE_RATIO) + + # Try configured font first + if FONT_PATH and os.path.exists(FONT_PATH): + try: + return ImageFont.truetype(FONT_PATH, font_size) + except Exception: + pass + + # Common fallbacks + fallback_fonts = [ + "DejaVuSans-Bold.ttf", + "Arial Bold.ttf", + "arialbd.ttf", + "seguiemj.ttf", # not ideal, but sometimes present + "bahnschrift.ttf" + ] + + for font_name in fallback_fonts: + try: + return ImageFont.truetype(font_name, font_size) + except Exception: + continue + + # Last resort + return ImageFont.load_default() + + +def lerp(a: int, b: int, t: float) -> int: + return int(a + (b - a) * t) + + +def blend_colours( + c1: Tuple[int, int, int], + c2: Tuple[int, int, int], + t: float, +) -> Tuple[int, int, int]: + return ( + lerp(c1[0], c2[0], t), + lerp(c1[1], c2[1], t), + lerp(c1[2], c2[2], t), + ) + + +def make_gradient_background(size: int, c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> Image.Image: + """ + Creates a diagonal gradient background. + """ + img = Image.new("RGBA", (size, size)) + px = img.load() + + for y in range(size): + for x in range(size): + # diagonal interpolation + t = (x + y) / (2 * (size - 1)) + r = lerp(c1[0], c2[0], t) + g = lerp(c1[1], c2[1], t) + b = lerp(c1[2], c2[2], t) + px[x, y] = (r, g, b, 255) + + return img + + +def make_mesh_gradient_background( + size: int, + c1: Tuple[int, int, int], + c2: Tuple[int, int, int], + rng: random.Random, +) -> Image.Image: + """ + Creates a softer, more modern mesh-like gradient with an angled sweep. + """ + img = Image.new("RGBA", (size, size)) + px = img.load() + + angle = rng.uniform(0, math.pi) + dx = math.cos(angle) + dy = math.sin(angle) + cx = rng.uniform(size * 0.2, size * 0.8) + cy = rng.uniform(size * 0.2, size * 0.8) + + accent = blend_colours(c1, c2, 0.5) + accent_strength = rng.uniform(0.12, 0.28) + radius = size * rng.uniform(0.35, 0.6) + + for y in range(size): + for x in range(size): + proj = ((x - cx) * dx + (y - cy) * dy) / size + t = max(0.0, min(1.0, 0.5 + proj)) + base = blend_colours(c1, c2, t) + + dist = math.hypot(x - cx, y - cy) + glow = max(0.0, 1.0 - (dist / radius)) + mix = min(1.0, accent_strength * glow) + + px[x, y] = ( + lerp(base[0], accent[0], mix), + lerp(base[1], accent[1], mix), + lerp(base[2], accent[2], mix), + 255, + ) + + return img + + +def make_solid_background(size: int, colour: Tuple[int, int, int]) -> Image.Image: + return Image.new("RGBA", (size, size), colour + (255,)) + + +def rounded_mask(size: int, radius: int) -> Image.Image: + mask = Image.new("L", (size, size), 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255) + return mask + + +def add_soft_light_overlay(base: Image.Image) -> Image.Image: + """ + Adds a subtle glossy highlight to make the tile feel a bit more polished. + """ + overlay = Image.new("RGBA", base.size, (255, 255, 255, 0)) + draw = ImageDraw.Draw(overlay) + + w, h = base.size + draw.ellipse((-w * 0.25, -h * 0.35, w * 0.85, h * 0.45), fill=(255, 255, 255, 28)) + overlay = overlay.filter(ImageFilter.GaussianBlur(radius=w // 18)) + + return Image.alpha_composite(base, overlay) + + +def add_modern_shapes( + base: Image.Image, + c1: Tuple[int, int, int], + c2: Tuple[int, int, int], + rng: random.Random, +) -> Image.Image: + """ + Adds blurred blobs, rings, and line accents for a more contemporary feel. + """ + overlay = Image.new("RGBA", base.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + w, h = base.size + + accent_light = blend_colours(c1, (255, 255, 255), 0.45) + accent_dark = blend_colours(c2, (8, 12, 18), 0.35) + + for _ in range(rng.randint(2, 4)): + blob_w = int(w * rng.uniform(0.28, 0.58)) + blob_h = int(h * rng.uniform(0.28, 0.58)) + x = int(rng.uniform(-w * 0.12, w * 0.72)) + y = int(rng.uniform(-h * 0.12, h * 0.72)) + fill = accent_light if rng.random() > 0.45 else accent_dark + alpha = rng.randint(38, 88) + draw.ellipse((x, y, x + blob_w, y + blob_h), fill=fill + (alpha,)) + + for _ in range(rng.randint(1, 2)): + ring_size = int(w * rng.uniform(0.2, 0.42)) + x = int(rng.uniform(-w * 0.08, w * 0.82)) + y = int(rng.uniform(-h * 0.08, h * 0.82)) + width = max(3, w // 64) + draw.ellipse( + (x, y, x + ring_size, y + ring_size), + outline=(255, 255, 255, rng.randint(40, 95)), + width=width, + ) + + for _ in range(rng.randint(2, 4)): + x1 = int(rng.uniform(0, w)) + y1 = int(rng.uniform(0, h)) + x2 = int(rng.uniform(0, w)) + y2 = int(rng.uniform(0, h)) + draw.line( + (x1, y1, x2, y2), + fill=(255, 255, 255, rng.randint(18, 45)), + width=max(2, w // 128), + ) + + overlay = overlay.filter(ImageFilter.GaussianBlur(radius=max(8, w // 22))) + return Image.alpha_composite(base, overlay) + + +def add_text(img: Image.Image, text: str, font: ImageFont.FreeTypeFont) -> Image.Image: + draw = ImageDraw.Draw(img) + center = (img.width / 2, img.height / 2) + + if ADD_SUBTLE_SHADOW: + shadow_offset = max(2, img.width // 128) + draw.text( + (center[0] + shadow_offset, center[1] + shadow_offset), + text, + font=font, + fill=(0, 0, 0, 60), + anchor="mm", + ) + + draw.text(center, text, font=font, fill=TEXT_COLOUR, anchor="mm") + return img + + +def create_avatar(name: str, size: int = IMAGE_SIZE) -> Image.Image: + rng = seeded_rng(name) + idx = hash_to_palette_index(name, len(PALETTE)) + c1, c2 = PALETTE[idx] + + if USE_GRADIENTS: + if rng.random() > 0.4: + bg = make_mesh_gradient_background(size, c1, c2, rng) + else: + bg = make_gradient_background(size, c1, c2) + else: + bg = make_solid_background(size, c1) + + bg = add_modern_shapes(bg, c1, c2, rng) + bg = add_soft_light_overlay(bg) + + mask = rounded_mask(size, CORNER_RADIUS) + rounded = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + rounded.paste(bg, (0, 0), mask) + + font = load_font(size) + text = initials_from_name(name) + rounded = add_text(rounded, text, font) + + return rounded + + +def save_avatar(img: Image.Image, username: str, out_dir: str) -> str: + filename = f"{safe_filename(username)}.png" + out_path = os.path.join(out_dir, filename) + img.save(out_path, format="PNG", optimize=True) + return out_path + + +# ========================================================= +# OPTIONAL: replace this with a real Emby API call +# ========================================================= + +def get_emby_users() -> List[str]: + """ + Replace this function with a real Emby API call if you want. + For now it returns the USERS list above. + """ + return USERS + + +# Example real API version if you want it later: +# +# import requests +# +# def get_emby_users() -> List[str]: +# emby_url = "http://YOUR-EMBY:8096" +# api_key = "YOUR_API_KEY" +# headers = {"X-Emby-Token": api_key} +# r = requests.get(f"{emby_url}/Users", headers=headers, timeout=30) +# r.raise_for_status() +# data = r.json() +# return [u.get("Name", "").strip() for u in data if u.get("Name")] + + +# ========================================================= +# MAIN +# ========================================================= + +def main() -> None: + ensure_output_dir(OUTPUT_DIR) + users = get_emby_users() + + if not users: + print("No users found.") + return + + print(f"Generating avatars for {len(users)} users...\n") + + for user in users: + avatar = create_avatar(user, IMAGE_SIZE) + saved = save_avatar(avatar, user, OUTPUT_DIR) + print(f"Created: {saved}") + + print(f"\nDone. Files saved to: {os.path.abspath(OUTPUT_DIR)}") + + +if __name__ == "__main__": + main() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b5acfa..93ba192 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,53 @@ import { useEffect, useState } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router-dom"; import Sidebar from "./components/Sidebar"; +import CommandPalette from "./components/CommandPalette"; import { AppConfig, apiGet } from "./api"; +import { IconClose, IconMenu } from "./components/icons"; import Dashboard from "./pages/Dashboard"; import Generator from "./pages/emby/Generator"; +import AvatarGenerator from "./pages/emby/AvatarGenerator"; import Collections from "./pages/emby/Collections"; import Airing from "./pages/emby/Airing"; import BulkAssign from "./pages/emby/BulkAssign"; import Favorites from "./pages/emby/Favorites"; +import HomescreenEditor from "./pages/emby/HomescreenEditor"; import Library from "./pages/navidrome/Library"; +import Reporting from "./pages/navidrome/Reporting"; import CoverManager from "./pages/navidrome/CoverManager"; +import Metadata from "./pages/navidrome/Metadata"; import CollectionCompleteness from "./pages/navidrome/CollectionCompleteness"; import Settings from "./pages/Settings"; +import AudiobookshelfOverview from "./pages/audiobookshelf/Overview"; +import Tasks from "./pages/Tasks"; const CRUMBS: Record = { "/": ["", "Dashboard"], - "/emby/generator": ["Emby", "Thumbnail Generator"], - "/emby/collections": ["Emby", "Collection Art"], - "/emby/airing": ["Emby", "Airing & New Seasons"], - "/emby/bulk-assign": ["Emby", "Bulk Assign"], - "/emby/favorites": ["Emby", "User Favorites"], + "/emby/generator": ["Emby", "Thumb Studio"], + "/emby/avatar-generator": ["Emby", "Avatars"], + "/emby/collections": ["Emby", "Collection Covers"], + "/emby/airing": ["Emby", "Airing Calendar"], + "/emby/bulk-assign": ["Emby", "Batch Artwork"], + "/emby/favorites": ["Emby", "Favorites"], + "/emby/homescreen": ["Emby", "Home Screen"], "/navidrome/library": ["Navidrome", "Music Library"], - "/navidrome/covers": ["Navidrome", "Cover Manager"], + "/navidrome/reporting": ["Navidrome", "Reporting"], + "/navidrome/cleanup": ["Navidrome", "Library Cleanup"], + "/navidrome/metadata": ["Navidrome", "Metadata Editor"], "/collection-completeness": ["Navidrome", "Collection Completeness"], + "/audiobookshelf": ["Audiobookshelf", "Overview"], + "/tasks": ["System", "Tasks"], "/settings": ["System", "Settings"], }; export default function App() { const [config, setConfig] = useState(null); const [navidromeConnected, setNavidromeConnected] = useState(false); + const [audiobookshelfConnected, setAudiobookshelfConnected] = useState(false); + const [mobileNavOpen, setMobileNavOpen] = useState(false); + const [compactShell, setCompactShell] = useState(() => + typeof window !== "undefined" ? window.innerWidth <= 1280 : false + ); const location = useLocation(); function refreshConfig() { @@ -38,17 +57,47 @@ export default function App() { apiGet<{ connected: boolean }>("/api/navidrome/status") .then((s) => setNavidromeConnected(!!s.connected)) .catch(() => setNavidromeConnected(false)); + apiGet<{ connected: boolean }>("/api/audiobookshelf/status") + .then((s) => setAudiobookshelfConnected(!!s.connected)) + .catch(() => setAudiobookshelfConnected(false)); } useEffect(refreshConfig, []); + useEffect(() => { + const sync = () => { + const compact = window.innerWidth <= 1280; + setCompactShell(compact); + if (!compact) setMobileNavOpen(false); + }; + sync(); + window.addEventListener("resize", sync); + return () => window.removeEventListener("resize", sync); + }, []); + const [section, page] = CRUMBS[location.pathname] || ["", ""]; return ( -
- +
+ setMobileNavOpen(false)} + />
+
{section && ( <> @@ -58,18 +107,25 @@ export default function App() { {page}
+
} /> } /> + } /> } /> } /> } /> } /> + } /> } /> - } /> + } /> + } /> + } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8cd2fa3..68dd7f5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -51,6 +51,49 @@ export async function apiPostImage( return { url: URL.createObjectURL(blob), cacheKey: res.headers.get("X-Cache-Key") }; } +// Reads a newline-delimited JSON stream, invoking onMessage per parsed object. +// Used for the disk-efficient music scan and the live maintenance run. +export async function streamNDJSON( + path: string, + opts: { method?: string; body?: unknown; signal?: AbortSignal; onMessage: (obj: any) => void } +): Promise { + const res = await fetch(path, { + method: opts.method || "GET", + headers: opts.body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + signal: opts.signal, + }); + if (!res.ok || !res.body) throw new ApiError(await parseError(res), res.status); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (line) { + try { + opts.onMessage(JSON.parse(line)); + } catch { + /* ignore partial/invalid line */ + } + } + } + } + const tail = buf.trim(); + if (tail) { + try { + opts.onMessage(JSON.parse(tail)); + } catch { + /* ignore */ + } + } +} + export async function uploadBackground(file: File): Promise<{ upload_id: string; width: number; height: number }> { const form = new FormData(); form.append("file", file); @@ -59,11 +102,22 @@ export async function uploadBackground(file: File): Promise<{ upload_id: string; return res.json(); } +export async function uploadHomescreenDb( + file: File +): Promise<{ upload: { upload_id: string; filename: string; size_bytes: number; uploaded_at: string; sha256: string; path: string } }> { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/homescreen/db-upload", { method: "POST", body: form }); + if (!res.ok) throw new ApiError(await parseError(res), res.status); + return res.json(); +} + // ── Shared types ──────────────────────────────────────────────────────────── export interface AppConfig { app_name: string; emby: { url: string; connected: boolean }; navidrome: { url: string; configured: boolean }; + audiobookshelf: { url: string; configured: boolean }; music: { root: string; available: boolean }; } diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..1fd475d --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,111 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { COMMANDS } from "../lib/commands"; +import { IconSearch } from "./icons"; + +export default function CommandPalette() { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const [active, setActive] = useState(0); + const rootRef = useRef(null); + const inputRef = useRef(null); + + const results = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return COMMANDS; + return COMMANDS.filter((c) => `${c.label} ${c.section} ${c.keywords || ""}`.toLowerCase().includes(q)); + }, [query]); + + // Keep the active row in range as the result set changes. + useEffect(() => { + setActive(0); + }, [query]); + + // Global Ctrl/⌘K to focus, Esc handled on the input. + useEffect(() => { + function onKey(e: KeyboardEvent) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + inputRef.current?.focus(); + setOpen(true); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + // Close when clicking outside. + useEffect(() => { + function onClick(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + function go(to: string) { + navigate(to); + setOpen(false); + setQuery(""); + inputRef.current?.blur(); + } + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((a) => Math.min(a + 1, results.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((a) => Math.max(a - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + if (results[active]) go(results[active].to); + } else if (e.key === "Escape") { + setOpen(false); + inputRef.current?.blur(); + } + } + + return ( +
+
+ + setOpen(true)} + onChange={(e) => setQuery(e.target.value)} + onKeyDown={onKeyDown} + /> + ⌘K +
+ + {open && ( +
+ {results.length === 0 ? ( +
No matching commands
+ ) : ( + results.map((c, i) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 91d6919..4b81e27 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { AppConfig } from "../api"; import { + IconBook, IconCalendar, IconChevron, IconDisc, @@ -13,6 +14,8 @@ import { IconLayers, IconMusic, IconSettings, + IconTrash, + IconUser, IconWand, } from "./icons"; @@ -28,11 +31,13 @@ const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] } label: "Emby", icon: , links: [ - { to: "/emby/generator", label: "Thumbnail Generator", icon: }, - { to: "/emby/collections", label: "Collection Art", icon: }, - { to: "/emby/airing", label: "Airing & New Seasons", icon: }, - { to: "/emby/bulk-assign", label: "Bulk Assign", icon: }, - { to: "/emby/favorites", label: "User Favorites", icon: }, + { to: "/emby/generator", label: "Thumb Studio", icon: }, + { to: "/emby/avatar-generator", label: "Avatars", icon: }, + { to: "/emby/collections", label: "Collection Covers", icon: }, + { to: "/emby/airing", label: "Airing Calendar", icon: }, + { to: "/emby/bulk-assign", label: "Batch Artwork", icon: }, + { to: "/emby/favorites", label: "Favorites", icon: }, + { to: "/emby/homescreen", label: "Home Screen", icon: }, ], }, { @@ -41,15 +46,17 @@ const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] } icon: , links: [ { to: "/navidrome/library", label: "Music Library", icon: }, - { to: "/navidrome/covers", label: "Cover Manager", icon: }, + { to: "/navidrome/reporting", label: "Reporting", icon: }, + { to: "/navidrome/cleanup", label: "Library Cleanup", icon: }, + { to: "/navidrome/metadata", label: "Metadata Editor", icon: }, { to: "/collection-completeness", label: "Collection Completeness", icon: }, ], }, { - id: "system", - label: "System", - icon: , - links: [{ to: "/settings", label: "Settings", icon: }], + id: "audiobookshelf", + label: "Audiobookshelf", + icon: , + links: [{ to: "/audiobookshelf", label: "Overview", icon: }], }, ]; @@ -70,29 +77,35 @@ function Chip({ label, ok, configured }: { label: string; ok: boolean; configure interface Props { config: AppConfig | null; navidromeConnected: boolean; + audiobookshelfConnected: boolean; + mobileOpen: boolean; + onClose: () => void; } -export default function Sidebar({ config, navidromeConnected }: Props) { +export default function Sidebar({ config, navidromeConnected, audiobookshelfConnected, mobileOpen, onClose }: Props) { const location = useLocation(); const activeGroup = GROUPS.find((g) => g.links.some((l) => location.pathname.startsWith(l.to)))?.id; - // Categories start collapsed; the group holding the current route opens itself. - const [open, setOpen] = useState>(() => (activeGroup ? { [activeGroup]: true } : {})); + // Accordion: only one category open at a time. The active route's group opens. + const [open, setOpen] = useState(activeGroup ?? null); useEffect(() => { - if (activeGroup) setOpen((o) => (o[activeGroup] ? o : { ...o, [activeGroup]: true })); + if (activeGroup) setOpen(activeGroup); }, [activeGroup]); - const toggle = (id: string) => setOpen((o) => ({ ...o, [id]: !o[id] })); + useEffect(() => { + onClose(); + }, [location.pathname]); // eslint-disable-line react-hooks/exhaustive-deps + + const toggle = (id: string) => setOpen((cur) => (cur === id ? null : id)); return ( -
+ `nav-item nav-item-top ${isActive ? "active" : ""}`}> + + Tasks + + `nav-item nav-item-top ${isActive ? "active" : ""}`}> + + Settings + -
HomelabToolkit v1.0
+
- + + ); } diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx index 212f7fe..dc43636 100644 --- a/frontend/src/components/icons.tsx +++ b/frontend/src/components/icons.tsx @@ -115,6 +115,26 @@ export const IconUser = (p: P) => ( ); +export const IconApple = (p: P) => ( + + + + +); +export const IconAndroid = (p: P) => ( + + + + + + +); +export const IconWeb = (p: P) => ( + + + + +); export const IconChevron = (p: P) => ( @@ -125,12 +145,42 @@ export const IconFolder = (p: P) => ( ); +export const IconBook = (p: P) => ( + + + + + +); +export const IconHeadphones = (p: P) => ( + + + + + +); +export const IconClock = (p: P) => ( + + + + +); export const IconSettings = (p: P) => ( ); +export const IconMenu = (p: P) => ( + + + +); +export const IconClose = (p: P) => ( + + + +); // Stylized Emby media mark (rounded square + play). Inherits currentColor so it // tints with nav state; swap in the official asset if you have it. export const IconEmby = (p: P) => ( diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 5827bd1..5f2b477 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,13 +1,10 @@ import { ReactNode } from "react"; -export function PageHead({ title, icon, children }: { title: string; icon?: ReactNode; children?: ReactNode }) { +export function PageHead({ title, icon }: { title: string; icon?: ReactNode }) { return (
-
- {icon && {icon}} -

{title}

-
- {children &&

{children}

} + {icon && {icon}} +

{title}

); } diff --git a/frontend/src/lib/commands.tsx b/frontend/src/lib/commands.tsx new file mode 100644 index 0000000..e573b72 --- /dev/null +++ b/frontend/src/lib/commands.tsx @@ -0,0 +1,47 @@ +import { + IconBook, + IconCalendar, + IconDisc, + IconGrid, + IconHeart, + IconHome, + IconImage, + IconLayers, + IconMusic, + IconSettings, + IconTrash, + IconUser, + IconWand, +} from "../components/icons"; + +export interface Command { + to: string; + label: string; + section: string; + icon: JSX.Element; + keywords?: string; +} + +/** Every navigable destination in the app — drives the command palette. */ +export const COMMANDS: Command[] = [ + { to: "/", label: "Dashboard", section: "Home", icon: , keywords: "overview stats home" }, + + { to: "/emby/generator", label: "Thumb Studio", section: "Emby", icon: , keywords: "thumbnail thumb cover artwork poster" }, + { to: "/emby/avatar-generator", label: "Avatars", section: "Emby", icon: , keywords: "avatar users profile initials python script" }, + { to: "/emby/collections", label: "Collection Covers", section: "Emby", icon: , keywords: "collection art covers artwork" }, + { to: "/emby/airing", label: "Airing Calendar", section: "Emby", icon: , keywords: "airing schedule new season calendar" }, + { to: "/emby/bulk-assign", label: "Batch Artwork", section: "Emby", icon: , keywords: "bulk batch assign artwork" }, + { to: "/emby/favorites", label: "Favorites", section: "Emby", icon: , keywords: "favourites favorites users" }, + { to: "/emby/homescreen", label: "Home Screen", section: "Emby", icon: , keywords: "home screen homescreen editor users db sections emby" }, + + { to: "/navidrome/library", label: "Music Library", section: "Navidrome", icon: , keywords: "music albums artists" }, + { to: "/navidrome/reporting", label: "Reporting", section: "Navidrome", icon: , keywords: "plays top tracks stats reports navidrome" }, + { to: "/navidrome/cleanup", label: "Library Cleanup", section: "Navidrome", icon: , keywords: "clean rename covers lyrics" }, + { to: "/navidrome/metadata", label: "Metadata Editor", section: "Navidrome", icon: , keywords: "genre tags junk track number musicbrainz" }, + { to: "/collection-completeness", label: "Collection Completeness", section: "Navidrome", icon: , keywords: "missing albums discography" }, + + { to: "/audiobookshelf", label: "Overview", section: "Audiobookshelf", icon: , keywords: "audiobooks abs" }, + + { to: "/tasks", label: "Tasks", section: "System", icon: , keywords: "automation cleanup maintenance scheduler" }, + { to: "/settings", label: "Settings", section: "System", icon: , keywords: "config emby navidrome url api key" }, +]; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9f77da3..913f7b7 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,8 @@ import { AppConfig, apiGet, apiPost } from "../api"; import { useToast } from "../lib/toast"; import { PageHead, StatCard, Loading, Empty, Avatar, timeAgo, fmtNumber, formatNZ } from "../components/ui"; import { + IconAndroid, + IconApple, IconCalendar, IconChevron, IconDisc, @@ -18,6 +20,7 @@ import { IconRefresh, IconUser, IconWand, + IconWeb, } from "../components/icons"; interface Props { @@ -71,6 +74,32 @@ interface FormatData { formats: { format: string; count: number }[]; } +const FORMATS_SESSION_KEY = "dashboard.navidrome.formats"; + +function readCachedFormats(): FormatData | null { + try { + const raw = window.sessionStorage.getItem(FORMATS_SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.formats) || typeof parsed.total !== "number") return null; + return parsed as FormatData; + } catch { + return null; + } +} + +function writeCachedFormats(data: FormatData | null) { + try { + if (!data) { + window.sessionStorage.removeItem(FORMATS_SESSION_KEY); + return; + } + window.sessionStorage.setItem(FORMATS_SESSION_KEY, JSON.stringify(data)); + } catch { + // Ignore browser storage failures; the in-memory state still works. + } +} + const FORMAT_COLORS: Record = { flac: "var(--accent)", mp3: "var(--amber)", @@ -86,13 +115,16 @@ const FORMAT_COLORS: Record = { const formatColor = (fmt: string) => FORMAT_COLORS[fmt.toLowerCase()] || "#6b7c90"; const tools = [ - { to: "/emby/generator", label: "Thumbnail Generator", icon: , cat: "Emby" }, - { to: "/emby/collections", label: "Collection Art", icon: , cat: "Emby" }, - { to: "/emby/airing", label: "Airing & New Seasons", icon: , cat: "Emby" }, - { to: "/emby/bulk-assign", label: "Bulk Assign", icon: , cat: "Emby" }, - { to: "/emby/favorites", label: "User Favorites", icon: , cat: "Emby" }, + { to: "/emby/generator", label: "Thumb Studio", icon: , cat: "Emby" }, + { to: "/emby/avatar-generator", label: "Avatars", icon: , cat: "Emby" }, + { to: "/emby/collections", label: "Collection Covers", icon: , cat: "Emby" }, + { to: "/emby/airing", label: "Airing Calendar", icon: , cat: "Emby" }, + { to: "/emby/bulk-assign", label: "Batch Artwork", icon: , cat: "Emby" }, + { to: "/emby/favorites", label: "Favorites", icon: , cat: "Emby" }, + { to: "/emby/homescreen", label: "Home Screen", icon: , cat: "Emby" }, { to: "/navidrome/library", label: "Music Library", icon: , cat: "Navidrome" }, - { to: "/navidrome/covers", label: "Cover Manager", icon: , cat: "Navidrome" }, + { to: "/navidrome/reporting", label: "Reporting", icon: , cat: "Navidrome" }, + { to: "/navidrome/cleanup", label: "Library Cleanup", icon: , cat: "Navidrome" }, ]; function MiniStat({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) { @@ -112,12 +144,52 @@ function StatusBadge({ configured, connected }: { configured: boolean; connected return {connected ? "connected" : "offline"}; } +type DevicePlatform = "apple" | "android" | "web" | "other"; + +function detectPlatform(user: UserActivity): DevicePlatform { + const haystack = [user.device, user.client].filter(Boolean).join(" ").toLowerCase(); + if (/(iphone|ipad|ipod|apple tv|appletv|ios|tvos|mac|macos|safari)/.test(haystack)) return "apple"; + if (/(android|google tv|shield|fire tv|firetv|chromecast)/.test(haystack)) return "android"; + if (/(web|chrome|firefox|edge|browser|opera)/.test(haystack)) return "web"; + return "other"; +} + +function platformLabel(platform: DevicePlatform) { + if (platform === "apple") return "Apple"; + if (platform === "android") return "Android"; + if (platform === "web") return "Web"; + return "Other"; +} + +function platformIcon(platform: DevicePlatform) { + if (platform === "apple") return ; + if (platform === "android") return ; + if (platform === "web") return ; + return ; +} + +function platformBadgeClass(platform: DevicePlatform) { + if (platform === "apple") return "activity-device-chip apple"; + if (platform === "android") return "activity-device-chip android"; + if (platform === "web") return "activity-device-chip web"; + return "activity-device-chip"; +} + +function platformSummaryItems(summary: ActivitySummary) { + return [ + { key: "apple", label: "Apple", count: summary.platforms.ios, pct: summary.platform_pct.ios, icon: }, + { key: "android", label: "Android", count: summary.platforms.android, pct: summary.platform_pct.android, icon: }, + { key: "web", label: "Web", count: summary.platforms.web, pct: summary.platform_pct.web, icon: }, + { key: "other", label: "Other", count: summary.platforms.other, pct: summary.platform_pct.other, icon: }, + ].filter((item) => item.count > 0); +} + export default function Dashboard({ config, navidromeConnected }: Props) { const toast = useToast(); const [data, setData] = useState(null); const [activity, setActivity] = useState(null); const [activitySummary, setActivitySummary] = useState(null); - const [formats, setFormats] = useState(null); + const [formats, setFormats] = useState(() => readCachedFormats()); const [formatsLoading, setFormatsLoading] = useState(false); const [loading, setLoading] = useState(true); const [embyScanning, setEmbyScanning] = useState(false); @@ -147,7 +219,28 @@ export default function Dashboard({ config, navidromeConnected }: Props) { } } - function load() { + // Format breakdown pages the whole song list, so it's cached server-side for the + // session. A normal page load reuses that cache; pass force to rescan on demand. + function loadFormats(force = false) { + if (!force) { + const cached = readCachedFormats(); + if (cached) { + setFormats(cached); + setFormatsLoading(false); + return; + } + } + setFormatsLoading(true); + apiGet(`/api/navidrome/formats${force ? "?refresh=true" : ""}`) + .then((result) => { + setFormats(result); + writeCachedFormats(result); + }) + .catch(() => setFormats((current) => current ?? null)) + .finally(() => setFormatsLoading(false)); + } + + function load(force = false) { setLoading(true); apiGet("/api/dashboard") .then(setData) @@ -162,15 +255,11 @@ export default function Dashboard({ config, navidromeConnected }: Props) { setActivity([]); setActivitySummary(null); }); - // Format breakdown pages the whole song list, so it may take a moment on the - // first load; the backend caches it for subsequent calls. - setFormatsLoading(true); - apiGet("/api/navidrome/formats") - .then(setFormats) - .catch(() => setFormats(null)) - .finally(() => setFormatsLoading(false)); + loadFormats(force); } - useEffect(load, []); + useEffect(() => { + load(); + }, []); const e = data?.emby; const n = data?.navidrome; @@ -180,11 +269,8 @@ export default function Dashboard({ config, navidromeConnected }: Props) { return ( <>
- }> - A live overview of your media stack — Emby library health on the left, your Navidrome music collection on the - right. - -
@@ -257,8 +343,18 @@ export default function Dashboard({ config, navidromeConnected }: Props) { } value={fmtNumber(n?.genre_count)} label="Genres" />
-
- Audio formats +
+ + Audio formats + +
{formatsLoading && !formats ? (

@@ -322,24 +418,6 @@ export default function Dashboard({ config, navidromeConnected }: Props) {

User Activity

- {activitySummary && ( -
- {activitySummary.user_count} users - {activitySummary.device_count} devices - {activitySummary.platforms.android > 0 && ( - Android {activitySummary.platform_pct.android}% - )} - {activitySummary.platforms.ios > 0 && ( - iOS {activitySummary.platform_pct.ios}% - )} - {activitySummary.platforms.web > 0 && ( - Web {activitySummary.platform_pct.web}% - )} - {activitySummary.platforms.other > 0 && ( - Other {activitySummary.platform_pct.other}% - )} -
- )}
{!activity ? (
@@ -348,40 +426,69 @@ export default function Dashboard({ config, navidromeConnected }: Props) { ) : activity.length === 0 ? ( }>No Emby users found. ) : ( -
- - - - - - - - - - - - {activity.map((u) => { - const when = u.last_activity || u.last_login; - return ( - - - - - - - - ); - })} - -
UserLast login (NZ)WhenIP addressDevice
-
- - {u.name} +
+ {activitySummary && ( +
+
+ Active users + {fmtNumber(activitySummary.user_count)} + Recently seen across Emby +
+
+ Devices + {fmtNumber(activitySummary.device_count)} + Distinct clients reported +
+ {platformSummaryItems(activitySummary).map((item) => ( +
+ {item.icon} +
+ {item.label} + {item.pct}% +
+ {fmtNumber(item.count)} devices +
+ ))} +
+ )} + +
+ {activity.map((u) => { + const when = u.last_activity || u.last_login; + const platform = detectPlatform(u); + const deviceLabel = [u.device, u.client].filter(Boolean).join(" · ") || "Unknown device"; + return ( +
+
+
+ +
+
{u.name}
+
{when ? timeAgo(when) : "Never active"}
-
{formatNZ(u.last_login)}{when ? timeAgo(when) : "Never"}{u.ip || } - {u.device || "—"} - {u.client ? ` · ${u.client}` : ""} -
+
+ + {platformIcon(platform)} + {platformLabel(platform)} + +
+ +
{deviceLabel}
+ +
+
+ Last login + {formatNZ(u.last_login)} +
+
+ IP address + {u.ip || "—"} +
+
+ + ); + })} +
)}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d54c799..d1d1599 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,52 +1,121 @@ import { useEffect, useState } from "react"; -import { apiGet, apiPost } from "../api"; -import { PageHead, Loading } from "../components/ui"; -import { IconCheck, IconEmby, IconFolder, IconMusic, IconSettings } from "../components/icons"; +import { apiGet, apiPost, AppConfig } from "../api"; +import { Loading, PageHead } from "../components/ui"; +import { IconBook, IconCheck, IconEmby, IconFolder, IconMusic, IconRefresh, IconSettings } from "../components/icons"; import { useToast } from "../lib/toast"; interface SettingsValues { emby_url: string; emby_api_key: string; + homescreen_db_path: string; + tmdb_api_key: string; navidrome_url: string; navidrome_user: string; navidrome_password: string; + audiobookshelf_url: string; + audiobookshelf_token: string; music_root: string; + deploy_nas_host: string; + deploy_nas_user: string; + deploy_nas_password: string; + deploy_remote_app_dir: string; + deploy_music_host_path: string; +} + +interface UpdateStatus { + available: boolean; + allowed: boolean; + configured: boolean; + transport: string | null; + transport_ready: boolean; + password_configured: boolean; + client_host: string; + nas_host: string; + nas_user: string; + remote_app_dir: string; + reason: string | null; + runtime: { + running: boolean; + last_started_at: string | null; + last_finished_at: string | null; + last_status: string; + last_message: string | null; + last_output_tail: string[]; + }; } const LABELS: Record = { - emby_url: { label: "Emby URL", placeholder: "http://10.0.0.2:8096" }, - emby_api_key: { label: "Emby API key", secret: true }, - navidrome_url: { label: "Navidrome URL", placeholder: "http://10.0.0.2:4533" }, - navidrome_user: { label: "Navidrome username" }, - navidrome_password: { label: "Navidrome password", secret: true }, - music_root: { label: "Music library path", placeholder: "/music" }, + emby_url: { label: "Server URL", placeholder: "http://10.0.0.2:8096" }, + emby_api_key: { label: "API key", secret: true }, + homescreen_db_path: { label: "Homescreen DB path", placeholder: "C:\\ProgramData\\Emby-Server\\data\\users.db" }, + tmdb_api_key: { label: "TMDB API key", secret: true }, + navidrome_url: { label: "Server URL", placeholder: "http://10.0.0.2:4533" }, + navidrome_user: { label: "Username" }, + navidrome_password: { label: "Password", secret: true }, + audiobookshelf_url: { label: "Server URL", placeholder: "http://10.0.0.2:13378" }, + audiobookshelf_token: { label: "API token", secret: true }, + music_root: { label: "Library path", placeholder: "/music" }, + deploy_nas_host: { label: "NAS host", placeholder: "MATT-NAS or 10.0.0.10" }, + deploy_nas_user: { label: "NAS SSH user", placeholder: "ssh" }, + deploy_nas_password: { label: "NAS SSH password", secret: true }, + deploy_remote_app_dir: { label: "Remote app dir", placeholder: "/share/Docker/homelabtoolkit" }, + deploy_music_host_path: { label: "Host music path", placeholder: "/share/Movies/Music" }, }; +type DotState = "ok" | "off" | "idle"; + export default function Settings({ onSaved }: { onSaved?: () => void }) { const toast = useToast(); const [values, setValues] = useState(null); const [saving, setSaving] = useState(false); const [reveal, setReveal] = useState(false); + const [config, setConfig] = useState(null); + const [navStatus, setNavStatus] = useState<{ configured: boolean; connected: boolean } | null>(null); + const [absStatus, setAbsStatus] = useState<{ configured: boolean; connected: boolean } | null>(null); + const [updateStatus, setUpdateStatus] = useState(null); + const [updating, setUpdating] = useState(false); + + function loadStatuses() { + apiGet("/api/config").then(setConfig).catch(() => setConfig(null)); + apiGet("/api/navidrome/status").then(setNavStatus).catch(() => setNavStatus(null)); + apiGet("/api/audiobookshelf/status").then(setAbsStatus).catch(() => setAbsStatus(null)); + apiGet("/api/update/status").then(setUpdateStatus).catch(() => setUpdateStatus(null)); + } useEffect(() => { apiGet("/api/settings") .then(setValues) .catch((e) => toast(e.message, "err")); + loadStatuses(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!updating && !updateStatus?.runtime.running) return; + const timer = window.setInterval(() => { + loadStatuses(); + }, 1500); + return () => window.clearInterval(timer); + }, [updating, updateStatus?.runtime.running]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!updating || updateStatus?.runtime.running) return; + setUpdating(false); + if (updateStatus?.runtime.last_message) { + toast(updateStatus.runtime.last_message, updateStatus.runtime.last_status === "ok" ? "ok" : "err"); + } + }, [updating, updateStatus?.runtime.running, updateStatus?.runtime.last_finished_at]); // eslint-disable-line react-hooks/exhaustive-deps + function set(k: K, v: string) { - setValues((s) => (s ? { ...s, [k]: v } : s)); + setValues((current) => (current ? { ...current, [k]: v } : current)); } async function save() { if (!values) return; setSaving(true); try { - const res = await apiPost("/api/settings", values); + await apiPost("/api/settings", values); toast("Settings saved", "ok"); - if (res?.navidrome?.configured) { - toast(res.navidrome.connected ? "Navidrome connected" : `Navidrome: ${res.navidrome.error || "offline"}`, res.navidrome.connected ? "ok" : "err"); - } + loadStatuses(); onSaved?.(); } catch (e: any) { toast(e.message, "err"); @@ -55,34 +124,115 @@ export default function Settings({ onSaved }: { onSaved?: () => void }) { } } + async function runUpdate() { + if (!values) return; + setUpdating(true); + try { + await apiPost("/api/settings", values); + const result = await apiPost<{ result: { message: string }; status: UpdateStatus }>("/api/update/run"); + setUpdateStatus(result.status); + toast(result.result.message || "Deployment started", "ok"); + } catch (e: any) { + toast(e.message, "err"); + loadStatuses(); + setUpdating(false); + return; + } + } + if (!values) return ; - // Render inline (not as a nested component) so React keeps input identity - // stable across renders — otherwise each keystroke remounts and drops focus. - const groups: { title: string; icon: React.ReactNode; keys: (keyof SettingsValues)[] }[] = [ - { title: "Emby", icon: , keys: ["emby_url", "emby_api_key"] }, - { title: "Navidrome", icon: , keys: ["navidrome_url", "navidrome_user", "navidrome_password"] }, - { title: "Music library", icon: , keys: ["music_root"] }, + const deployConfiguredDraft = !!values.deploy_nas_host.trim() && !!values.deploy_nas_user.trim(); + const deployToolsReady = !!updateStatus?.transport_ready; + const deployAllowed = !!updateStatus?.allowed; + const deployReady = deployAllowed && deployConfiguredDraft && deployToolsReady; + const deployStateLabel = updateStatus + ? deployReady + ? "Ready" + : deployAllowed + ? deployConfiguredDraft + ? "Missing tools" + : "Needs setup" + : "Local only" + : "Checking"; + const deployReason = + !updateStatus + ? null + : !deployAllowed + ? updateStatus.reason + : !deployConfiguredDraft + ? "Set a NAS host and NAS SSH user, then deploy directly from this screen." + : !deployToolsReady + ? updateStatus.reason + : updateStatus.reason; + + const dot = (configured: boolean, connected: boolean): [DotState, string] => + !configured ? ["idle", "Not configured"] : connected ? ["ok", "Connected"] : ["off", "Offline"]; + + const cards: { + title: string; + icon: React.ReactNode; + keys: (keyof SettingsValues)[]; + status: [DotState, string]; + }[] = [ + { + title: "Emby", + icon: , + keys: ["emby_url", "emby_api_key", "homescreen_db_path", "tmdb_api_key"], + status: dot(!!config?.emby.connected, !!config?.emby.connected), + }, + { + title: "Navidrome", + icon: , + keys: ["navidrome_url", "navidrome_user", "navidrome_password"], + status: dot(!!navStatus?.configured, !!navStatus?.connected), + }, + { + title: "Audiobookshelf", + icon: , + keys: ["audiobookshelf_url", "audiobookshelf_token"], + status: dot(!!absStatus?.configured, !!absStatus?.connected), + }, + { + title: "Music library", + icon: , + keys: ["music_root"], + status: config?.music.available ? (["ok", "Mounted"] as [DotState, string]) : (["off", "Not mounted"] as [DotState, string]), + }, ]; return ( <> - }> - Configure connections without touching environment variables. Saved settings are written to the app's config - file and applied immediately — they override the deploy-time defaults. - +
+ } /> +
+ + +
+
-
- {groups.map((g) => ( -
+
Connections
+
+ {cards.map((c) => ( +
-
- {g.icon} +
+ {c.icon}
-

{g.title}

+

{c.title}

+ + + + {c.status[1]} + +
- {g.keys.map((k) => ( + {c.keys.map((k) => (
void }) {
))} +
+

+ Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network. +

-
- - +
Update
+
+
+
+ +
+

Deploy to Docker host

+ {updateStatus ? ( + + + + {deployStateLabel} + + + ) : null} +
+
+
+ {(["deploy_nas_host", "deploy_nas_user", "deploy_nas_password", "deploy_remote_app_dir", "deploy_music_host_path"] as const).map((k) => ( +
+ + set(k, e.target.value)} + /> +
+ ))} +
+ +
+ + + {updateStatus?.runtime.last_finished_at ? Last run: {updateStatus.runtime.last_finished_at} : null} + {updateStatus?.runtime.last_status && updateStatus.runtime.last_status !== "idle" ? ( + + {updateStatus.runtime.last_status} + + ) : null} +
+ +

+ Available only when the app is opened from a local/private address like 127.0.0.1 or 10.0.0.124. The app now deploys directly over Python SSH, syncing the repo and rebuilding Docker on the configured NAS host without relying on PowerShell or interactive prompts. +

+

+ The remote compose file is rendered from these deploy settings, including the NAS-side music bind mount path. +

+ {updateStatus?.client_host ?

Detected client: {updateStatus.client_host}

: null} + {updateStatus?.transport ?

Transport: {updateStatus.transport} · Auth: {updateStatus.password_configured ? "saved password" : "SSH keys / agent"}

: null} + {deployReason ?

{deployReason}

: null} + {updateStatus?.runtime.running ?

Deployment in progress. Status refreshes automatically.

: null} + {updateStatus?.runtime.last_message ?

Last result: {updateStatus.runtime.last_message}

: null} + {updateStatus?.runtime.last_output_tail?.length ? ( +
+ +
+ {updateStatus.runtime.last_output_tail.map((line, index) => ( +
+ {line} +
+ ))} +
+
+ ) : null}
-

- Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network. -

); diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx new file mode 100644 index 0000000..997f590 --- /dev/null +++ b/frontend/src/pages/Tasks.tsx @@ -0,0 +1,390 @@ +import { useEffect, useMemo, useState } from "react"; +import { apiGet, apiPost } from "../api"; +import { Empty, Loading, PageHead } from "../components/ui"; +import { + IconCalendar, + IconCheck, + IconChevron, + IconClock, + IconDisc, + IconEmby, + IconMusic, + IconPlay, + IconRefresh, + IconSettings, + IconTrash, +} from "../components/icons"; +import { useToast } from "../lib/toast"; + +interface PrerollSettings { + preroll_enabled: boolean; + preroll_active_dir: string; + preroll_inactive_dir: string; + preroll_state_file: string; + preroll_weekday: number; + preroll_time: string; +} + +interface PrerollTaskStatus { + enabled: boolean; + next_run_at: string | null; + due_now: boolean; + schedule_error: string | null; + runtime: { + running: boolean; + last_message: string | null; + }; + state: { + last_rotation?: string | null; + active_file?: string | null; + }; +} + +interface CleanupTask { + id: string; + section: "emby" | "navidrome" | string; + section_title: string; + title: string; + description: string; + supports_run: boolean; + supports_automation: boolean; + requires?: string | null; + run_label: string; + settings: { + automation_enabled: boolean; + weekday: number; + time: string; + retention_days: number; + }; + status: { + last_run_at?: string; + last_status?: string; + last_result?: { message?: string } | null; + }; + next_run_at: string | null; + schedule_error: string | null; +} + +const WEEKDAYS = [ + { value: 0, label: "Monday" }, + { value: 1, label: "Tuesday" }, + { value: 2, label: "Wednesday" }, + { value: 3, label: "Thursday" }, + { value: 4, label: "Friday" }, + { value: 5, label: "Saturday" }, + { value: 6, label: "Sunday" }, +]; + +const SECTION_ORDER = ["emby", "navidrome"]; + +export default function Tasks() { + const toast = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [prerollSaving, setPrerollSaving] = useState(false); + const [prerollBusy, setPrerollBusy] = useState(false); + const [tasks, setTasks] = useState([]); + const [expanded, setExpanded] = useState>({ preroll: true }); + const [prerollSettings, setPrerollSettings] = useState(null); + const [prerollStatus, setPrerollStatus] = useState(null); + + async function load() { + setLoading(true); + try { + const [preroll, cleanup] = await Promise.all([ + apiGet<{ settings: PrerollSettings; status: PrerollTaskStatus }>("/api/tasks/preroll"), + apiGet<{ tasks: CleanupTask[] }>("/api/tasks/cleanup"), + ]); + setPrerollSettings(preroll.settings); + setPrerollStatus(preroll.status); + setTasks(cleanup.tasks); + setExpanded((current) => { + const next = { ...current }; + for (const task of cleanup.tasks) { + if (!(task.id in next)) next[task.id] = false; + } + return next; + }); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const sections = useMemo(() => { + const grouped = new Map(); + for (const task of tasks) { + if (!grouped.has(task.section)) grouped.set(task.section, { title: task.section_title, tasks: [] }); + grouped.get(task.section)!.tasks.push(task); + } + const orderedKeys = [...SECTION_ORDER.filter((key) => grouped.has(key)), ...[...grouped.keys()].filter((key) => !SECTION_ORDER.includes(key))]; + return orderedKeys.map((key) => ({ key, ...grouped.get(key)! })); + }, [tasks]); + + function setPreroll(key: K, value: PrerollSettings[K]) { + setPrerollSettings((current) => (current ? { ...current, [key]: value } : current)); + } + + function setTask(taskId: string, patch: Partial) { + setTasks((current) => current.map((task) => (task.id === taskId ? { ...task, settings: { ...task.settings, ...patch } } : task))); + } + + async function savePreroll() { + if (!prerollSettings) return; + setPrerollSaving(true); + try { + await apiPost("/api/settings", prerollSettings); + toast("System task settings saved", "ok"); + await load(); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setPrerollSaving(false); + } + } + + async function saveCleanupTasks() { + setSaving(true); + try { + const payload = Object.fromEntries(tasks.map((task) => [task.id, task.settings])); + const res = await apiPost<{ tasks: CleanupTask[] }>("/api/tasks/cleanup/settings", { emby_tasks: payload }); + setTasks(res.tasks); + toast("Task automation saved", "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setSaving(false); + } + } + + async function runPrerollNow() { + setPrerollBusy(true); + try { + const res = await apiPost<{ status: PrerollTaskStatus; result: { message: string } }>("/api/tasks/preroll/run"); + setPrerollStatus(res.status); + toast(res.result.message || "Preroll rotated", "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setPrerollBusy(false); + } + } + + async function runTask(taskId: string, dryRun: boolean) { + try { + await apiPost("/api/tasks/cleanup/settings", { emby_tasks: Object.fromEntries(tasks.map((task) => [task.id, task.settings])) }); + const res = await apiPost<{ tasks: CleanupTask[]; result: { message: string } }>(`/api/tasks/cleanup/${taskId}/run`, { dryRun }); + setTasks(res.tasks); + toast(res.result.message || "Task completed", "ok"); + } catch (e: any) { + toast(e.message, "err"); + } + } + + function taskIcon(section: string) { + if (section === "emby") return ; + if (section === "navidrome") return ; + return ; + } + + function requirementHint(task: CleanupTask) { + if (task.requires === "emby_server_data") { + return "This one needs Emby's internal server-data path mounted into HomelabToolkit before it can safely inspect or delete files."; + } + if (task.requires === "music_root") { + return "This task needs the configured music root mounted into HomelabToolkit so it can inspect and update your library files."; + } + return null; + } + + if (loading || !prerollSettings) return ; + + return ( + <> +
+ } /> +
+ + +
+
+ +
System
+
+
+ + {expanded.preroll ? ( +
+
+ + + {prerollStatus?.next_run_at ? Next run: {prerollStatus.next_run_at} : null} + {prerollStatus?.state?.last_rotation ? Last run: {prerollStatus.state.last_rotation} : null} +
+ +
+
+ + +
+
+ + setPreroll("preroll_time", e.target.value)} /> +
+
+
+ + setPreroll("preroll_active_dir", e.target.value)} /> +
+
+ + setPreroll("preroll_inactive_dir", e.target.value)} /> +
+
+ + setPreroll("preroll_state_file", e.target.value)} /> +
+ {prerollStatus?.schedule_error ?

Schedule error: {prerollStatus.schedule_error}

: null} + {prerollStatus?.runtime.last_message ?

Last result: {prerollStatus.runtime.last_message}

: null} +
+ ) : null} +
+
+ + {sections.map((section) => ( +
+
{section.title}
+
+ {section.tasks.map((task) => { + const hint = requirementHint(task); + return ( +
+ + {expanded[task.id] ? ( +
+
+ + + {task.status?.last_run_at ? Last run: {task.status.last_run_at} : null} + {task.status?.last_status ? {task.status.last_status} : null} +
+ {task.supports_automation ? ( + <> + +
+
+ + +
+
+ + setTask(task.id, { time: e.target.value })} /> +
+ {"retention_days" in task.settings ? ( +
+ + setTask(task.id, { retention_days: Number(e.target.value) || 1 })} + /> +
+ ) : null} +
+ + ) : ( +

+ This task cannot be automated from the current deployment. +

+ )} + {task.schedule_error ?

Schedule error: {task.schedule_error}

: null} + {task.status?.last_result?.message ?

Last result: {task.status.last_result.message}

: null} + {hint ?

{hint}

: null} +
+ ) : null} +
+ ); + })} +
+
+ ))} + + {!tasks.length ? }>No cleanup tasks available. : null} + + ); +} diff --git a/frontend/src/pages/audiobookshelf/Overview.tsx b/frontend/src/pages/audiobookshelf/Overview.tsx new file mode 100644 index 0000000..1dd3735 --- /dev/null +++ b/frontend/src/pages/audiobookshelf/Overview.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { apiGet } from "../../api"; +import { PageHead, StatCard, Empty, Loading, fmtNumber } from "../../components/ui"; +import { IconBook, IconClock, IconHeadphones, IconRefresh, IconUser } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface LibraryView { + id: string; + name: string; + media_type: string; + items: number; + authors: number; + duration: number; + size: number; +} +interface Stats { + library_count: number; + book_count: number; + podcast_count: number; + author_count: number; + total_duration: number; + total_size: number; + num_audio_tracks: number; + libraries: LibraryView[]; +} + +function fmtHours(seconds: number): string { + if (!seconds) return "0h"; + const h = Math.floor(seconds / 3600); + if (h >= 24) return `${(h / 24).toFixed(0)}d ${h % 24}h`; + const m = Math.floor((seconds % 3600) / 60); + return h > 0 ? `${h}h ${m}m` : `${m}m`; +} +function fmtSize(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = bytes; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(1)} ${units[i]}`; +} + +export default function AudiobookshelfOverview() { + const toast = useToast(); + const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + function load() { + setLoading(true); + apiGet("/api/audiobookshelf/status") + .then((s) => { + setStatus(s); + if (s.connected) { + return apiGet("/api/audiobookshelf/stats").then(setStats); + } + setStats(null); + }) + .catch(() => setStatus({ connected: false, configured: false })) + .finally(() => setLoading(false)); + } + useEffect(load, []); + + return ( + <> +
+ } /> + +
+ + {loading ? ( + + ) : !status?.configured ? ( +
+ }> + Audiobookshelf is not configured. Add your server URL and API token in Settings. + +
+ ) : !status.connected ? ( +
+ }>Could not connect to Audiobookshelf. {status.error} +
+ ) : ( + <> +
+ } value={fmtNumber(stats?.book_count)} label="Audiobooks" /> + } value={fmtNumber(stats?.podcast_count)} label="Podcasts" /> + } value={fmtNumber(stats?.author_count)} label="Authors" /> + } value={fmtHours(stats?.total_duration || 0)} label="Total runtime" /> +
+
+ } value={fmtNumber(stats?.library_count)} label="Libraries" /> + } value={fmtNumber(stats?.num_audio_tracks)} label="Audio tracks" /> + } value={fmtSize(stats?.total_size || 0)} label="On disk" /> +
+ +
+
+

Libraries

+ {stats?.libraries.length || 0} +
+ {!stats?.libraries.length ? ( + }>No libraries found. + ) : ( + + + + + + + + + + + + + {stats.libraries.map((lib) => ( + + + + + + + + + ))} + +
LibraryTypeItemsAuthorsRuntimeSize
{lib.name} + + {lib.media_type} + + {fmtNumber(lib.items)}{fmtNumber(lib.authors)}{fmtHours(lib.duration)}{fmtSize(lib.size)}
+ )} +
+ + )} + + ); +} diff --git a/frontend/src/pages/emby/Airing.tsx b/frontend/src/pages/emby/Airing.tsx index 34ed98e..52ed38c 100644 --- a/frontend/src/pages/emby/Airing.tsx +++ b/frontend/src/pages/emby/Airing.tsx @@ -99,10 +99,7 @@ export default function Airing() { return ( <> - - Series currently airing in your library. Eligible new-season premieres can be stamped with "New Season" - artwork in one click. - + } />
diff --git a/frontend/src/pages/emby/AvatarGenerator.tsx b/frontend/src/pages/emby/AvatarGenerator.tsx new file mode 100644 index 0000000..c7ebfa8 --- /dev/null +++ b/frontend/src/pages/emby/AvatarGenerator.tsx @@ -0,0 +1,49 @@ +import { PageHead } from "../../components/ui"; +import { IconImage, IconUser } from "../../components/icons"; + +export default function AvatarGenerator() { + return ( + <> + } /> + +
+
+

Standalone Emby utility

+ Python script +
+
+

+ This tool lives in emby-avatar-generator.py and generates rounded user avatar tiles for Emby users. +

+
+
+
+ +
+
+
User avatars
+
Creates initials-based PNG profile images
+
+
+
+
+ +
+
+
Modern gradients
+
Mesh backgrounds, shapes, and rounded corners
+
+
+
+
+ +
emby-avatar-generator.py
+
+

+ This page adds the avatar generator to the Emby tool list. The script itself is still run directly from the workspace. +

+
+
+ + ); +} diff --git a/frontend/src/pages/emby/BulkAssign.tsx b/frontend/src/pages/emby/BulkAssign.tsx index 4910f93..a6dbd16 100644 --- a/frontend/src/pages/emby/BulkAssign.tsx +++ b/frontend/src/pages/emby/BulkAssign.tsx @@ -97,10 +97,7 @@ export default function BulkAssign() { return ( <> - - Generate and push landscape thumbnails across many titles at once. Eligible titles need an Emby primary, logo - and backdrop. - + } />
diff --git a/frontend/src/pages/emby/Collections.tsx b/frontend/src/pages/emby/Collections.tsx index 7abe1b5..badfe78 100644 --- a/frontend/src/pages/emby/Collections.tsx +++ b/frontend/src/pages/emby/Collections.tsx @@ -101,7 +101,7 @@ export default function Collections() { return ( <> - Generate cover artwork for your Emby collections with custom titling. + } />
diff --git a/frontend/src/pages/emby/Favorites.tsx b/frontend/src/pages/emby/Favorites.tsx index e0e0c39..76b4877 100644 --- a/frontend/src/pages/emby/Favorites.tsx +++ b/frontend/src/pages/emby/Favorites.tsx @@ -113,10 +113,7 @@ export default function Favorites() { return ( <> - - Browse any Emby collection with per-user watched status, prune watched items, and top up with personalized - recommendations. Both actions default to a safe dry run. - + } />
diff --git a/frontend/src/pages/emby/Generator.tsx b/frontend/src/pages/emby/Generator.tsx index faafb85..74e3abe 100644 --- a/frontend/src/pages/emby/Generator.tsx +++ b/frontend/src/pages/emby/Generator.tsx @@ -148,9 +148,7 @@ export default function Generator() { return ( <> - - Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby. - + } />
{/* search column */} diff --git a/frontend/src/pages/emby/HomescreenEditor.tsx b/frontend/src/pages/emby/HomescreenEditor.tsx new file mode 100644 index 0000000..8d74a40 --- /dev/null +++ b/frontend/src/pages/emby/HomescreenEditor.tsx @@ -0,0 +1,920 @@ +import { useEffect, useMemo, useState } from "react"; +import { apiGet, apiPost, uploadHomescreenDb } from "../../api"; +import { Empty, Loading, PageHead, formatNZ, timeAgo } from "../../components/ui"; +import { + IconCheck, + IconEmby, + IconLayers, + IconRefresh, + IconSearch, + IconTrash, + IconUser, +} from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface HomescreenSettings { + homescreen_db_path: string; + tmdb_api_key: string; +} + +interface UploadedDb { + upload_id: string; + filename: string; + size_bytes: number; + uploaded_at: string | null; + sha256: string; + path: string; +} + +interface HomescreenEnums { + section_types: { value: string; label: string }[]; + collection_types: { value: string; label: string }[]; + item_types: string[]; + sort_options: { value: string; label: string }[]; + image_types: { value: string; label: string }[]; +} + +interface HomescreenUser { + id: string | number; + name: string; + dbName?: string; + guid?: string; + embyGuid?: string; + embyName?: string | null; + sections: Record[]; + details?: { + sourceTable?: string | null; + lastLoginDate?: string | null; + lastActivityDate?: string | null; + importedCollectionsCount?: number; + }; + match?: { + ok?: boolean; + mismatchedSectionUserIds?: string[]; + missingSectionUserIds?: number; + }; +} + +interface DbReadPayload { + users: HomescreenUser[]; + validation: { + userSource: string | null; + userCount: number; + settingsCount: number; + matchedUsers: number; + mismatchedUsers: number; + normalizedUsers: number; + missingSectionUserIds: number; + orphanedSettingsUserIds: string[]; + embyCacheMatchedUsers?: number; + embyCacheUserCount?: number; + embyCacheLastSyncedAt?: string | null; + }; + source?: { + mode: "upload" | "path"; + db_path: string; + upload?: UploadedDb | null; + }; +} + +interface EmbyUsersPayload { + users: { embyGuid: string; name: string }[]; + source: "live" | "cache"; + lastSyncedAt: string | null; + message?: string; +} + +interface UserContextPayload { + views: { id: string; name: string; type: string }[]; + recentlyPlayed: { id: string; name: string; type: string; seriesName?: string | null; datePlayed?: string | null }[]; + excludedFolderLookup: Record; + source: "live" | "cache"; + lastSyncedAt?: string | null; + message?: string; +} + +function makeId() { + return crypto.randomUUID().replace(/-/g, "").slice(0, 32); +} + +function createEmptySection(userId: string) { + return { + UserId: userId, + Name: "New Section", + CustomName: "New Section", + Id: makeId(), + SectionType: "items", + ImageType: "Thumb", + CollectionType: "movies", + SortBy: "Random", + SortOrder: "Descending", + Monitor: [], + ItemTypes: ["Movie"], + ExcludedFolders: [], + CardSizeOffset: 0, + IncludeNextUpInResume: true, + Query: { + StudioIds: [], + TagIds: [], + GenreIds: [], + CollectionTypes: [], + IsPlayed: false, + }, + }; +} + +function createRecentlyWatchedSection(userId: string, userName = "") { + const label = userName ? `Recently Watched - ${userName}` : "Recently Watched"; + return { + UserId: userId, + Name: label, + CustomName: label, + Id: makeId(), + SectionType: "items", + ImageType: "Thumb", + CollectionType: "", + SortBy: "DatePlayed", + SortOrder: "Descending", + Monitor: [], + ItemTypes: ["Movie", "Series"], + ExcludedFolders: [], + CardSizeOffset: 0, + IncludeNextUpInResume: true, + Query: { + StudioIds: [], + TagIds: [], + GenreIds: [], + CollectionTypes: [], + IsPlayed: true, + }, + }; +} + +function createCollectionSection(userId: string) { + return { + UserId: userId, + Name: "New Collection", + CustomName: "New Collection", + Id: makeId(), + SectionType: "boxset", + ImageType: "Thumb", + ItemTypes: [], + SortBy: "Random", + SortOrder: "Descending", + Monitor: [], + ExcludedFolders: [], + CardSizeOffset: 0, + IncludeNextUpInResume: true, + ParentItem: { + Name: "New Collection", + Id: "", + }, + ParentId: "", + }; +} + +function cloneSectionsForTarget(sections: Record[], targetGuid: string, mode: "append" | "replace", existing: Record[]) { + const cloned = sections.map((section) => ({ + ...JSON.parse(JSON.stringify(section)), + UserId: targetGuid, + Id: makeId(), + })); + return mode === "replace" ? cloned : [...existing, ...cloned]; +} + +export default function HomescreenEditor() { + const toast = useToast(); + const [settings, setSettings] = useState(null); + const [enums, setEnums] = useState(null); + const [users, setUsers] = useState([]); + const [originalUsers, setOriginalUsers] = useState([]); + const [validation, setValidation] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(""); + const [selectedSectionIndex, setSelectedSectionIndex] = useState(0); + const [sectionJson, setSectionJson] = useState(""); + const [sqlPreview, setSqlPreview] = useState(""); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [savingSettings, setSavingSettings] = useState(false); + const [userContext, setUserContext] = useState(null); + const [contextBusy, setContextBusy] = useState(false); + const [syncSourceId, setSyncSourceId] = useState(""); + const [syncTargetIds, setSyncTargetIds] = useState([]); + const [syncMode, setSyncMode] = useState<"append" | "replace">("append"); + const [uploadedDb, setUploadedDb] = useState(null); + const [uploadingDb, setUploadingDb] = useState(false); + + useEffect(() => { + async function load() { + setLoading(true); + try { + const [allSettings, enumPayload, dbSource] = await Promise.all([ + apiGet("/api/settings"), + apiGet("/api/homescreen/enums"), + apiGet<{ upload: UploadedDb | null }>("/api/homescreen/db-source"), + ]); + setSettings({ + homescreen_db_path: allSettings.homescreen_db_path || "", + tmdb_api_key: allSettings.tmdb_api_key || "", + }); + setEnums(enumPayload); + setUploadedDb(dbSource.upload || null); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setLoading(false); + } + } + load(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const selectedUser = useMemo( + () => users.find((user) => String(user.id) === selectedUserId) || null, + [users, selectedUserId] + ); + const selectedSection = selectedUser?.sections?.[selectedSectionIndex] || null; + const filteredUsers = useMemo(() => { + const needle = search.trim().toLowerCase(); + if (!needle) return users; + return users.filter((user) => + [user.name, user.dbName, user.embyName, user.embyGuid].some((value) => String(value || "").toLowerCase().includes(needle)) + ); + }, [users, search]); + const changes = useMemo(() => { + const originalLookup = new Map(originalUsers.map((user) => [String(user.id), user])); + return users + .filter((user) => JSON.stringify(user.sections || []) !== JSON.stringify(originalLookup.get(String(user.id))?.sections || [])) + .map((user) => ({ userId: user.id, sections: user.sections, name: user.name })); + }, [originalUsers, users]); + + useEffect(() => { + if (!selectedUser) return; + if (!selectedUser.sections.length) { + setSelectedSectionIndex(0); + setSectionJson(""); + return; + } + if (selectedSectionIndex >= selectedUser.sections.length) { + setSelectedSectionIndex(0); + } + }, [selectedSectionIndex, selectedUser]); + + useEffect(() => { + setSectionJson(selectedSection ? JSON.stringify(selectedSection, null, 2) : ""); + }, [selectedSection]); + + useEffect(() => { + async function loadContext() { + if (!selectedUser?.embyGuid) { + setUserContext(null); + return; + } + const excludedIds = ((selectedSection?.ExcludedFolders as string[]) || []).join(","); + setContextBusy(true); + try { + const payload = await apiGet( + `/api/homescreen/user-context?embyGuid=${encodeURIComponent(selectedUser.embyGuid)}${excludedIds ? `&excludedIds=${encodeURIComponent(excludedIds)}` : ""}` + ); + setUserContext(payload); + } catch (e: any) { + setUserContext({ + views: [], + recentlyPlayed: [], + excludedFolderLookup: {}, + source: "cache", + message: e.message, + }); + } finally { + setContextBusy(false); + } + } + loadContext(); + }, [selectedSection?.ExcludedFolders, selectedUser?.embyGuid]); + + function patchSettings(key: K, value: HomescreenSettings[K]) { + setSettings((current) => (current ? { ...current, [key]: value } : current)); + } + + async function saveEditorSettings() { + if (!settings) return; + setSavingSettings(true); + try { + await apiPost("/api/settings", settings); + toast("Homescreen editor settings saved", "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setSavingSettings(false); + } + } + + async function loadFromDb() { + if (!uploadedDb && !settings?.homescreen_db_path) { + toast("Upload a users.db extract or set a fallback path first", "err"); + return; + } + setBusy(true); + try { + const payload = await apiPost("/api/homescreen/db-read", { + dbPath: settings?.homescreen_db_path, + uploadId: uploadedDb?.upload_id || null, + }); + setUsers(payload.users); + setOriginalUsers(JSON.parse(JSON.stringify(payload.users))); + setValidation(payload.validation); + setUploadedDb(payload.source?.upload || uploadedDb || null); + const firstUser = payload.users.find((user) => user.sections?.length > 0) || payload.users[0]; + setSelectedUserId(firstUser ? String(firstUser.id) : ""); + setSelectedSectionIndex(0); + setSyncSourceId(firstUser ? String(firstUser.id) : ""); + toast(`Loaded ${payload.users.length} user(s) from ${payload.source?.mode === "upload" ? "the uploaded users.db" : "users.db path"}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy(false); + } + } + + async function handleDbUpload(file: File | null) { + if (!file) return; + setUploadingDb(true); + try { + const payload = await uploadHomescreenDb(file); + setUploadedDb(payload.upload); + toast(`Uploaded ${payload.upload.filename}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setUploadingDb(false); + } + } + + async function refreshEmbyNames() { + setBusy(true); + try { + const payload = await apiGet("/api/homescreen/emby-users"); + const nameMap = new Map(payload.users.map((user) => [user.embyGuid, user.name])); + setUsers((current) => + current.map((user) => { + const name = user.embyGuid ? nameMap.get(String(user.embyGuid).toLowerCase()) : null; + return name ? { ...user, name, embyName: name } : user; + }) + ); + setOriginalUsers((current) => + current.map((user) => { + const name = user.embyGuid ? nameMap.get(String(user.embyGuid).toLowerCase()) : null; + return name ? { ...user, name, embyName: name } : user; + }) + ); + toast(payload.message || `Loaded ${payload.users.length} Emby user name(s) from ${payload.source}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy(false); + } + } + + function replaceSelectedUser(nextUser: HomescreenUser) { + setUsers((current) => current.map((user) => (String(user.id) === String(nextUser.id) ? nextUser : user))); + } + + function updateSelectedSection(mutator: (section: Record) => Record) { + if (!selectedUser || !selectedSection) return; + const nextSections = selectedUser.sections.map((section, index) => (index === selectedSectionIndex ? mutator(JSON.parse(JSON.stringify(section))) : section)); + replaceSelectedUser({ ...selectedUser, sections: nextSections }); + } + + function addSection(kind: "empty" | "recent" | "collection") { + if (!selectedUser) return; + const userGuid = selectedUser.embyGuid || ""; + const next = + kind === "recent" + ? createRecentlyWatchedSection(userGuid, selectedUser.name) + : kind === "collection" + ? createCollectionSection(userGuid) + : createEmptySection(userGuid); + const nextSections = [...(selectedUser.sections || []), next]; + replaceSelectedUser({ ...selectedUser, sections: nextSections }); + setSelectedSectionIndex(nextSections.length - 1); + } + + function moveSection(direction: -1 | 1) { + if (!selectedUser || !selectedSection) return; + const nextIndex = selectedSectionIndex + direction; + if (nextIndex < 0 || nextIndex >= selectedUser.sections.length) return; + const nextSections = [...selectedUser.sections]; + [nextSections[selectedSectionIndex], nextSections[nextIndex]] = [nextSections[nextIndex], nextSections[selectedSectionIndex]]; + replaceSelectedUser({ ...selectedUser, sections: nextSections }); + setSelectedSectionIndex(nextIndex); + } + + function removeSection() { + if (!selectedUser || !selectedSection) return; + const nextSections = selectedUser.sections.filter((_, index) => index !== selectedSectionIndex); + replaceSelectedUser({ ...selectedUser, sections: nextSections }); + setSelectedSectionIndex(Math.max(0, selectedSectionIndex - 1)); + } + + function applySectionJson() { + if (!selectedUser) return; + try { + const parsed = JSON.parse(sectionJson); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Section JSON must be an object."); + updateSelectedSection(() => parsed); + toast("Section JSON applied", "ok"); + } catch (e: any) { + toast(e.message, "err"); + } + } + + function toggleItemType(itemType: string) { + updateSelectedSection((section) => { + const current = Array.isArray(section.ItemTypes) ? section.ItemTypes : []; + return { + ...section, + ItemTypes: current.includes(itemType) ? current.filter((value: string) => value !== itemType) : [...current, itemType], + }; + }); + } + + async function previewSql() { + try { + const payload = await apiPost<{ sql: string }>("/api/homescreen/sql-preview", { users, originalUsers }); + setSqlPreview(payload.sql); + } catch (e: any) { + toast(e.message, "err"); + } + } + + async function writeToDb() { + if ((!uploadedDb && !settings?.homescreen_db_path) || !changes.length) return; + if (!window.confirm(`Write homescreen changes for ${changes.length} user(s) directly to the Emby database?\n\nStop Emby first for safety.`)) return; + setBusy(true); + try { + const payload = await apiPost<{ count: number; normalizedSections: number; source?: { upload?: UploadedDb | null } }>("/api/homescreen/db-write", { + dbPath: settings?.homescreen_db_path, + uploadId: uploadedDb?.upload_id || null, + changes, + }); + setOriginalUsers(JSON.parse(JSON.stringify(users))); + setValidation((current) => current ? { ...current, normalizedUsers: payload.normalizedSections } : current); + setUploadedDb(payload.source?.upload || uploadedDb || null); + toast(`Wrote ${payload.count} user(s) to ${uploadedDb ? "the uploaded users.db" : "users.db"}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy(false); + } + } + + function performSync() { + if (!syncSourceId || !syncTargetIds.length) return; + const source = users.find((user) => String(user.id) === syncSourceId); + if (!source) return; + setUsers((current) => + current.map((user) => { + if (!syncTargetIds.includes(String(user.id)) || !user.embyGuid) return user; + return { + ...user, + sections: cloneSectionsForTarget(source.sections || [], user.embyGuid || "", syncMode, user.sections || []), + }; + }) + ); + toast(`Synced ${source.sections.length} section(s) to ${syncTargetIds.length} user(s)`, "ok"); + } + + if (loading || !settings || !enums) return ; + + return ( + <> +
+ } /> +
+ + + + +
+
+ +
+
+
+
+

Editor Settings

+
+
+
+ + handleDbUpload(e.target.files?.[0] || null)} + /> +
+ {uploadedDb ? ( +
+ Uploaded source + {uploadedDb.filename} + {Math.round(uploadedDb.size_bytes / 1024)} KB + {uploadedDb.uploaded_at ? Uploaded {uploadedDb.uploaded_at} : null} +
+ ) : null} +
+ + patchSettings("homescreen_db_path", e.target.value)} + placeholder="Optional if the app host can read the file directly" + /> +
+
+ + patchSettings("tmdb_api_key", e.target.value)} /> +
+ +

+ Uploading a users.db extract is the portable Docker-safe flow and works anywhere the browser can reach this app. The fallback path is only for direct host filesystem access. +

+

+ Stop Emby before writing to users.db. Typical Windows path: C:\ProgramData\Emby-Server\data\users.db +

+
+
+ +
+
+

Users

+ {filteredUsers.length} +
+
+
+ + setSearch(e.target.value)} placeholder="Search users or Emby GUID" /> +
+ {!filteredUsers.length ? ( + }>Load the Emby users database to begin. + ) : ( +
+ {filteredUsers.map((user) => { + const selected = String(user.id) === selectedUserId; + return ( + + ); + })} +
+ )} +
+
+
+ +
+ {!selectedUser ? ( + }>Load a homescreen database and select a user to edit. + ) : ( + <> +
+
+

{selectedUser.name}

+ {selectedUser.details?.lastActivityDate ? Active {timeAgo(selectedUser.details.lastActivityDate)} : null} + {selectedUser.embyGuid ? Linked to Emby : Unlinked} +
+
+
+ DB name: {selectedUser.dbName || selectedUser.name} + {selectedUser.embyGuid ? Emby GUID: {selectedUser.embyGuid} : null} + {selectedUser.details?.lastLoginDate ? Last login: {formatNZ(selectedUser.details.lastLoginDate)} : null} + {selectedUser.match?.mismatchedSectionUserIds?.length ? Mismatched IDs: {selectedUser.match.mismatchedSectionUserIds.length} : null} +
+
+ + + +
+
+
+ +
+
+
+
+

Sections

+ {selectedUser.sections.length} +
+
+ {!selectedUser.sections.length ? ( + }>No homescreen sections for this user yet. + ) : ( + selectedUser.sections.map((section, index) => ( + + )) + )} +
+
+ +
+
+

Sync Sections

+
+
+
+ + +
+
+ + +
+
+ +
+ {users.filter((user) => String(user.id) !== syncSourceId).map((user) => ( + + ))} +
+
+ +
+
+
+ +
+ {!selectedSection ? ( + }>Select a section to edit. + ) : ( + <> +
+
+

{selectedSection.CustomName || selectedSection.Name || "Section"}

+ + + +
+
+
+
+ + updateSelectedSection((section) => ({ ...section, Name: e.target.value }))} /> +
+
+ + updateSelectedSection((section) => ({ ...section, CustomName: e.target.value }))} /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ {enums.item_types.map((itemType) => ( + + ))} +
+
+ +
+ + +
+ + {selectedSection.SectionType === "boxset" ? ( +
+
+ + + updateSelectedSection((section) => ({ + ...section, + ParentItem: { ...(section.ParentItem || {}), Name: e.target.value }, + })) + } + /> +
+
+ + + updateSelectedSection((section) => ({ + ...section, + ParentId: e.target.value, + ParentItem: { ...(section.ParentItem || {}), Id: e.target.value }, + })) + } + /> +
+
+ ) : null} + +
+ +