Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+26 -2
View File
@@ -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
+11 -6
View File
@@ -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"]
+9 -1
View File
@@ -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
+718 -7
View File
@@ -1,5 +1,6 @@
import asyncio
import io
import json
import logging
import os
import hashlib
@@ -9,6 +10,9 @@ import time
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import rotate_preroll
logging.basicConfig(
level=logging.INFO,
@@ -23,18 +27,27 @@ from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingRes
from fastapi.staticfiles import StaticFiles
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError
from services import audiobookshelf as abs_service
from services import cache_maintenance
from services import db as db_service
from services import emby_tasks as emby_tasks_service
from services import favorites as favorites_service
from services import homescreen_editor as homescreen_service
from services import music_covers as music_service
from services import music_library as library_service
from services import music_metadata as metadata_service
from services import navidrome as navidrome_service
from services import settings as settings_service
from services import self_update as self_update_service
from services.favorites import FavoritesError
from services.music_covers import ProcessOptions
from services.navidrome import NavidromeError
from services.recommendations import DEFAULT_TARGET_SIZE
Image.MAX_IMAGE_PIXELS = None # Emby backdrops can exceed PIL's default bomb threshold; source is trusted
# Emby backdrops can exceed PIL's default ~89MP bomb threshold, but leaving it
# unbounded lets one pathological image OOM the container. Cap at a generous but
# finite ceiling (≈178MP, 2× PIL's default) so memory per decode stays bounded.
Image.MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", 178_956_970))
EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a")
@@ -66,6 +79,18 @@ NEW_SEASON_MAX_AGE_DAYS = 21
SEASON_INFERENCE_LOOKBACK_DAYS = 180
airing_lookup_cache: dict[int, dict] = {}
airing_lookup_lock = asyncio.Lock()
preroll_task_lock = asyncio.Lock()
preroll_scheduler: asyncio.Task | None = None
emby_tasks_lock = asyncio.Lock()
emby_tasks_scheduler: asyncio.Task | None = None
preroll_runtime = {
"running": False,
"last_started_at": None,
"last_finished_at": None,
"last_status": "idle",
"last_message": None,
"last_result": None,
}
# ── Studio logo file map ─────────────────────────────────────────────────────
STUDIOS_DIR = Path("static/studios")
@@ -308,17 +333,164 @@ def apply_settings(values: dict) -> None:
navidrome_service.NAVIDROME_URL = values["navidrome_url"]
navidrome_service.NAVIDROME_USER = values["navidrome_user"]
navidrome_service.NAVIDROME_PASSWORD = values["navidrome_password"]
abs_service.ABS_URL = values["audiobookshelf_url"]
abs_service.ABS_TOKEN = values["audiobookshelf_token"]
music_service.MUSIC_ROOT = Path(values["music_root"])
PREROLL_WEEKDAYS = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
]
def preroll_config_from_settings(values: dict | None = None) -> rotate_preroll.PrerollConfig:
values = values or settings_service.load()
return rotate_preroll.PrerollConfig(
active_dir=Path(values["preroll_active_dir"]),
inactive_dir=Path(values["preroll_inactive_dir"]),
state_file=Path(values["preroll_state_file"]),
rotate_weekday=int(values["preroll_weekday"]),
schedule_time=str(values["preroll_time"]).strip(),
)
def preroll_task_due(config: rotate_preroll.PrerollConfig) -> bool:
hour, minute = rotate_preroll.parse_schedule_time(config.schedule_time)
current = rotate_preroll.now()
if current.weekday() != config.rotate_weekday:
return False
scheduled = current.replace(hour=hour, minute=minute, second=0, microsecond=0)
return current >= scheduled
def preroll_status_payload(values: dict | None = None) -> dict:
values = values or settings_service.load()
config = preroll_config_from_settings(values)
next_run_at = None
schedule_error = None
due_now = False
try:
rotate_preroll.parse_schedule_time(config.schedule_time)
due_now = bool(values["preroll_enabled"]) and preroll_task_due(config) and rotate_preroll.should_rotate(config, quiet=True)
next_run = rotate_preroll.now() if due_now else rotate_preroll.next_run_after(config)
next_run_at = next_run.isoformat(timespec="seconds")
except Exception as exc:
schedule_error = str(exc)
return {
"enabled": bool(values["preroll_enabled"]),
"weekday_label": PREROLL_WEEKDAYS[int(values["preroll_weekday"]) % 7],
"next_run_at": next_run_at,
"due_now": due_now,
"schedule_error": schedule_error,
"runtime": dict(preroll_runtime),
"state": rotate_preroll.read_rotation_state(config),
}
async def run_preroll_task(*, force: bool) -> dict:
async with preroll_task_lock:
values = settings_service.load()
config = preroll_config_from_settings(values)
preroll_runtime["running"] = True
preroll_runtime["last_started_at"] = rotate_preroll.now().isoformat(timespec="seconds")
preroll_runtime["last_message"] = None
result = await asyncio.to_thread(rotate_preroll.run_rotation, config, force=force)
preroll_runtime["running"] = False
preroll_runtime["last_finished_at"] = rotate_preroll.now().isoformat(timespec="seconds")
preroll_runtime["last_status"] = "ok" if result.get("ok") else "error"
preroll_runtime["last_message"] = result.get("message")
preroll_runtime["last_result"] = result
return result
async def _preroll_scheduler_loop() -> None:
while True:
try:
values = settings_service.load()
if values["preroll_enabled"]:
config = preroll_config_from_settings(values)
if preroll_task_due(config) and rotate_preroll.should_rotate(config, quiet=True):
result = await run_preroll_task(force=False)
if result.get("ok"):
logger.info("Preroll rotation completed: %s", result.get("message"))
else:
logger.warning("Preroll rotation failed: %s", result.get("message"))
except Exception as exc: # pragma: no cover - keep scheduler alive
logger.warning("Preroll scheduler failed: %s", exc)
await asyncio.sleep(30)
async def run_library_task(task_id: str, *, dry_run: bool, automated: bool = False) -> dict:
async with emby_tasks_lock:
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
result = await emby_tasks_service.run_task(task_id, emby_client_adapter, task_settings, dry_run=dry_run)
emby_tasks_service.record_run(task_id, result, automated=automated)
return result
async def _emby_tasks_scheduler_loop() -> None:
while True:
try:
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
state = emby_tasks_service.load_state()
for task in emby_tasks_service.describe_tasks(task_settings):
if not task["supports_run"] or not task["supports_automation"]:
continue
if emby_tasks_service.is_due(task["id"], task["settings"], state):
result = await run_library_task(task["id"], dry_run=False, automated=True)
if result.get("ok"):
logger.info("Automated %s task %s completed: %s", task["section"], task["id"], result.get("message"))
else:
logger.warning("Automated %s task %s failed: %s", task["section"], task["id"], result.get("message"))
state = emby_tasks_service.load_state()
except Exception as exc: # pragma: no cover - keep scheduler alive
logger.warning("Cleanup task scheduler failed: %s", exc)
await asyncio.sleep(45)
async def _cache_sweeper() -> None:
"""Periodically evict old/oversized cache files so disk stays bounded."""
interval = max(5, cache_maintenance.SWEEP_INTERVAL_MIN) * 60
while True:
try:
result = await asyncio.to_thread(cache_maintenance.prune, CACHE_DIR)
if result["removed"]:
logger.info(
"Cache prune: removed %d file(s), freed %.1f MB (kept %.1f MB)",
result["removed"], result["freed_mb"], result["kept_mb"],
)
except Exception as exc: # pragma: no cover - never let the sweeper crash
logger.warning("Cache prune failed: %s", exc)
await asyncio.sleep(interval)
@asynccontextmanager
async def lifespan(app: FastAPI):
settings_service.init_store()
apply_settings(settings_service.load())
db_service.init_db()
get_http_client()
sweeper = asyncio.create_task(_cache_sweeper())
global preroll_scheduler
global emby_tasks_scheduler
preroll_scheduler = asyncio.create_task(_preroll_scheduler_loop())
emby_tasks_scheduler = asyncio.create_task(_emby_tasks_scheduler_loop())
try:
yield
finally:
sweeper.cancel()
if preroll_scheduler is not None:
preroll_scheduler.cancel()
if emby_tasks_scheduler is not None:
emby_tasks_scheduler.cancel()
global http_client
if http_client is not None:
await http_client.aclose()
@@ -337,7 +509,11 @@ if (FRONTEND_DIST / "assets").exists():
def get_http_client() -> httpx.AsyncClient:
global http_client
if http_client is None:
http_client = httpx.AsyncClient(timeout=HTTP_TIMEOUT)
# Bound the connection pool so idle keep-alives don't accumulate memory.
http_client = httpx.AsyncClient(
timeout=HTTP_TIMEOUT,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=5),
)
return http_client
@@ -2910,6 +3086,10 @@ async def get_config():
"url": navidrome_service.NAVIDROME_URL,
"configured": navidrome_service.is_configured(),
},
"audiobookshelf": {
"url": abs_service.ABS_URL,
"configured": abs_service.is_configured(),
},
"music": {
"root": str(music_service.MUSIC_ROOT),
"available": music_service.MUSIC_ROOT.exists(),
@@ -2917,6 +3097,13 @@ async def get_config():
}
def _request_client_host(request: Request) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded
return request.client.host if request.client else None
# ── Dashboard overview ───────────────────────────────────────────────────────
@@ -3125,6 +3312,18 @@ async def update_settings(request: Request):
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Settings payload must be an object.")
if "preroll_weekday" in body:
try:
weekday = int(body["preroll_weekday"])
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Preroll weekday must be a number from 0 to 6.") from exc
if weekday < 0 or weekday > 6:
raise HTTPException(status_code=400, detail="Preroll weekday must be between 0 and 6.")
if "preroll_time" in body:
try:
rotate_preroll.parse_schedule_time(str(body["preroll_time"]).strip())
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid preroll time: {exc}") from exc
values = settings_service.save(body)
apply_settings(values)
navidrome_status = await navidrome_service.ping(get_http_client())
@@ -3135,6 +3334,375 @@ async def update_settings(request: Request):
}
@app.get("/api/update/status")
async def get_update_status(request: Request):
values = settings_service.load()
return self_update_service.status_payload(values, _request_client_host(request))
@app.post("/api/update/run")
async def run_update(request: Request):
values = settings_service.load()
status = self_update_service.status_payload(values, _request_client_host(request))
if not status["allowed"]:
raise HTTPException(status_code=403, detail=status["reason"] or "Updates are only available from the local network.")
result = await self_update_service.start_update(values, _request_client_host(request))
if not result.get("ok"):
raise HTTPException(status_code=409, detail=result.get("message") or "Deployment failed to start.")
return {
"result": result,
"status": self_update_service.status_payload(values, _request_client_host(request)),
}
# ── Homescreen Editor ────────────────────────────────────────────────────────
@app.get("/api/homescreen/enums")
async def homescreen_enums():
return homescreen_service.enums_payload()
@app.get("/api/homescreen/db-source")
async def homescreen_db_source():
return {"upload": homescreen_service.get_active_upload()}
@app.post("/api/homescreen/db-upload")
async def homescreen_db_upload(request: Request):
form = await request.form()
upload = form.get("file")
if upload is None:
raise HTTPException(status_code=400, detail="No database file uploaded.")
filename = getattr(upload, "filename", "") or "users.db"
try:
content = await upload.read()
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not read uploaded file: {exc}") from exc
try:
meta = await asyncio.to_thread(homescreen_service.save_uploaded_db, filename, content)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Could not store uploaded database: {exc}") from exc
return {"upload": meta}
@app.post("/api/homescreen/db-read")
async def homescreen_db_read(request: Request):
try:
body = await request.json()
except Exception:
body = {}
values = settings_service.load()
requested_path = str((body or {}).get("dbPath") or values.get("homescreen_db_path") or "").strip()
upload_id = str((body or {}).get("uploadId") or "").strip() or None
try:
db_path, upload_meta = await asyncio.to_thread(homescreen_service.resolve_db_source, requested_path, upload_id)
result = await asyncio.to_thread(homescreen_service.read_db, db_path)
enriched = homescreen_service.apply_cached_emby_names(result["users"])
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {
**result,
"users": enriched["users"],
"validation": {
**result["validation"],
"embyCacheMatchedUsers": enriched["cache"]["matchedCount"],
"embyCacheUserCount": enriched["cache"]["totalCachedUsers"],
"embyCacheLastSyncedAt": enriched["cache"]["lastSyncedAt"],
},
"source": {
"mode": "upload" if upload_meta else "path",
"db_path": db_path,
"upload": upload_meta,
},
}
@app.post("/api/homescreen/db-write")
async def homescreen_db_write(request: Request):
try:
body = await request.json()
except Exception:
body = {}
values = settings_service.load()
requested_path = str((body or {}).get("dbPath") or values.get("homescreen_db_path") or "").strip()
upload_id = str((body or {}).get("uploadId") or "").strip() or None
changes = (body or {}).get("changes") if isinstance(body, dict) else None
if not isinstance(changes, list) or not changes:
return {"ok": True, "count": 0, "normalizedSections": 0}
try:
db_path, upload_meta = await asyncio.to_thread(homescreen_service.resolve_db_source, requested_path, upload_id)
result = await asyncio.to_thread(homescreen_service.write_db, db_path, changes)
return {**result, "source": {"mode": "upload" if upload_meta else "path", "db_path": db_path, "upload": upload_meta}}
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
@app.post("/api/homescreen/sql-preview")
async def homescreen_sql_preview(request: Request):
try:
body = await request.json()
except Exception:
body = {}
users = (body or {}).get("users") if isinstance(body, dict) else None
original_users = (body or {}).get("originalUsers") if isinstance(body, dict) else None
if not isinstance(users, list) or not isinstance(original_users, list):
raise HTTPException(status_code=400, detail="Provide users and originalUsers arrays.")
return {"sql": homescreen_service.generate_sql(users, original_users)}
@app.get("/api/homescreen/emby-users")
async def homescreen_emby_users():
values = settings_service.load()
if not values.get("emby_url") or not values.get("emby_api_key"):
raise HTTPException(status_code=400, detail="Emby URL and API key not configured.")
try:
users = await emby_get("/Users")
cached = homescreen_service.write_cached_emby_users(
[{"embyGuid": user.get("Id"), "name": user.get("Name")} for user in users if user.get("Id") and user.get("Name")]
)
return {
"users": cached["users"],
"source": "live",
"lastSyncedAt": cached["lastSyncedAt"],
}
except Exception as exc:
cached = homescreen_service.read_cached_emby_users()
if cached["users"]:
return {
"users": cached["users"],
"source": "cache",
"lastSyncedAt": cached["lastSyncedAt"],
"message": f"Using cached Emby users because the server could not be reached: {exc}",
}
raise HTTPException(status_code=502, detail=f"Could not reach Emby server: {exc}") from exc
async def _homescreen_fetch_all_user_items(emby_guid: str, params: dict[str, Any]) -> list[dict]:
all_items: list[dict] = []
start_index = 0
page_size = 200
while True:
payload = await emby_get(
f"/Users/{emby_guid}/Items",
{
"Recursive": "true",
"GroupItemsIntoCollections": "false",
"Limit": str(page_size),
"StartIndex": str(start_index),
**{key: str(value) for key, value in params.items()},
},
)
page_items = payload.get("Items") or payload or []
all_items.extend(page_items)
total = int(payload.get("TotalRecordCount") or 0)
if not page_items or (total and len(all_items) >= total) or len(page_items) < page_size:
break
start_index += len(page_items)
return all_items
@app.get("/api/homescreen/user-context")
async def homescreen_user_context(embyGuid: str = Query(""), excludedIds: str = Query("")):
emby_guid = homescreen_service.normalize_guid(embyGuid)
excluded_ids = [item.strip() for item in excludedIds.split(",") if item.strip()]
if not emby_guid:
raise HTTPException(status_code=400, detail="Missing embyGuid.")
cached = homescreen_service.read_cached_user_context(emby_guid)
try:
views_payload, recently_played_payload = await asyncio.gather(
emby_get(f"/Users/{emby_guid}/Views"),
_homescreen_fetch_all_user_items(
emby_guid,
{
"Filters": "IsPlayed",
"IncludeItemTypes": "Movie,Series",
"SortBy": "DatePlayed",
"SortOrder": "Descending",
"Fields": "UserData",
},
),
)
excluded_lookup: dict[str, Any] = {}
if excluded_ids:
excluded_payload = await emby_get(
f"/Users/{emby_guid}/Items",
{"Ids": ",".join(excluded_ids), "Fields": "Path"},
)
for item in excluded_payload.get("Items") or excluded_payload or []:
if item.get("Id"):
excluded_lookup[str(item["Id"])] = {
"name": item.get("Name") or item.get("Path") or f"Item {item['Id']}",
"type": item.get("CollectionType") or item.get("Type") or "Item",
}
context = homescreen_service.write_cached_user_context(
emby_guid,
{
"views": [
{
"id": str(item.get("Id") or ""),
"name": item.get("Name") or "Unnamed view",
"type": item.get("CollectionType") or item.get("Type") or "View",
}
for item in (views_payload.get("Items") or views_payload or [])
],
"recentlyPlayed": [
{
"id": str(item.get("Id") or ""),
"name": item.get("Name") or item.get("SeriesName") or "Unknown item",
"type": item.get("Type") or "Item",
"seriesName": item.get("SeriesName"),
"datePlayed": ((item.get("UserData") or {}).get("LastPlayedDate")) or item.get("DateLastMediaAdded"),
"isPlayed": (item.get("UserData") or {}).get("Played", True),
}
for item in recently_played_payload
],
"excludedFolderLookup": excluded_lookup,
"lastSyncedAt": datetime.now().astimezone().isoformat(timespec="seconds"),
},
)
return {**context, "source": "live"}
except Exception as exc:
if cached:
filtered_lookup = (
{key: value for key, value in (cached.get("excludedFolderLookup") or {}).items() if key in excluded_ids}
if excluded_ids
else (cached.get("excludedFolderLookup") or {})
)
return {
**cached,
"excludedFolderLookup": filtered_lookup,
"source": "cache",
"message": f"Using cached Emby user context because live fetch failed: {exc}",
}
raise HTTPException(status_code=502, detail=f"Could not load Emby user context: {exc}") from exc
@app.get("/api/tasks/preroll")
async def get_preroll_task():
values = settings_service.load()
return {
"settings": {
"preroll_enabled": values["preroll_enabled"],
"preroll_active_dir": values["preroll_active_dir"],
"preroll_inactive_dir": values["preroll_inactive_dir"],
"preroll_state_file": values["preroll_state_file"],
"preroll_weekday": values["preroll_weekday"],
"preroll_time": values["preroll_time"],
},
"status": preroll_status_payload(values),
}
@app.post("/api/tasks/preroll/run")
async def run_preroll_task_now():
values = settings_service.load()
try:
rotate_preroll.parse_schedule_time(str(values["preroll_time"]).strip())
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Invalid preroll schedule time: {exc}") from exc
result = await run_preroll_task(force=True)
if not result.get("ok"):
raise HTTPException(status_code=500, detail=result.get("message") or "Preroll rotation failed.")
return {
"result": result,
"status": preroll_status_payload(values),
}
@app.get("/api/tasks/cleanup")
async def get_cleanup_tasks():
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
return {"tasks": emby_tasks_service.describe_tasks(task_settings)}
@app.post("/api/tasks/cleanup/settings")
async def update_cleanup_tasks_settings(request: Request):
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Task settings payload must be an object.")
normalized = emby_tasks_service.normalize_settings(body.get("emby_tasks"))
values = settings_service.save({"emby_tasks": normalized})
return {"tasks": emby_tasks_service.describe_tasks(values["emby_tasks"])}
@app.post("/api/tasks/cleanup/{task_id}/run")
async def run_cleanup_task(task_id: str, request: Request):
try:
body = await request.json()
except Exception:
body = {}
if task_id not in emby_tasks_service.TASK_DEFS:
raise HTTPException(status_code=404, detail="Unknown cleanup task.")
dry_run = True if not isinstance(body, dict) else bool(body.get("dryRun", True))
result = await run_library_task(task_id, dry_run=dry_run, automated=False)
status_code = 200 if result.get("ok") else 409
payload = {
"result": result,
"tasks": emby_tasks_service.describe_tasks(emby_tasks_service.normalize_settings(settings_service.load().get("emby_tasks"))),
}
if status_code != 200:
raise HTTPException(status_code=status_code, detail=result.get("message") or "Task failed.")
return payload
@app.get("/api/tasks/emby")
async def get_emby_tasks():
values = settings_service.load()
task_settings = emby_tasks_service.normalize_settings(values.get("emby_tasks"))
return {"tasks": [task for task in emby_tasks_service.describe_tasks(task_settings) if task["section"] == "emby"]}
@app.post("/api/tasks/emby/settings")
async def update_emby_tasks_settings(request: Request):
return await update_cleanup_tasks_settings(request)
@app.post("/api/tasks/emby/{task_id}/run")
async def run_emby_task(task_id: str, request: Request):
if task_id not in emby_tasks_service.TASK_DEFS or emby_tasks_service.TASK_DEFS[task_id]["section"] != "emby":
raise HTTPException(status_code=404, detail="Unknown Emby task.")
return await run_cleanup_task(task_id, request)
# ── Audiobookshelf ───────────────────────────────────────────────────────────
def _handle_abs_error(exc: abs_service.AudiobookshelfError) -> HTTPException:
return HTTPException(status_code=exc.status, detail=exc.message)
@app.get("/api/audiobookshelf/status")
async def audiobookshelf_status():
return await abs_service.ping(get_http_client())
@app.get("/api/audiobookshelf/stats")
async def audiobookshelf_stats():
try:
return await abs_service.get_stats(get_http_client())
except abs_service.AudiobookshelfError as exc:
raise _handle_abs_error(exc) from exc
@app.get("/api/audiobookshelf/libraries")
async def audiobookshelf_libraries():
try:
return {"items": await abs_service.get_libraries(get_http_client())}
except abs_service.AudiobookshelfError as exc:
raise _handle_abs_error(exc) from exc
# ── Navidrome (Subsonic API) ─────────────────────────────────────────────────
@@ -3173,21 +3741,36 @@ async def navidrome_albums(
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache: dict = {"data": None, "expires": 0.0}
NAVIDROME_FORMATS_TTL = 1800
# The format breakdown pages the entire song list, so it's expensive. Cache it
# for the whole server session — recompute only when the caller passes
# ?refresh=true (Dashboard's Refresh / rescan buttons), not on every page load.
_navidrome_formats_cache: dict = {"data": None, "fetched_at": None}
_navidrome_reporting_cache: dict = {"data": None, "fetched_at": None}
@app.get("/api/navidrome/formats")
async def navidrome_formats(refresh: bool = Query(False)):
now = time.time()
cached = _navidrome_formats_cache
if not refresh and cached["data"] is not None and cached["expires"] > now:
if not refresh and cached["data"] is not None:
return cached["data"]
try:
data = await navidrome_service.get_format_breakdown(get_http_client())
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache.update(data=data, expires=now + NAVIDROME_FORMATS_TTL)
_navidrome_formats_cache.update(data=data, fetched_at=time.time())
return data
@app.get("/api/navidrome/reporting")
async def navidrome_reporting(refresh: bool = Query(False)):
cached = _navidrome_reporting_cache
if not refresh and cached["data"] is not None:
return cached["data"]
try:
data = await navidrome_service.get_reporting_snapshot(get_http_client())
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_reporting_cache.update(data=data, fetched_at=time.time())
return data
@@ -3199,6 +3782,38 @@ async def navidrome_album(album_id: str):
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/playlists")
async def navidrome_playlists():
try:
return {"items": await navidrome_service.get_playlists(get_http_client())}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.post("/api/navidrome/playlists/delete")
async def navidrome_delete_playlists(request: Request):
try:
body = await request.json()
except Exception:
body = {}
ids = (body or {}).get("ids") if isinstance(body, dict) else None
if not isinstance(ids, list) or not ids:
raise HTTPException(status_code=400, detail="Provide a non-empty list of playlist ids.")
client = get_http_client()
deleted: list[str] = []
failed: list[dict] = []
for raw_id in ids:
playlist_id = str(raw_id).strip()
if not playlist_id:
continue
try:
await navidrome_service.delete_playlist(client, playlist_id)
deleted.append(playlist_id)
except NavidromeError as exc:
failed.append({"id": playlist_id, "error": exc.message})
return {"deleted_count": len(deleted), "failed_count": len(failed), "failed": failed[:12]}
@app.get("/api/navidrome/cover/{cover_id}")
async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
try:
@@ -3213,11 +3828,107 @@ async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
# ── Music library maintenance (music-covers) ─────────────────────────────────
def _ndjson_stream(generator):
"""Wrap a sync generator of dicts as newline-delimited JSON.
Starlette iterates a sync generator in its threadpool, so the blocking NAS
walk and tag reads never block the event loop while results stream out.
"""
for obj in generator:
yield json.dumps(obj) + "\n"
@app.get("/api/music/scan")
async def music_scan():
return await asyncio.to_thread(music_service.scan_library)
@app.get("/api/music/scan/stream")
async def music_scan_stream():
"""Disk-efficient streaming scan: emits one album at a time as NDJSON."""
return StreamingResponse(
_ndjson_stream(music_service.scan_library_stream()),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/process/stream")
async def music_process_stream(request: Request):
"""Streaming maintenance run: emits each action live as NDJSON."""
try:
body = await request.json()
except Exception:
body = {}
options = ProcessOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return StreamingResponse(
_ndjson_stream(music_service.process_library_stream(options, album_paths=album_paths)),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/metadata/process/stream")
async def music_metadata_process_stream(request: Request):
"""Streaming tag-maintenance run (genres / junk / track numbers) as NDJSON."""
try:
body = await request.json()
except Exception:
body = {}
options = metadata_service.MetadataOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return StreamingResponse(
_ndjson_stream(metadata_service.process_library_stream(options, album_paths=album_paths)),
media_type="application/x-ndjson",
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
)
@app.post("/api/music/metadata/process")
async def music_metadata_process(request: Request):
try:
body = await request.json()
except Exception:
body = {}
options = metadata_service.MetadataOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
return await asyncio.to_thread(
metadata_service.process_library, options, album_paths=album_paths
)
@app.get("/api/music/metadata/overrides")
async def music_metadata_overrides():
return {"overrides": await asyncio.to_thread(metadata_service.list_genre_overrides)}
@app.post("/api/music/metadata/overrides")
async def music_metadata_set_override(request: Request):
try:
body = await request.json()
except Exception:
body = {}
artist = (body or {}).get("artist", "")
genre = (body or {}).get("genre", "")
try:
result = await asyncio.to_thread(metadata_service.set_genre_override, artist, genre)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@app.post("/api/music/metadata/overrides/delete")
async def music_metadata_delete_override(request: Request):
try:
body = await request.json()
except Exception:
body = {}
artist = (body or {}).get("artist", "")
await asyncio.to_thread(metadata_service.delete_genre_override, artist)
return {"status": "ok"}
# ── Music Collection Completeness ────────────────────────────────────────────
+35
View File
@@ -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:
+397
View File
@@ -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()
+65 -9
View File
@@ -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<string, [string, string]> = {
"/": ["", "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<AppConfig | null>(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 (
<div className="app">
<Sidebar config={config} navidromeConnected={navidromeConnected} />
<div className={`app ${compactShell ? "app-compact" : ""}`}>
<Sidebar
config={config}
navidromeConnected={navidromeConnected}
audiobookshelfConnected={audiobookshelfConnected}
mobileOpen={compactShell && mobileNavOpen}
onClose={() => setMobileNavOpen(false)}
/>
<div className="main">
<header className="topbar">
<button
className="btn btn-sm topbar-menu"
onClick={() => setMobileNavOpen((open) => !open)}
aria-label={mobileNavOpen ? "Close navigation" : "Open navigation"}
aria-expanded={mobileNavOpen}
aria-hidden={!compactShell}
tabIndex={compactShell ? 0 : -1}
>
{mobileNavOpen ? <IconClose /> : <IconMenu />}
</button>
<div className="crumbs">
{section && (
<>
@@ -58,18 +107,25 @@ export default function App() {
{page}
</div>
<div className="topbar-spacer" />
<CommandPalette />
</header>
<div className="content">
<Routes>
<Route path="/" element={<Dashboard config={config} navidromeConnected={navidromeConnected} />} />
<Route path="/emby/generator" element={<Generator />} />
<Route path="/emby/avatar-generator" element={<AvatarGenerator />} />
<Route path="/emby/collections" element={<Collections />} />
<Route path="/emby/airing" element={<Airing />} />
<Route path="/emby/bulk-assign" element={<BulkAssign />} />
<Route path="/emby/favorites" element={<Favorites />} />
<Route path="/emby/homescreen" element={<HomescreenEditor />} />
<Route path="/navidrome/library" element={<Library />} />
<Route path="/navidrome/covers" element={<CoverManager />} />
<Route path="/navidrome/reporting" element={<Reporting />} />
<Route path="/navidrome/cleanup" element={<CoverManager />} />
<Route path="/navidrome/metadata" element={<Metadata />} />
<Route path="/collection-completeness" element={<CollectionCompleteness />} />
<Route path="/audiobookshelf" element={<AudiobookshelfOverview />} />
<Route path="/tasks" element={<Tasks />} />
<Route path="/settings" element={<Settings onSaved={refreshConfig} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+54
View File
@@ -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<void> {
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 };
}
+111
View File
@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="cmdk" ref={rootRef}>
<div className="cmdk-field">
<IconSearch />
<input
ref={inputRef}
className="cmdk-input"
placeholder="Search commands…"
value={query}
onFocus={() => setOpen(true)}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
/>
<kbd className="cmdk-kbd">K</kbd>
</div>
{open && (
<div className="cmdk-menu">
{results.length === 0 ? (
<div className="cmdk-empty">No matching commands</div>
) : (
results.map((c, i) => (
<button
key={c.to}
className={`cmdk-item ${i === active ? "active" : ""}`}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => {
e.preventDefault(); // keep focus so blur doesn't close before click
go(c.to);
}}
>
<span className="cmdk-icon">{c.icon}</span>
<span className="cmdk-label">{c.label}</span>
<span className="cmdk-section">{c.section}</span>
</button>
))
)}
</div>
)}
</div>
);
}
+44 -22
View File
@@ -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: <IconEmby />,
links: [
{ to: "/emby/generator", label: "Thumbnail Generator", icon: <IconImage /> },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers /> },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar /> },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid /> },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart /> },
{ to: "/emby/generator", label: "Thumb Studio", icon: <IconImage /> },
{ to: "/emby/avatar-generator", label: "Avatars", icon: <IconUser /> },
{ to: "/emby/collections", label: "Collection Covers", icon: <IconLayers /> },
{ to: "/emby/airing", label: "Airing Calendar", icon: <IconCalendar /> },
{ to: "/emby/bulk-assign", label: "Batch Artwork", icon: <IconGrid /> },
{ to: "/emby/favorites", label: "Favorites", icon: <IconHeart /> },
{ to: "/emby/homescreen", label: "Home Screen", icon: <IconLayers /> },
],
},
{
@@ -41,15 +46,17 @@ const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] }
icon: <IconMusic />,
links: [
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc /> },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand /> },
{ to: "/navidrome/reporting", label: "Reporting", icon: <IconMusic /> },
{ to: "/navidrome/cleanup", label: "Library Cleanup", icon: <IconWand /> },
{ to: "/navidrome/metadata", label: "Metadata Editor", icon: <IconMusic /> },
{ to: "/collection-completeness", label: "Collection Completeness", icon: <IconLayers /> },
],
},
{
id: "system",
label: "System",
icon: <IconSettings />,
links: [{ to: "/settings", label: "Settings", icon: <IconSettings /> }],
id: "audiobookshelf",
label: "Audiobookshelf",
icon: <IconBook />,
links: [{ to: "/audiobookshelf", label: "Overview", icon: <IconBook /> }],
},
];
@@ -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<Record<string, boolean>>(() => (activeGroup ? { [activeGroup]: true } : {}));
// Accordion: only one category open at a time. The active route's group opens.
const [open, setOpen] = useState<string | null>(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 className="nav">
<>
<button className={`nav-backdrop ${mobileOpen ? "open" : ""}`} onClick={onClose} aria-label="Close navigation" />
<nav className={`nav ${mobileOpen ? "mobile-open" : ""}`}>
<div className="nav-brand">
<div className="nav-logo">H</div>
<div className="nav-name">
HomelabToolkit
<small>media operations</small>
</div>
<div className="nav-name">HomelabToolkit</div>
</div>
<div className="nav-scroll">
@@ -104,7 +117,7 @@ export default function Sidebar({ config, navidromeConnected }: Props) {
</div>
{GROUPS.map((g) => {
const isOpen = !!open[g.id];
const isOpen = open === g.id;
return (
<div className="nav-group" key={g.id}>
<button className="nav-group-head" onClick={() => toggle(g.id)} aria-expanded={isOpen}>
@@ -134,10 +147,19 @@ export default function Sidebar({ config, navidromeConnected }: Props) {
</div>
<div className="nav-foot">
<NavLink to="/tasks" className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
<IconTrash />
Tasks
</NavLink>
<NavLink to="/settings" className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
<IconSettings />
Settings
</NavLink>
<Chip label="Emby" ok={!!config?.emby.connected} configured={!!config?.emby.connected} />
<Chip label="Navidrome" ok={navidromeConnected} configured={!!config?.navidrome.configured} />
<div className="nav-version">HomelabToolkit v1.0</div>
<Chip label="Audiobookshelf" ok={audiobookshelfConnected} configured={!!config?.audiobookshelf.configured} />
</div>
</nav>
</>
);
}
+50
View File
@@ -115,6 +115,26 @@ export const IconUser = (p: P) => (
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
</svg>
);
export const IconApple = (p: P) => (
<svg {...base} {...p}>
<path d="M15.3 12.2c0-1.7 1.4-2.6 1.5-2.7-.8-1.2-2.1-1.4-2.6-1.4-1.1-.1-2.1.6-2.7.6-.6 0-1.4-.6-2.3-.6-1.2 0-2.3.7-2.9 1.7-1.3 2.2-.3 5.6.9 7.3.6.8 1.3 1.8 2.2 1.7.9 0 1.2-.6 2.3-.6 1 0 1.4.6 2.3.6 1 0 1.6-.8 2.2-1.7.7-1 1-2 1-2.1-.1 0-1.9-.7-1.9-2.8z" />
<path d="M13.7 6.8c.5-.6.9-1.5.8-2.3-.8 0-1.7.5-2.2 1.1-.5.6-.9 1.4-.8 2.2.9.1 1.7-.4 2.2-1z" />
</svg>
);
export const IconAndroid = (p: P) => (
<svg {...base} {...p}>
<path d="M8 9l-1.8-2.7M16 9l1.8-2.7M9 5.5l.2-.1a7 7 0 0 1 5.6 0l.2.1" />
<rect x="6" y="9" width="12" height="8" rx="2.5" />
<path d="M8.5 17v2.5M15.5 17v2.5M4.5 10.5V15M19.5 10.5V15" />
<path d="M10 12h.01M14 12h.01" />
</svg>
);
export const IconWeb = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
</svg>
);
export const IconChevron = (p: P) => (
<svg {...base} {...p}>
<path d="M9 18l6-6-6-6" />
@@ -125,12 +145,42 @@ export const IconFolder = (p: P) => (
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
);
export const IconBook = (p: P) => (
<svg {...base} {...p}>
<path d="M4 4a2 2 0 0 1 2-2h13v18H6a2 2 0 0 0-2 2z" />
<path d="M4 20a2 2 0 0 0 2 2h13" />
<path d="M9 7h6" />
</svg>
);
export const IconHeadphones = (p: P) => (
<svg {...base} {...p}>
<path d="M4 14v-2a8 8 0 0 1 16 0v2" />
<rect x="2.5" y="14" width="4.5" height="6" rx="1.5" />
<rect x="17" y="14" width="4.5" height="6" rx="1.5" />
</svg>
);
export const IconClock = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</svg>
);
export const IconSettings = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
export const IconMenu = (p: P) => (
<svg {...base} {...p}>
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
export const IconClose = (p: P) => (
<svg {...base} {...p}>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
// 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) => (
+1 -4
View File
@@ -1,14 +1,11 @@
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 (
<div className="page-head">
<div className="page-head-row">
{icon && <span className="page-head-icon">{icon}</span>}
<h1>{title}</h1>
</div>
{children && <p>{children}</p>}
</div>
);
}
+47
View File
@@ -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: <IconHome />, keywords: "overview stats home" },
{ to: "/emby/generator", label: "Thumb Studio", section: "Emby", icon: <IconImage />, keywords: "thumbnail thumb cover artwork poster" },
{ to: "/emby/avatar-generator", label: "Avatars", section: "Emby", icon: <IconUser />, keywords: "avatar users profile initials python script" },
{ to: "/emby/collections", label: "Collection Covers", section: "Emby", icon: <IconLayers />, keywords: "collection art covers artwork" },
{ to: "/emby/airing", label: "Airing Calendar", section: "Emby", icon: <IconCalendar />, keywords: "airing schedule new season calendar" },
{ to: "/emby/bulk-assign", label: "Batch Artwork", section: "Emby", icon: <IconGrid />, keywords: "bulk batch assign artwork" },
{ to: "/emby/favorites", label: "Favorites", section: "Emby", icon: <IconHeart />, keywords: "favourites favorites users" },
{ to: "/emby/homescreen", label: "Home Screen", section: "Emby", icon: <IconLayers />, keywords: "home screen homescreen editor users db sections emby" },
{ to: "/navidrome/library", label: "Music Library", section: "Navidrome", icon: <IconDisc />, keywords: "music albums artists" },
{ to: "/navidrome/reporting", label: "Reporting", section: "Navidrome", icon: <IconMusic />, keywords: "plays top tracks stats reports navidrome" },
{ to: "/navidrome/cleanup", label: "Library Cleanup", section: "Navidrome", icon: <IconWand />, keywords: "clean rename covers lyrics" },
{ to: "/navidrome/metadata", label: "Metadata Editor", section: "Navidrome", icon: <IconMusic />, keywords: "genre tags junk track number musicbrainz" },
{ to: "/collection-completeness", label: "Collection Completeness", section: "Navidrome", icon: <IconLayers />, keywords: "missing albums discography" },
{ to: "/audiobookshelf", label: "Overview", section: "Audiobookshelf", icon: <IconBook />, keywords: "audiobooks abs" },
{ to: "/tasks", label: "Tasks", section: "System", icon: <IconTrash />, keywords: "automation cleanup maintenance scheduler" },
{ to: "/settings", label: "Settings", section: "System", icon: <IconSettings />, keywords: "config emby navidrome url api key" },
];
+173 -66
View File
@@ -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<string, string> = {
flac: "var(--accent)",
mp3: "var(--amber)",
@@ -86,13 +115,16 @@ const FORMAT_COLORS: Record<string, string> = {
const formatColor = (fmt: string) => FORMAT_COLORS[fmt.toLowerCase()] || "#6b7c90";
const tools = [
{ to: "/emby/generator", label: "Thumbnail Generator", icon: <IconImage />, cat: "Emby" },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers />, cat: "Emby" },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar />, cat: "Emby" },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid />, cat: "Emby" },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart />, cat: "Emby" },
{ to: "/emby/generator", label: "Thumb Studio", icon: <IconImage />, cat: "Emby" },
{ to: "/emby/avatar-generator", label: "Avatars", icon: <IconUser />, cat: "Emby" },
{ to: "/emby/collections", label: "Collection Covers", icon: <IconLayers />, cat: "Emby" },
{ to: "/emby/airing", label: "Airing Calendar", icon: <IconCalendar />, cat: "Emby" },
{ to: "/emby/bulk-assign", label: "Batch Artwork", icon: <IconGrid />, cat: "Emby" },
{ to: "/emby/favorites", label: "Favorites", icon: <IconHeart />, cat: "Emby" },
{ to: "/emby/homescreen", label: "Home Screen", icon: <IconLayers />, cat: "Emby" },
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc />, cat: "Navidrome" },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand />, cat: "Navidrome" },
{ to: "/navidrome/reporting", label: "Reporting", icon: <IconMusic />, cat: "Navidrome" },
{ to: "/navidrome/cleanup", label: "Library Cleanup", icon: <IconWand />, 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 <span className={`badge ${connected ? "badge-ok" : "badge-bad"}`}>{connected ? "connected" : "offline"}</span>;
}
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 <IconApple />;
if (platform === "android") return <IconAndroid />;
if (platform === "web") return <IconWeb />;
return <IconUser />;
}
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: <IconApple /> },
{ key: "android", label: "Android", count: summary.platforms.android, pct: summary.platform_pct.android, icon: <IconAndroid /> },
{ key: "web", label: "Web", count: summary.platforms.web, pct: summary.platform_pct.web, icon: <IconWeb /> },
{ key: "other", label: "Other", count: summary.platforms.other, pct: summary.platform_pct.other, icon: <IconUser /> },
].filter((item) => item.count > 0);
}
export default function Dashboard({ config, navidromeConnected }: Props) {
const toast = useToast();
const [data, setData] = useState<DashboardData | null>(null);
const [activity, setActivity] = useState<UserActivity[] | null>(null);
const [activitySummary, setActivitySummary] = useState<ActivitySummary | null>(null);
const [formats, setFormats] = useState<FormatData | null>(null);
const [formats, setFormats] = useState<FormatData | null>(() => 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<FormatData>(`/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<DashboardData>("/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<FormatData>("/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 (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Dashboard" icon={<IconHome />}>
A live overview of your media stack Emby library health on the left, your Navidrome music collection on the
right.
</PageHead>
<button className="btn btn-sm" onClick={load} disabled={loading}>
<PageHead title="Dashboard" icon={<IconHome />} />
<button className="btn btn-sm" onClick={() => load()} disabled={loading || formatsLoading}>
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
@@ -257,8 +343,18 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
<MiniStat icon={<IconLayers />} value={fmtNumber(n?.genre_count)} label="Genres" />
</div>
<div>
<div className="section-label" style={{ margin: "4px 0 8px" }}>
<div className="row between" style={{ margin: "4px 0 8px", alignItems: "center" }}>
<span className="section-label" style={{ margin: 0 }}>
Audio formats
</span>
<button
className="btn btn-sm"
onClick={() => loadFormats(true)}
disabled={formatsLoading || !n?.connected}
title="Rescan track formats (otherwise cached for the session)"
>
{formatsLoading ? <span className="spinner" /> : <IconRefresh />}
</button>
</div>
{formatsLoading && !formats ? (
<p className="hint row gap-sm">
@@ -322,24 +418,6 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
<div className="panel-head">
<IconUser className="dim" />
<h3 className="grow">User Activity</h3>
{activitySummary && (
<div className="row wrap gap-sm" style={{ justifyContent: "flex-end" }}>
<span className="badge">{activitySummary.user_count} users</span>
<span className="badge">{activitySummary.device_count} devices</span>
{activitySummary.platforms.android > 0 && (
<span className="badge badge-ok">Android {activitySummary.platform_pct.android}%</span>
)}
{activitySummary.platforms.ios > 0 && (
<span className="badge badge-accent">iOS {activitySummary.platform_pct.ios}%</span>
)}
{activitySummary.platforms.web > 0 && (
<span className="badge badge-warn">Web {activitySummary.platform_pct.web}%</span>
)}
{activitySummary.platforms.other > 0 && (
<span className="badge">Other {activitySummary.platform_pct.other}%</span>
)}
</div>
)}
</div>
{!activity ? (
<div className="panel-body">
@@ -348,40 +426,69 @@ export default function Dashboard({ config, navidromeConnected }: Props) {
) : activity.length === 0 ? (
<Empty icon={<IconUser />}>No Emby users found.</Empty>
) : (
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>User</th>
<th>Last login (NZ)</th>
<th>When</th>
<th>IP address</th>
<th>Device</th>
</tr>
</thead>
<tbody>
<div className="panel-body activity-shell">
{activitySummary && (
<div className="activity-summary-grid">
<div className="activity-summary-card">
<span className="activity-summary-label">Active users</span>
<strong>{fmtNumber(activitySummary.user_count)}</strong>
<span className="activity-summary-sub">Recently seen across Emby</span>
</div>
<div className="activity-summary-card">
<span className="activity-summary-label">Devices</span>
<strong>{fmtNumber(activitySummary.device_count)}</strong>
<span className="activity-summary-sub">Distinct clients reported</span>
</div>
{platformSummaryItems(activitySummary).map((item) => (
<div className="activity-summary-card activity-summary-platform" key={item.key}>
<span className={`activity-summary-icon ${item.key}`}>{item.icon}</span>
<div>
<span className="activity-summary-label">{item.label}</span>
<strong>{item.pct}%</strong>
</div>
<span className="activity-summary-sub">{fmtNumber(item.count)} devices</span>
</div>
))}
</div>
)}
<div className="activity-card-grid">
{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 (
<tr key={u.id}>
<td>
<article className="activity-card" key={u.id}>
<div className="activity-card-head">
<div className="row gap-sm">
<Avatar name={u.name} />
<span className="cell-strong">{u.name}</span>
<div className="activity-user-meta">
<div className="activity-user-name">{u.name}</div>
<div className="activity-user-when">{when ? timeAgo(when) : "Never active"}</div>
</div>
</td>
<td className="mono">{formatNZ(u.last_login)}</td>
<td className="cell-sub">{when ? timeAgo(when) : "Never"}</td>
<td className="mono">{u.ip || <span className="dim"></span>}</td>
<td className="cell-sub">
{u.device || "—"}
{u.client ? ` · ${u.client}` : ""}
</td>
</tr>
</div>
<span className={platformBadgeClass(platform)}>
{platformIcon(platform)}
{platformLabel(platform)}
</span>
</div>
<div className="activity-device-title">{deviceLabel}</div>
<div className="activity-card-meta">
<div className="activity-meta-block">
<span className="activity-meta-label">Last login</span>
<span className="activity-meta-value mono">{formatNZ(u.last_login)}</span>
</div>
<div className="activity-meta-block">
<span className="activity-meta-label">IP address</span>
<span className="activity-meta-value mono">{u.ip || "—"}</span>
</div>
</div>
</article>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
+258 -42
View File
@@ -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<keyof SettingsValues, { label: string; secret?: boolean; placeholder?: string }> = {
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<SettingsValues | null>(null);
const [saving, setSaving] = useState(false);
const [reveal, setReveal] = useState(false);
const [config, setConfig] = useState<AppConfig | null>(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<UpdateStatus | null>(null);
const [updating, setUpdating] = useState(false);
function loadStatuses() {
apiGet<AppConfig>("/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<UpdateStatus>("/api/update/status").then(setUpdateStatus).catch(() => setUpdateStatus(null));
}
useEffect(() => {
apiGet<SettingsValues>("/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 extends keyof SettingsValues>(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 <Loading />;
// 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: <IconEmby />, keys: ["emby_url", "emby_api_key"] },
{ title: "Navidrome", icon: <IconMusic />, keys: ["navidrome_url", "navidrome_user", "navidrome_password"] },
{ title: "Music library", icon: <IconFolder />, 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: <IconEmby />,
keys: ["emby_url", "emby_api_key", "homescreen_db_path", "tmdb_api_key"],
status: dot(!!config?.emby.connected, !!config?.emby.connected),
},
{
title: "Navidrome",
icon: <IconMusic />,
keys: ["navidrome_url", "navidrome_user", "navidrome_password"],
status: dot(!!navStatus?.configured, !!navStatus?.connected),
},
{
title: "Audiobookshelf",
icon: <IconBook />,
keys: ["audiobookshelf_url", "audiobookshelf_token"],
status: dot(!!absStatus?.configured, !!absStatus?.connected),
},
{
title: "Music library",
icon: <IconFolder />,
keys: ["music_root"],
status: config?.music.available ? (["ok", "Mounted"] as [DotState, string]) : (["off", "Not mounted"] as [DotState, string]),
},
];
return (
<>
<PageHead title="Settings" icon={<IconSettings />}>
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.
</PageHead>
<div className="col" style={{ maxWidth: 640, gap: 16 }}>
{groups.map((g) => (
<div className="panel" key={g.title}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
{g.icon}
<div className="page-toolbar">
<PageHead title="Settings" icon={<IconSettings />} />
<div className="page-toolbar-actions">
<label className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={reveal} onChange={(e) => setReveal(e.target.checked)} /> Show secrets
</label>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save
</button>
</div>
<h3>{g.title}</h3>
</div>
<div className="section-label">Connections</div>
<div className="settings-grid">
{cards.map((c) => (
<div className="panel" key={c.title}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
{c.icon}
</div>
<h3 className="grow">{c.title}</h3>
<span className="status-chip" style={{ padding: "5px 9px", background: "transparent", border: 0 }}>
<span className={`dot ${c.status[0] === "ok" ? "" : c.status[0]}`} />
<span className="dim" style={{ fontSize: 12 }}>
{c.status[1]}
</span>
</span>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{g.keys.map((k) => (
{c.keys.map((k) => (
<div className="field" key={k}>
<label className="field-label">{LABELS[k].label}</label>
<input
@@ -99,18 +249,84 @@ export default function Settings({ onSaved }: { onSaved?: () => void }) {
</div>
</div>
))}
<div className="row between">
<label className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={reveal} onChange={(e) => setReveal(e.target.checked)} /> Show secrets
</label>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save settings
</button>
</div>
<p className="hint">
<p className="hint" style={{ marginTop: 12 }}>
Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network.
</p>
<div className="section-label" style={{ marginTop: 28 }}>Update</div>
<div className="panel" style={{ maxWidth: 980 }}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
<IconRefresh />
</div>
<h3 className="grow">Deploy to Docker host</h3>
{updateStatus ? (
<span className="status-chip" style={{ padding: "5px 9px", background: "transparent", border: 0 }}>
<span className={`dot ${deployReady ? "" : deployAllowed ? "off" : "idle"}`} />
<span className="dim" style={{ fontSize: 12 }}>
{deployStateLabel}
</span>
</span>
) : null}
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<div className="settings-update-grid">
{(["deploy_nas_host", "deploy_nas_user", "deploy_nas_password", "deploy_remote_app_dir", "deploy_music_host_path"] as const).map((k) => (
<div className="field" key={k}>
<label className="field-label">{LABELS[k].label}</label>
<input
className="input"
type={LABELS[k].secret && !reveal ? "password" : "text"}
autoComplete="off"
spellCheck={false}
placeholder={LABELS[k].placeholder}
value={values[k]}
onChange={(e) => set(k, e.target.value)}
/>
</div>
))}
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-primary" onClick={runUpdate} disabled={updating || !!updateStatus?.runtime.running || !deployReady}>
{updating || updateStatus?.runtime.running ? <span className="spinner" /> : <IconRefresh />} Deploy now
</button>
<button className="btn btn-sm" onClick={loadStatuses}>
<IconRefresh /> Refresh status
</button>
{updateStatus?.runtime.last_finished_at ? <span className="badge">Last run: {updateStatus.runtime.last_finished_at}</span> : null}
{updateStatus?.runtime.last_status && updateStatus.runtime.last_status !== "idle" ? (
<span className={`badge ${updateStatus.runtime.last_status === "ok" ? "badge-ok" : updateStatus.runtime.last_status === "running" ? "badge-accent" : "badge-bad"}`}>
{updateStatus.runtime.last_status}
</span>
) : null}
</div>
<p className="hint" style={{ margin: 0 }}>
Available only when the app is opened from a local/private address like <code>127.0.0.1</code> or <code>10.0.0.124</code>. 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.
</p>
<p className="hint" style={{ margin: 0 }}>
The remote compose file is rendered from these deploy settings, including the NAS-side music bind mount path.
</p>
{updateStatus?.client_host ? <p className="hint" style={{ margin: 0 }}>Detected client: <code>{updateStatus.client_host}</code></p> : null}
{updateStatus?.transport ? <p className="hint" style={{ margin: 0 }}>Transport: <code>{updateStatus.transport}</code> · Auth: <code>{updateStatus.password_configured ? "saved password" : "SSH keys / agent"}</code></p> : null}
{deployReason ? <p className="hint" style={{ margin: 0, color: "var(--red)" }}>{deployReason}</p> : null}
{updateStatus?.runtime.running ? <p className="hint" style={{ margin: 0 }}>Deployment in progress. Status refreshes automatically.</p> : null}
{updateStatus?.runtime.last_message ? <p className="hint" style={{ margin: 0 }}>Last result: {updateStatus.runtime.last_message}</p> : null}
{updateStatus?.runtime.last_output_tail?.length ? (
<div className="field">
<label className="field-label">Recent deploy output</label>
<div className="console" style={{ maxHeight: 260 }}>
{updateStatus.runtime.last_output_tail.map((line, index) => (
<div className="log-line" key={`${index}-${line}`}>
<span>{line}</span>
</div>
))}
</div>
</div>
) : null}
</div>
</div>
</>
);
+390
View File
@@ -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<CleanupTask[]>([]);
const [expanded, setExpanded] = useState<Record<string, boolean>>({ preroll: true });
const [prerollSettings, setPrerollSettings] = useState<PrerollSettings | null>(null);
const [prerollStatus, setPrerollStatus] = useState<PrerollTaskStatus | null>(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<string, { title: string; tasks: CleanupTask[] }>();
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<K extends keyof PrerollSettings>(key: K, value: PrerollSettings[K]) {
setPrerollSettings((current) => (current ? { ...current, [key]: value } : current));
}
function setTask(taskId: string, patch: Partial<CleanupTask["settings"]>) {
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 <IconEmby />;
if (section === "navidrome") return <IconMusic />;
return <IconDisc />;
}
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 <Loading />;
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Tasks" icon={<IconTrash />} />
<div className="row gap-sm">
<button className="btn btn-sm" onClick={load}>
<IconRefresh /> Reload
</button>
<button className="btn btn-primary" onClick={saveCleanupTasks} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save Task Automation
</button>
</div>
</div>
<div className="section-label">System</div>
<div className="task-stack" style={{ marginBottom: 28 }}>
<div className="panel task-panel">
<button className="task-toggle" onClick={() => setExpanded((current) => ({ ...current, preroll: !current.preroll }))}>
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
<IconCalendar />
</div>
<div className="task-meta grow">
<div className="task-title-row">
<h3>Rotate Emby prerolls</h3>
<span className={`badge ${prerollStatus?.enabled ? "badge-ok" : ""}`}>{prerollStatus?.enabled ? "scheduled" : "disabled"}</span>
</div>
<div className="task-summary">
Weekly on {WEEKDAYS.find((day) => day.value === prerollSettings.preroll_weekday)?.label ?? "Monday"} at {prerollSettings.preroll_time}
</div>
</div>
<IconChevron className={`nav-caret ${expanded.preroll ? "open" : ""}`} />
</button>
{expanded.preroll ? (
<div className="panel-body col task-body" style={{ gap: 14 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm btn-primary" onClick={runPrerollNow} disabled={prerollBusy || prerollStatus?.runtime.running}>
{prerollBusy || prerollStatus?.runtime.running ? <span className="spinner" /> : <IconPlay />} Run now
</button>
<button className="btn btn-sm" onClick={savePreroll} disabled={prerollSaving}>
{prerollSaving ? <span className="spinner" /> : <IconSettings />} Save schedule
</button>
{prerollStatus?.next_run_at ? <span className="badge">Next run: {prerollStatus.next_run_at}</span> : null}
{prerollStatus?.state?.last_rotation ? <span className="badge">Last run: {prerollStatus.state.last_rotation}</span> : null}
</div>
<label className="chip" style={{ cursor: "pointer", width: "fit-content" }}>
<input type="checkbox" checked={prerollSettings.preroll_enabled} onChange={(e) => setPreroll("preroll_enabled", e.target.checked)} /> Enable automation
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">
<IconCalendar /> Weekday
</label>
<select className="input" value={prerollSettings.preroll_weekday} onChange={(e) => setPreroll("preroll_weekday", Number(e.target.value))}>
{WEEKDAYS.map((day) => (
<option key={day.value} value={day.value}>
{day.label}
</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">
<IconClock /> Time
</label>
<input className="input" type="time" value={prerollSettings.preroll_time} onChange={(e) => setPreroll("preroll_time", e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Active preroll folder</label>
<input className="input" value={prerollSettings.preroll_active_dir} onChange={(e) => setPreroll("preroll_active_dir", e.target.value)} />
</div>
<div className="field">
<label className="field-label">Inactive preroll folder</label>
<input className="input" value={prerollSettings.preroll_inactive_dir} onChange={(e) => setPreroll("preroll_inactive_dir", e.target.value)} />
</div>
<div className="field">
<label className="field-label">State file</label>
<input className="input" value={prerollSettings.preroll_state_file} onChange={(e) => setPreroll("preroll_state_file", e.target.value)} />
</div>
{prerollStatus?.schedule_error ? <p className="hint" style={{ margin: 0, color: "var(--bad)" }}>Schedule error: {prerollStatus.schedule_error}</p> : null}
{prerollStatus?.runtime.last_message ? <p className="hint" style={{ margin: 0 }}>Last result: {prerollStatus.runtime.last_message}</p> : null}
</div>
) : null}
</div>
</div>
{sections.map((section) => (
<div key={section.key} style={{ marginBottom: 28 }}>
<div className="section-label">{section.title}</div>
<div className="task-stack">
{section.tasks.map((task) => {
const hint = requirementHint(task);
return (
<div className="panel task-panel" key={task.id}>
<button className="task-toggle" onClick={() => setExpanded((current) => ({ ...current, [task.id]: !current[task.id] }))}>
<div className="stat-icon" style={{ width: 32, height: 32, borderRadius: 9 }}>
{taskIcon(task.section)}
</div>
<div className="task-meta grow">
<div className="task-title-row">
<h3>{task.title}</h3>
<span className={`badge ${task.supports_run ? "badge-ok" : "badge-warn"}`}>{task.supports_run ? "ready" : "needs mount"}</span>
{task.settings.automation_enabled && task.supports_automation ? <span className="badge badge-accent">automation on</span> : null}
</div>
<div className="task-summary">
{task.description}
{task.next_run_at ? ` · Next run ${task.next_run_at}` : ""}
</div>
</div>
<IconChevron className={`nav-caret ${expanded[task.id] ? "open" : ""}`} />
</button>
{expanded[task.id] ? (
<div className="panel-body col task-body" style={{ gap: 14 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={() => runTask(task.id, true)} disabled={!task.supports_run}>
<IconPlay /> Preview
</button>
<button className="btn btn-sm btn-primary" onClick={() => runTask(task.id, false)} disabled={!task.supports_run}>
<IconTrash /> {task.run_label}
</button>
{task.status?.last_run_at ? <span className="badge">Last run: {task.status.last_run_at}</span> : null}
{task.status?.last_status ? <span className={`badge ${task.status.last_status === "ok" ? "badge-ok" : "badge-bad"}`}>{task.status.last_status}</span> : null}
</div>
{task.supports_automation ? (
<>
<label className="chip" style={{ cursor: "pointer", width: "fit-content" }}>
<input
type="checkbox"
checked={task.settings.automation_enabled}
onChange={(e) => setTask(task.id, { automation_enabled: e.target.checked })}
/>{" "}
Enable weekly automation
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Weekday</label>
<select className="input" value={task.settings.weekday} onChange={(e) => setTask(task.id, { weekday: Number(e.target.value) })}>
{WEEKDAYS.map((day) => (
<option key={day.value} value={day.value}>
{day.label}
</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">Time</label>
<input className="input" type="time" value={task.settings.time} onChange={(e) => setTask(task.id, { time: e.target.value })} />
</div>
{"retention_days" in task.settings ? (
<div className="field">
<label className="field-label">Retention days</label>
<input
className="input"
type="number"
min={1}
max={3650}
value={task.settings.retention_days}
onChange={(e) => setTask(task.id, { retention_days: Number(e.target.value) || 1 })}
/>
</div>
) : null}
</div>
</>
) : (
<p className="hint" style={{ margin: 0 }}>
This task cannot be automated from the current deployment.
</p>
)}
{task.schedule_error ? <p className="hint" style={{ margin: 0, color: "var(--bad)" }}>Schedule error: {task.schedule_error}</p> : null}
{task.status?.last_result?.message ? <p className="hint" style={{ margin: 0 }}>Last result: {task.status.last_result.message}</p> : null}
{hint ? <p className="hint" style={{ margin: 0 }}>{hint}</p> : null}
</div>
) : null}
</div>
);
})}
</div>
</div>
))}
{!tasks.length ? <Empty icon={<IconTrash />}>No cleanup tasks available.</Empty> : null}
</>
);
}
@@ -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<Stats | null>(null);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
apiGet("/api/audiobookshelf/status")
.then((s) => {
setStatus(s);
if (s.connected) {
return apiGet<Stats>("/api/audiobookshelf/stats").then(setStats);
}
setStats(null);
})
.catch(() => setStatus({ connected: false, configured: false }))
.finally(() => setLoading(false));
}
useEffect(load, []);
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Audiobookshelf" icon={<IconBook />} />
<button className="btn btn-sm" onClick={load} disabled={loading}>
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
{loading ? (
<Loading label="Loading Audiobookshelf stats…" />
) : !status?.configured ? (
<div className="panel">
<Empty icon={<IconBook />}>
Audiobookshelf is not configured. Add your server URL and API token in <strong>Settings</strong>.
</Empty>
</div>
) : !status.connected ? (
<div className="panel">
<Empty icon={<IconBook />}>Could not connect to Audiobookshelf. {status.error}</Empty>
</div>
) : (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<StatCard icon={<IconBook />} value={fmtNumber(stats?.book_count)} label="Audiobooks" />
<StatCard icon={<IconHeadphones />} value={fmtNumber(stats?.podcast_count)} label="Podcasts" />
<StatCard icon={<IconUser />} value={fmtNumber(stats?.author_count)} label="Authors" />
<StatCard icon={<IconClock />} value={fmtHours(stats?.total_duration || 0)} label="Total runtime" />
</div>
<div className="stat-row" style={{ marginBottom: 26 }}>
<StatCard icon={<IconBook />} value={fmtNumber(stats?.library_count)} label="Libraries" />
<StatCard icon={<IconHeadphones />} value={fmtNumber(stats?.num_audio_tracks)} label="Audio tracks" />
<StatCard icon={<IconBook />} value={fmtSize(stats?.total_size || 0)} label="On disk" />
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Libraries</h3>
<span className="sub">{stats?.libraries.length || 0}</span>
</div>
{!stats?.libraries.length ? (
<Empty icon={<IconBook />}>No libraries found.</Empty>
) : (
<table className="data-table">
<thead>
<tr>
<th>Library</th>
<th>Type</th>
<th>Items</th>
<th>Authors</th>
<th>Runtime</th>
<th>Size</th>
</tr>
</thead>
<tbody>
{stats.libraries.map((lib) => (
<tr key={lib.id}>
<td className="cell-strong">{lib.name}</td>
<td>
<span className={`badge ${lib.media_type === "podcast" ? "badge-warn" : "badge-accent"}`}>
{lib.media_type}
</span>
</td>
<td className="mono">{fmtNumber(lib.items)}</td>
<td className="mono">{fmtNumber(lib.authors)}</td>
<td className="mono cell-sub">{fmtHours(lib.duration)}</td>
<td className="mono cell-sub">{fmtSize(lib.size)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</>
);
}
+1 -4
View File
@@ -99,10 +99,7 @@ export default function Airing() {
return (
<>
<PageHead title="Airing & New Seasons">
Series currently airing in your library. Eligible new-season premieres can be stamped with "New Season"
artwork in one click.
</PageHead>
<PageHead title="Airing & New Seasons" icon={<IconCalendar />} />
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="row gap-sm wrap">
@@ -0,0 +1,49 @@
import { PageHead } from "../../components/ui";
import { IconImage, IconUser } from "../../components/icons";
export default function AvatarGenerator() {
return (
<>
<PageHead title="Avatar Generator" icon={<IconUser />} />
<div className="panel" style={{ maxWidth: 880 }}>
<div className="panel-head">
<h3 className="grow">Standalone Emby utility</h3>
<span className="badge badge-ok">Python script</span>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<p className="hint" style={{ margin: 0 }}>
This tool lives in <code>emby-avatar-generator.py</code> and generates rounded user avatar tiles for Emby users.
</p>
<div className="mini-grid">
<div className="mini-stat">
<div className="stat-icon">
<IconUser />
</div>
<div className="stat-meta">
<div className="stat-value" style={{ fontSize: 16 }}>User avatars</div>
<div className="stat-label">Creates initials-based PNG profile images</div>
</div>
</div>
<div className="mini-stat">
<div className="stat-icon">
<IconImage />
</div>
<div className="stat-meta">
<div className="stat-value" style={{ fontSize: 16 }}>Modern gradients</div>
<div className="stat-label">Mesh backgrounds, shapes, and rounded corners</div>
</div>
</div>
</div>
<div className="field">
<label className="field-label">Script path</label>
<div className="input mono">emby-avatar-generator.py</div>
</div>
<p className="hint" style={{ margin: 0 }}>
This page adds the avatar generator to the Emby tool list. The script itself is still run directly from the workspace.
</p>
</div>
</div>
</>
);
}
+1 -4
View File
@@ -97,10 +97,7 @@ export default function BulkAssign() {
return (
<>
<PageHead title="Bulk Assign">
Generate and push landscape thumbnails across many titles at once. Eligible titles need an Emby primary, logo
and backdrop.
</PageHead>
<PageHead title="Bulk Assign" icon={<IconGrid />} />
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
<div className="row gap-sm wrap">
+1 -1
View File
@@ -101,7 +101,7 @@ export default function Collections() {
return (
<>
<PageHead title="Collection Art">Generate cover artwork for your Emby collections with custom titling.</PageHead>
<PageHead title="Collection Art" icon={<IconLayers />} />
<div className="workbench">
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
+1 -4
View File
@@ -113,10 +113,7 @@ export default function Favorites() {
return (
<>
<PageHead title="User Favorites">
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.
</PageHead>
<PageHead title="User Favorites" icon={<IconHeart />} />
<div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-body row wrap" style={{ gap: 14 }}>
+1 -3
View File
@@ -148,9 +148,7 @@ export default function Generator() {
return (
<>
<PageHead title="Thumbnail Generator">
Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby.
</PageHead>
<PageHead title="Thumbnail Generator" icon={<IconImage />} />
<div className="workbench">
{/* search column */}
@@ -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<string, any>[];
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<string, { name: string; type: string }>;
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<string, any>[], targetGuid: string, mode: "append" | "replace", existing: Record<string, any>[]) {
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<HomescreenSettings | null>(null);
const [enums, setEnums] = useState<HomescreenEnums | null>(null);
const [users, setUsers] = useState<HomescreenUser[]>([]);
const [originalUsers, setOriginalUsers] = useState<HomescreenUser[]>([]);
const [validation, setValidation] = useState<DbReadPayload["validation"] | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>("");
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<UserContextPayload | null>(null);
const [contextBusy, setContextBusy] = useState(false);
const [syncSourceId, setSyncSourceId] = useState("");
const [syncTargetIds, setSyncTargetIds] = useState<string[]>([]);
const [syncMode, setSyncMode] = useState<"append" | "replace">("append");
const [uploadedDb, setUploadedDb] = useState<UploadedDb | null>(null);
const [uploadingDb, setUploadingDb] = useState(false);
useEffect(() => {
async function load() {
setLoading(true);
try {
const [allSettings, enumPayload, dbSource] = await Promise.all([
apiGet<any>("/api/settings"),
apiGet<HomescreenEnums>("/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<UserContextPayload>(
`/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<K extends keyof HomescreenSettings>(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<DbReadPayload>("/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<EmbyUsersPayload>("/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<string, any>) => Record<string, any>) {
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 <Loading label="Loading homescreen editor…" />;
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Homescreen Editor" icon={<IconEmby />} />
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={refreshEmbyNames} disabled={busy}>
<IconRefresh /> Refresh Emby names
</button>
<button className="btn btn-sm" onClick={loadFromDb} disabled={busy || uploadingDb}>
<IconLayers /> Load from DB
</button>
<button className="btn btn-sm" onClick={previewSql} disabled={!users.length}>
<IconSearch /> Preview SQL
</button>
<button className="btn btn-primary" onClick={writeToDb} disabled={busy || uploadingDb || !changes.length}>
{busy ? <span className="spinner" /> : <IconCheck />} Write to DB
</button>
</div>
</div>
<div className="workbench" style={{ gridTemplateColumns: "320px 1fr" }}>
<div className="col">
<div className="panel">
<div className="panel-head">
<h3 className="grow">Editor Settings</h3>
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="field">
<label className="field-label">Upload users.db extract</label>
<input
className="input"
type="file"
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onChange={(e) => handleDbUpload(e.target.files?.[0] || null)}
/>
</div>
{uploadedDb ? (
<div className="row wrap" style={{ gap: 8 }}>
<span className="badge badge-ok">Uploaded source</span>
<span className="badge">{uploadedDb.filename}</span>
<span className="badge">{Math.round(uploadedDb.size_bytes / 1024)} KB</span>
{uploadedDb.uploaded_at ? <span className="badge">Uploaded {uploadedDb.uploaded_at}</span> : null}
</div>
) : null}
<div className="field">
<label className="field-label">Fallback users.db path</label>
<input
className="input"
value={settings.homescreen_db_path}
onChange={(e) => patchSettings("homescreen_db_path", e.target.value)}
placeholder="Optional if the app host can read the file directly"
/>
</div>
<div className="field">
<label className="field-label">TMDB API key</label>
<input className="input" type="password" value={settings.tmdb_api_key} onChange={(e) => patchSettings("tmdb_api_key", e.target.value)} />
</div>
<button className="btn btn-sm" onClick={saveEditorSettings} disabled={savingSettings}>
{savingSettings ? <span className="spinner" /> : <IconCheck />} Save editor settings
</button>
<p className="hint" style={{ margin: 0 }}>
Uploading a <code>users.db</code> 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.
</p>
<p className="hint" style={{ margin: 0 }}>
Stop Emby before writing to <code>users.db</code>. Typical Windows path: <code>C:\ProgramData\Emby-Server\data\users.db</code>
</p>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Users</h3>
<span className="sub">{filteredUsers.length}</span>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
<div className="field">
<label className="field-label">Search</label>
<input className="input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search users or Emby GUID" />
</div>
{!filteredUsers.length ? (
<Empty icon={<IconUser />}>Load the Emby users database to begin.</Empty>
) : (
<div style={{ maxHeight: 520, overflow: "auto" }}>
{filteredUsers.map((user) => {
const selected = String(user.id) === selectedUserId;
return (
<button
key={String(user.id)}
className={`nav-item ${selected ? "active" : ""}`}
style={{ width: "100%", justifyContent: "space-between", marginBottom: 6 }}
onClick={() => {
setSelectedUserId(String(user.id));
setSelectedSectionIndex(0);
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 10 }}>
<IconUser />
<span style={{ textAlign: "left" }}>
<div>{user.name}</div>
<div className="hint">{user.sections?.length || 0} sections</div>
</span>
</span>
<span className={`badge ${user.match?.ok ? "badge-ok" : "badge-warn"}`}>{user.match?.ok ? "ok" : "fixes"}</span>
</button>
);
})}
</div>
)}
</div>
</div>
</div>
<div className="col">
{!selectedUser ? (
<Empty icon={<IconEmby />}>Load a homescreen database and select a user to edit.</Empty>
) : (
<>
<div className="panel">
<div className="panel-head">
<h3 className="grow">{selectedUser.name}</h3>
{selectedUser.details?.lastActivityDate ? <span className="badge">Active {timeAgo(selectedUser.details.lastActivityDate)}</span> : null}
{selectedUser.embyGuid ? <span className="badge badge-ok">Linked to Emby</span> : <span className="badge">Unlinked</span>}
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<span className="badge">DB name: {selectedUser.dbName || selectedUser.name}</span>
{selectedUser.embyGuid ? <span className="badge">Emby GUID: {selectedUser.embyGuid}</span> : null}
{selectedUser.details?.lastLoginDate ? <span className="badge">Last login: {formatNZ(selectedUser.details.lastLoginDate)}</span> : null}
{selectedUser.match?.mismatchedSectionUserIds?.length ? <span className="badge badge-warn">Mismatched IDs: {selectedUser.match.mismatchedSectionUserIds.length}</span> : null}
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<button className="btn btn-sm" onClick={() => addSection("empty")}><IconLayers /> Add section</button>
<button className="btn btn-sm" onClick={() => addSection("collection")}><IconLayers /> Add collection row</button>
<button className="btn btn-sm" onClick={() => addSection("recent")}><IconRefresh /> Add recently watched</button>
</div>
</div>
</div>
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
<div className="col">
<div className="panel">
<div className="panel-head">
<h3 className="grow">Sections</h3>
<span className="sub">{selectedUser.sections.length}</span>
</div>
<div className="panel-body col" style={{ gap: 8 }}>
{!selectedUser.sections.length ? (
<Empty icon={<IconLayers />}>No homescreen sections for this user yet.</Empty>
) : (
selectedUser.sections.map((section, index) => (
<button
key={String(section.Id || index)}
className={`nav-item ${index === selectedSectionIndex ? "active" : ""}`}
style={{ width: "100%", justifyContent: "space-between" }}
onClick={() => setSelectedSectionIndex(index)}
>
<span style={{ textAlign: "left" }}>
<div>{section.CustomName || section.Name || "Unnamed section"}</div>
<div className="hint">{section.SectionType || "items"}{section.SortBy ? ` · ${section.SortBy}` : ""}</div>
</span>
<span className="badge">{index + 1}</span>
</button>
))
)}
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Sync Sections</h3>
</div>
<div className="panel-body col" style={{ gap: 12 }}>
<div className="field">
<label className="field-label">Source user</label>
<select className="input" value={syncSourceId} onChange={(e) => setSyncSourceId(e.target.value)}>
<option value="">Choose source</option>
{users.filter((user) => user.sections?.length).map((user) => (
<option key={String(user.id)} value={String(user.id)}>{user.name}</option>
))}
</select>
</div>
<div className="field">
<label className="field-label">Mode</label>
<select className="input" value={syncMode} onChange={(e) => setSyncMode(e.target.value as "append" | "replace")}>
<option value="append">Append</option>
<option value="replace">Replace</option>
</select>
</div>
<div className="field">
<label className="field-label">Targets</label>
<div style={{ maxHeight: 180, overflow: "auto", display: "grid", gap: 8 }}>
{users.filter((user) => String(user.id) !== syncSourceId).map((user) => (
<label key={String(user.id)} className="chip" style={{ justifyContent: "flex-start", cursor: "pointer" }}>
<input
type="checkbox"
checked={syncTargetIds.includes(String(user.id))}
onChange={(e) =>
setSyncTargetIds((current) =>
e.target.checked ? [...current, String(user.id)] : current.filter((id) => id !== String(user.id))
)
}
/>{" "}
{user.name}
</label>
))}
</div>
</div>
<button className="btn btn-sm" onClick={performSync} disabled={!syncSourceId || !syncTargetIds.length}>
<IconCheck /> Sync sections
</button>
</div>
</div>
</div>
<div className="col">
{!selectedSection ? (
<Empty icon={<IconLayers />}>Select a section to edit.</Empty>
) : (
<>
<div className="panel">
<div className="panel-head">
<h3 className="grow">{selectedSection.CustomName || selectedSection.Name || "Section"}</h3>
<button className="btn btn-sm" onClick={() => moveSection(-1)} disabled={selectedSectionIndex === 0}>Up</button>
<button className="btn btn-sm" onClick={() => moveSection(1)} disabled={selectedSectionIndex >= selectedUser.sections.length - 1}>Down</button>
<button className="btn btn-sm btn-danger" onClick={removeSection}><IconTrash /> Remove</button>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Name</label>
<input className="input" value={selectedSection.Name || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, Name: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Custom name</label>
<input className="input" value={selectedSection.CustomName || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, CustomName: e.target.value }))} />
</div>
<div className="field">
<label className="field-label">Section type</label>
<select className="input" value={selectedSection.SectionType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, SectionType: e.target.value }))}>
{enums.section_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Image type</label>
<select className="input" value={selectedSection.ImageType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, ImageType: e.target.value }))}>
{enums.image_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Collection type</label>
<select className="input" value={selectedSection.CollectionType || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, CollectionType: e.target.value }))}>
{enums.collection_types.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
<div className="field">
<label className="field-label">Sort by</label>
<select className="input" value={selectedSection.SortBy || ""} onChange={(e) => updateSelectedSection((section) => ({ ...section, SortBy: e.target.value }))}>
{enums.sort_options.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}
</select>
</div>
</div>
<div className="field">
<label className="field-label">Item types</label>
<div className="row wrap" style={{ gap: 8 }}>
{enums.item_types.map((itemType) => (
<label key={itemType} className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={(selectedSection.ItemTypes || []).includes(itemType)} onChange={() => toggleItemType(itemType)} /> {itemType}
</label>
))}
</div>
</div>
<div className="row gap-sm" style={{ flexWrap: "wrap" }}>
<label className="chip" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={!!selectedSection.IncludeNextUpInResume}
onChange={(e) => updateSelectedSection((section) => ({ ...section, IncludeNextUpInResume: e.target.checked }))}
/>{" "}
Include Next Up in resume
</label>
<label className="chip" style={{ cursor: "pointer" }}>
<input
type="checkbox"
checked={!!selectedSection.Query?.IsPlayed}
onChange={(e) => updateSelectedSection((section) => ({ ...section, Query: { ...(section.Query || {}), IsPlayed: e.target.checked } }))}
/>{" "}
Played items only
</label>
</div>
{selectedSection.SectionType === "boxset" ? (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 14 }}>
<div className="field">
<label className="field-label">Parent item name</label>
<input
className="input"
value={selectedSection.ParentItem?.Name || ""}
onChange={(e) =>
updateSelectedSection((section) => ({
...section,
ParentItem: { ...(section.ParentItem || {}), Name: e.target.value },
}))
}
/>
</div>
<div className="field">
<label className="field-label">Parent item / collection ID</label>
<input
className="input"
value={selectedSection.ParentId || selectedSection.ParentItem?.Id || ""}
onChange={(e) =>
updateSelectedSection((section) => ({
...section,
ParentId: e.target.value,
ParentItem: { ...(section.ParentItem || {}), Id: e.target.value },
}))
}
/>
</div>
</div>
) : null}
<div className="field">
<label className="field-label">Section JSON</label>
<textarea
className="input"
value={sectionJson}
onChange={(e) => setSectionJson(e.target.value)}
style={{ minHeight: 300, fontFamily: "ui-monospace, SFMono-Regular, monospace", resize: "vertical" }}
/>
<div className="row gap-sm" style={{ marginTop: 10 }}>
<button className="btn btn-sm" onClick={applySectionJson}><IconCheck /> Apply JSON</button>
</div>
</div>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Emby Context</h3>
{contextBusy ? <span className="spinner" /> : userContext?.source ? <span className="badge">{userContext.source}</span> : null}
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{userContext?.message ? <p className="hint" style={{ margin: 0 }}>{userContext.message}</p> : null}
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Libraries / views</div>
<div className="row wrap" style={{ gap: 8 }}>
{userContext?.views?.length ? userContext.views.map((view) => <span key={view.id} className="badge">{view.name}</span>) : <span className="hint">No views loaded.</span>}
</div>
</div>
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Excluded folders in this section</div>
<div className="row wrap" style={{ gap: 8 }}>
{Object.entries(userContext?.excludedFolderLookup || {}).length ? Object.entries(userContext?.excludedFolderLookup || {}).map(([id, item]) => (
<span key={id} className="badge">{item.name}</span>
)) : <span className="hint">No excluded folder metadata for this section.</span>}
</div>
</div>
<div>
<div className="section-label" style={{ margin: "0 0 8px" }}>Recently played</div>
{userContext?.recentlyPlayed?.length ? (
<div style={{ maxHeight: 220, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th>Played</th>
</tr>
</thead>
<tbody>
{userContext.recentlyPlayed.slice(0, 20).map((item) => (
<tr key={item.id}>
<td>
<div className="cell-strong">{item.name}</div>
{item.seriesName ? <div className="cell-sub">{item.seriesName}</div> : null}
</td>
<td>{item.type}</td>
<td>{item.datePlayed ? formatNZ(item.datePlayed) : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<span className="hint">No recently played items loaded.</span>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
</>
)}
{validation ? (
<div className="panel">
<div className="panel-head">
<h3 className="grow">Database Validation</h3>
</div>
<div className="panel-body">
<div className="mini-grid">
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.userCount}</div><div className="stat-label">Users</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.settingsCount}</div><div className="stat-label">Settings rows</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{validation.mismatchedUsers}</div><div className="stat-label">Mismatched users</div></div></div>
<div className="mini-stat"><div className="stat-meta"><div className="stat-value">{changes.length}</div><div className="stat-label">Pending changes</div></div></div>
</div>
</div>
</div>
) : null}
{sqlPreview ? (
<div className="panel">
<div className="panel-head">
<h3 className="grow">SQL Preview</h3>
</div>
<div className="panel-body">
<div className="console" style={{ maxHeight: 360 }}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{sqlPreview}</pre>
</div>
</div>
</div>
) : null}
</div>
</div>
</>
);
}
@@ -145,10 +145,7 @@ export default function CollectionCompleteness() {
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Music Collection Completeness" icon={<IconDisc />}>
Compares the albums you own (from a database-backed library scan) against MusicBrainz to surface albums you may
be missing. Scanning and metadata lookups run as background jobs this page only reads the database.
</PageHead>
<PageHead title="Music Collection Completeness" icon={<IconDisc />} />
<div className="row gap-sm">
<button className="btn" onClick={startScan} disabled={o?.scan_running}>
{o?.scan_running ? <span className="spinner" /> : <IconRefresh />} Scan library
+165 -91
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "../../api";
import { PageHead, StatCard, Empty, Loading } from "../../components/ui";
import { IconDisc, IconFolder, IconImage, IconRefresh, IconTrash, IconWand, IconPlay } from "../../components/icons";
import { useEffect, useRef, useState } from "react";
import { apiGet, streamNDJSON } from "../../api";
import { PageHead, Empty } from "../../components/ui";
import { IconDisc, IconFolder, IconImage, IconRefresh, IconTrash, IconWand, IconPlay, IconCheck, IconCalendar } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Album {
@@ -12,19 +12,9 @@ interface Album {
year: string | null;
track_count: number;
has_cover: boolean;
suggested_folder: string | null;
needs_folder_rename: boolean;
extra_file_count: number;
}
interface Scan {
root: string;
exists: boolean;
album_count?: number;
missing_cover_count?: number;
needs_rename_count?: number;
extra_file_count?: number;
albums: Album[];
}
interface Action {
level: string;
action: string;
@@ -32,78 +22,107 @@ interface Action {
}
const MODES = [
{ key: "covers", label: "Fetch covers", desc: "Download missing cover.jpg from Cover Art Archive", icon: <IconImage /> },
{ key: "folder_cleanup", label: "Folder cleanup", desc: "Normalize folders to 'YEAR - Album'", icon: <IconFolder /> },
{ key: "folder_cleanup", label: "Folder rename", desc: "Normalize album folders to 'YEAR - Album'", icon: <IconFolder /> },
{ key: "rename", label: "Rename tracks", desc: "Rename audio files to 'NN - Title'", icon: <IconDisc /> },
{ key: "covers", label: "Fetch covers", desc: "Download missing cover.jpg (Cover Art Archive)", icon: <IconImage /> },
{ key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars (LRCLIB)", icon: <IconWand /> },
{ key: "file_cleanup", label: "File cleanup", desc: "Remove non-audio / non-art files", icon: <IconTrash /> },
{ key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars from LRCLIB", icon: <IconWand /> },
] as const;
type ModeKey = (typeof MODES)[number]["key"];
export default function CoverManager() {
const toast = useToast();
const [scan, setScan] = useState<Scan | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [root, setRoot] = useState<{ path: string; available: boolean } | null>(null);
const [modes, setModes] = useState<Record<ModeKey, boolean>>({
folder_cleanup: true,
rename: true,
covers: true,
folder_cleanup: false,
rename: false,
file_cleanup: false,
lyrics: false,
file_cleanup: false,
});
const [actions, setActions] = useState<Action[] | null>(null);
const [recentOnly, setRecentOnly] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [running, setRunning] = useState(false);
const [actions, setActions] = useState<Action[]>([]);
function refresh() {
setLoading(true);
apiGet<Scan>("/api/music/scan")
.then(setScan)
.catch((e) => toast(e.message, "err"))
.finally(() => setLoading(false));
}
useEffect(refresh, []); // eslint-disable-line react-hooks/exhaustive-deps
const [albums, setAlbums] = useState<Album[]>([]);
const [scanning, setScanning] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
async function run() {
const runAbort = useRef<AbortController | null>(null);
const scanAbort = useRef<AbortController | null>(null);
const logRef = useRef<HTMLDivElement>(null);
useEffect(() => {
apiGet("/api/config")
.then((c) => setRoot({ path: c.music.root, available: c.music.available }))
.catch(() => setRoot(null));
return () => {
runAbort.current?.abort();
scanAbort.current?.abort();
};
}, []);
useEffect(() => {
logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
}, [actions]);
const anyMode = Object.values(modes).some(Boolean);
async function run(albumPaths?: string[]) {
if (!anyMode) return;
if (!dryRun) {
const ok = window.confirm(
"Apply mode is ON. This will permanently rename folders, rename files, delete extras and download files on your music share. Continue?"
);
if (!ok) return;
const scope = albumPaths ? "the selected album(s)" : recentOnly ? "recently-changed albums" : "your whole music library";
if (!window.confirm(`Apply mode is ON. This will permanently modify files in ${scope}. Continue?`)) return;
}
setRunning(true);
setActions(null);
setActions([]);
const ctrl = new AbortController();
runAbort.current = ctrl;
try {
const res = await apiPost<{ actions: Action[]; dry_run: boolean }>("/api/music/process", {
...modes,
dry_run: dryRun,
await streamNDJSON("/api/music/process/stream", {
method: "POST",
body: { ...modes, dry_run: dryRun, recent_only: albumPaths ? false : recentOnly, album_paths: albumPaths || null },
signal: ctrl.signal,
onMessage: (msg: Action) => setActions((prev) => [...prev, msg]),
});
setActions(res.actions);
toast(dryRun ? "Dry run complete" : "Changes applied", dryRun ? "info" : "ok");
if (!dryRun) refresh();
} catch (e: any) {
toast(e.message, "err");
if (e?.name !== "AbortError") toast(e.message || "Run failed", "err");
} finally {
setRunning(false);
runAbort.current = null;
}
}
if (loading) return <Loading label="Scanning music library…" />;
async function previewScan() {
setScanning(true);
setAlbums([]);
setSelected(new Set());
const ctrl = new AbortController();
scanAbort.current = ctrl;
try {
await streamNDJSON("/api/music/scan/stream", {
signal: ctrl.signal,
onMessage: (msg) => {
if (msg.type === "album") setAlbums((prev) => [...prev, msg.album]);
else if (msg.type === "error") toast(msg.message, "err");
},
});
} catch (e: any) {
if (e?.name !== "AbortError") toast(e.message || "Scan failed", "err");
} finally {
setScanning(false);
scanAbort.current = null;
}
}
if (!scan?.exists) {
return (
<>
<PageHead title="Cover Manager">Maintain your local music library.</PageHead>
<div className="panel">
<Empty icon={<IconFolder />}>
Music root not found: <code>{scan?.root}</code>
<br />
Set <code>MUSIC_ROOT</code> and mount the share into the container.
</Empty>
</div>
</>
);
function toggleSelect(path: string) {
setSelected((s) => {
const n = new Set(s);
n.has(path) ? n.delete(path) : n.add(path);
return n;
});
}
const logClass = (a: Action) =>
@@ -111,23 +130,22 @@ export default function CoverManager() {
return (
<>
<PageHead title="Cover Manager">
Clean album folders, rename tracks and fetch missing covers across <code>{scan.root}</code>. Runs in dry-run
mode by default nothing changes until you turn that off.
</PageHead>
<PageHead title="Library Cleanup" icon={<IconWand />} />
<div className="stat-row" style={{ marginBottom: 22 }}>
<StatCard icon={<IconDisc />} value={scan.album_count ?? 0} label="Albums" />
<StatCard icon={<IconImage />} value={scan.missing_cover_count ?? 0} label="Missing covers" />
<StatCard icon={<IconFolder />} value={scan.needs_rename_count ?? 0} label="Folders to rename" />
<StatCard icon={<IconTrash />} value={scan.extra_file_count ?? 0} label="Extra files" />
{root && !root.available && (
<div className="panel" style={{ marginBottom: 18 }}>
<Empty icon={<IconFolder />}>
Music root not mounted: <code>{root.path}</code>. Set <code>MUSIC_ROOT</code> and mount the share.
</Empty>
</div>
)}
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
<div className="workbench" style={{ gridTemplateColumns: "360px 1fr" }}>
{/* controls */}
<div className="col">
<div className="panel">
<div className="panel-head">
<h3>Maintenance modes</h3>
<h3>What to do</h3>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
{MODES.map((m) => (
@@ -149,12 +167,23 @@ export default function CoverManager() {
</span>
</label>
))}
<label className={`chip ${recentOnly ? "active" : ""}`} style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}>
<input type="checkbox" checked={recentOnly} onChange={(e) => setRecentOnly(e.target.checked)} style={{ marginRight: 4 }} />
<span style={{ flexShrink: 0 }}>
<IconCalendar />
</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>Only recent</div>
<div className="hint">Only albums changed in the last 2 hours</div>
</span>
</label>
</div>
</div>
<div className="panel">
<div className="panel-body col">
<label className={`chip ${!dryRun ? "" : "active"}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<label className={`chip ${dryRun ? "active" : ""}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<span>
<div style={{ fontWeight: 700, color: dryRun ? "var(--accent-h)" : "var(--red)" }}>
{dryRun ? "Dry run (safe)" : "Apply changes (live)"}
@@ -163,28 +192,47 @@ export default function CoverManager() {
</span>
<input type="checkbox" checked={!dryRun} onChange={(e) => setDryRun(!e.target.checked)} />
</label>
<button className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`} onClick={run} disabled={running}>
{running ? <span className="spinner" /> : <IconPlay />}
{running ? "Working…" : dryRun ? "Preview changes" : "Apply now"}
{running ? (
<button className="btn btn-danger btn-block" onClick={() => runAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
<button className="btn btn-block" onClick={refresh} disabled={running}>
<IconRefresh /> Rescan library
) : (
<button
className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`}
onClick={() => run()}
disabled={!anyMode || (root ? !root.available : false)}
>
<IconPlay /> {dryRun ? "Preview run" : "Run now"}
</button>
)}
{selected.size > 0 && !running && (
<button className="btn btn-block" onClick={() => run([...selected])} disabled={!anyMode}>
<IconCheck /> Run on {selected.size} selected
</button>
)}
<p className="hint">
{dryRun
? "Preview shows exactly what would change — nothing is modified."
: "Live mode renames, deletes and downloads immediately."}
</p>
</div>
</div>
</div>
{/* output */}
<div className="col">
{actions && (
<div className="panel">
<div className="panel-head">
<h3>{scan && actions ? "Result" : ""} Action log</h3>
<span className="sub">{actions.length} entries</span>
<h3 className="grow">Activity</h3>
<span className="sub">
{actions.length} entries{running ? " · running…" : ""}
</span>
</div>
<div className="panel-body">
<div className="console">
<div className="console" ref={logRef} style={{ minHeight: 200 }}>
{actions.length === 0 ? (
<span className="dim">Nothing to do.</span>
<span className="dim">Pick what to do on the left, then Run. Output streams here as it happens.</span>
) : (
actions.map((a, i) => (
<div className="log-line" key={i}>
@@ -196,26 +244,50 @@ export default function CoverManager() {
</div>
</div>
</div>
)}
<div className="panel">
<div className="panel-head">
<h3>Albums</h3>
<span className="sub">{scan.albums.length}</span>
<h3 className="grow">Library preview</h3>
<span className="sub">{albums.length} albums</span>
{scanning ? (
<button className="btn btn-sm btn-danger" onClick={() => scanAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
) : (
<button className="btn btn-sm" onClick={previewScan} disabled={root ? !root.available : false}>
<IconRefresh /> Scan
</button>
)}
{albums.length > 0 && (
<button
className="btn btn-sm"
onClick={() => setSelected(selected.size === albums.length ? new Set() : new Set(albums.map((a) => a.path)))}
>
{selected.size === albums.length ? "Clear" : "Select all"}
</button>
)}
</div>
<div style={{ maxHeight: 520, overflow: "auto" }}>
{albums.length === 0 ? (
<Empty icon={<IconDisc />}>{scanning ? "Scanning…" : "Optional: scan to preview albums and run actions on specific ones."}</Empty>
) : (
<div style={{ maxHeight: 460, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th>Album</th>
<th>Year</th>
<th>Tracks</th>
<th>Status</th>
<th style={{ textAlign: "right" }}>Action</th>
</tr>
</thead>
<tbody>
{scan.albums.map((a) => (
{albums.map((a) => (
<tr key={a.path}>
<td>
<input type="checkbox" checked={selected.has(a.path)} onChange={() => toggleSelect(a.path)} />
</td>
<td>
<div className="cell-strong">{a.album || a.folder_name}</div>
<div className="cell-sub">{a.artist}</div>
@@ -224,20 +296,22 @@ export default function CoverManager() {
<td className="mono">{a.track_count}</td>
<td>
<div className="row wrap" style={{ gap: 6 }}>
{a.has_cover ? (
<span className="badge badge-ok">cover</span>
) : (
<span className="badge badge-warn">no cover</span>
)}
{a.has_cover ? <span className="badge badge-ok">cover</span> : <span className="badge badge-warn">no cover</span>}
{a.needs_folder_rename && <span className="badge badge-accent">rename</span>}
{a.extra_file_count > 0 && <span className="badge">{a.extra_file_count} extra</span>}
</div>
</td>
<td style={{ textAlign: "right" }}>
<button className="btn btn-sm" disabled={running || !anyMode} onClick={() => run([a.path])} title="Run selected modes on this album">
<IconWand /> Fix
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
+3 -3
View File
@@ -74,7 +74,7 @@ export default function Library() {
if (status && !status.configured) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
@@ -87,7 +87,7 @@ export default function Library() {
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
@@ -97,7 +97,7 @@ export default function Library() {
return (
<>
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
<PageHead title="Music Library" icon={<IconDisc />} />
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="seg">
+484
View File
@@ -0,0 +1,484 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { apiGet, apiPost, streamNDJSON } from "../../api";
import { PageHead, Empty } from "../../components/ui";
import { IconMusic, IconDisc, IconTrash, IconPlay, IconCalendar, IconCheck, IconChevron, IconWand } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Override {
artist: string;
genre: string;
}
interface Action {
level: string;
action: string;
message: string;
file?: string;
group?: string;
subgroup?: string;
junk?: string[];
track?: string | null;
genre?: string | null;
path?: string;
}
const MODES = [
{
key: "genres",
label: "Fix genres",
desc: "Look each album up on MusicBrainz and write one canonical genre to every track",
icon: <IconMusic />,
},
{
key: "strip_junk",
label: "Strip junk tags",
desc: "Remove comment, encoder/tool, URL/purchase and embedded-lyrics frames",
icon: <IconTrash />,
},
{
key: "normalize_tracks",
label: "Normalize track numbers",
desc: "Drop the '/total' suffix and zero-pad track/disc tags (1/12 → 01)",
icon: <IconDisc />,
},
] as const;
type ModeKey = (typeof MODES)[number]["key"];
export default function Metadata() {
const toast = useToast();
const [root, setRoot] = useState<{ path: string; available: boolean } | null>(null);
const [modes, setModes] = useState<Record<ModeKey, boolean>>({
genres: true,
strip_junk: true,
normalize_tracks: true,
});
const [recentOnly, setRecentOnly] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [running, setRunning] = useState(false);
const [actions, setActions] = useState<Action[]>([]);
const [showLog, setShowLog] = useState(false);
const [overrides, setOverrides] = useState<Override[]>([]);
const [newArtist, setNewArtist] = useState("");
const [newGenre, setNewGenre] = useState("");
const runAbort = useRef<AbortController | null>(null);
const logRef = useRef<HTMLDivElement>(null);
function loadOverrides() {
apiGet<{ overrides: Override[] }>("/api/music/metadata/overrides")
.then((r) => setOverrides(r.overrides || []))
.catch(() => {});
}
useEffect(() => {
apiGet("/api/config")
.then((c) => setRoot({ path: c.music.root, available: c.music.available }))
.catch(() => setRoot(null));
loadOverrides();
return () => runAbort.current?.abort();
}, []);
async function saveOverride(artist: string, genre: string) {
if (!artist.trim() || !genre.trim()) return;
try {
await apiPost("/api/music/metadata/overrides", { artist: artist.trim(), genre: genre.trim() });
loadOverrides();
toast(`Pinned ${artist.trim()}${genre.trim()}`, "ok");
} catch (e: any) {
toast(e.message || "Could not save override", "err");
}
}
async function removeOverride(artist: string) {
try {
await apiPost("/api/music/metadata/overrides/delete", { artist });
loadOverrides();
} catch (e: any) {
toast(e.message || "Could not remove override", "err");
}
}
async function addOverride() {
await saveOverride(newArtist, newGenre);
setNewArtist("");
setNewGenre("");
}
useEffect(() => {
if (showLog) logRef.current?.scrollTo({ top: logRef.current.scrollHeight });
}, [actions, showLog]);
const anyMode = Object.values(modes).some(Boolean);
// ── derive table rows, grouped by album, plus live stats ──────────────────
const rows = useMemo(() => actions.filter((a) => a.action === "tag" && a.file), [actions]);
const statusLog = useMemo(() => actions.filter((a) => a.action !== "tag"), [actions]);
const stats = useMemo(() => {
let junk = 0;
let tracks = 0;
let genres = 0;
for (const r of rows) {
junk += r.junk?.length || 0;
if (r.track) tracks += 1;
if (r.genre) genres += 1;
}
return { files: rows.length, junk, tracks, genres };
}, [rows]);
const groups = useMemo(() => {
const out: { key: string; group: string; subgroup: string; rows: Action[] }[] = [];
const index = new Map<string, number>();
for (const r of rows) {
const key = `${r.subgroup || ""}//${r.group || ""}`;
let i = index.get(key);
if (i === undefined) {
i = out.length;
index.set(key, i);
out.push({ key, group: r.group || "Unknown album", subgroup: r.subgroup || "", rows: [] });
}
out[i].rows.push(r);
}
return out;
}, [rows]);
const overriddenKeys = useMemo(
() => new Set(overrides.map((o) => o.artist.trim().toLowerCase())),
[overrides]
);
const lastStatus = statusLog.length ? statusLog[statusLog.length - 1].message : "";
async function run() {
if (!anyMode) return;
if (!dryRun) {
const scope = recentOnly ? "recently-changed albums" : "your whole music library";
if (!window.confirm(`Apply mode is ON. This will permanently rewrite tags in ${scope}. Continue?`)) return;
}
setRunning(true);
setActions([]);
const ctrl = new AbortController();
runAbort.current = ctrl;
try {
await streamNDJSON("/api/music/metadata/process/stream", {
method: "POST",
body: { ...modes, dry_run: dryRun, recent_only: recentOnly },
signal: ctrl.signal,
onMessage: (msg: Action) => setActions((prev) => [...prev, msg]),
});
toast(dryRun ? "Dry run complete" : "Tags updated", dryRun ? "info" : "ok");
} catch (e: any) {
if (e?.name !== "AbortError") toast(e.message || "Run failed", "err");
} finally {
setRunning(false);
runAbort.current = null;
}
}
const logClass = (a: Action) =>
({ ok: "log-ok", dry: "log-dry", skip: "log-skip", warn: "log-warn", info: "log-info" }[a.level] || "log-info");
return (
<>
<PageHead title="Metadata Editor" icon={<IconMusic />} />
{root && !root.available && (
<div className="panel" style={{ marginBottom: 18 }}>
<Empty icon={<IconMusic />}>
Music root not mounted: <code>{root.path}</code>. Set <code>MUSIC_ROOT</code> and mount the share.
</Empty>
</div>
)}
<div className="workbench" style={{ gridTemplateColumns: "330px 1fr", alignItems: "start" }}>
{/* ── controls ── */}
<div className="col">
<div className="panel">
<div className="panel-head">
<h3>What to do</h3>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
{MODES.map((m) => (
<label
key={m.key}
className={`chip ${modes[m.key] ? "active" : ""}`}
style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}
>
<input
type="checkbox"
checked={modes[m.key]}
onChange={(e) => setModes((s) => ({ ...s, [m.key]: e.target.checked }))}
style={{ marginRight: 4 }}
/>
<span style={{ flexShrink: 0 }}>{m.icon}</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>{m.label}</div>
<div className="hint">{m.desc}</div>
</span>
</label>
))}
<label className={`chip ${recentOnly ? "active" : ""}`} style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}>
<input type="checkbox" checked={recentOnly} onChange={(e) => setRecentOnly(e.target.checked)} style={{ marginRight: 4 }} />
<span style={{ flexShrink: 0 }}>
<IconCalendar />
</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>Only recent</div>
<div className="hint">Only albums changed in the last 2 hours</div>
</span>
</label>
</div>
</div>
<div className="panel">
<div className="panel-body col">
<label className={`chip ${dryRun ? "active" : ""}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<span>
<div style={{ fontWeight: 700, color: dryRun ? "var(--accent-h)" : "var(--red)" }}>
{dryRun ? "Dry run (safe)" : "Apply changes (live)"}
</div>
<div className="hint">{dryRun ? "Preview only — no tags written" : "Will rewrite tags in your files"}</div>
</span>
<input type="checkbox" checked={!dryRun} onChange={(e) => setDryRun(!e.target.checked)} />
</label>
{running ? (
<button className="btn btn-danger btn-block" onClick={() => runAbort.current?.abort()}>
<span className="spinner" /> Stop
</button>
) : (
<button
className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`}
onClick={() => run()}
disabled={!anyMode || (root ? !root.available : false)}
>
<IconPlay /> {dryRun ? "Preview run" : "Run now"}
</button>
)}
<p className="hint">
{dryRun
? "Preview shows exactly which tags would change — nothing is written."
: "Live mode rewrites tags immediately. Genre lookups query MusicBrainz (~1 album/sec)."}
</p>
</div>
</div>
<div className="panel">
<div className="panel-head">
<h3 className="grow">Genre overrides</h3>
<span className="sub">{overrides.length}</span>
</div>
<div className="panel-body col" style={{ gap: 8 }}>
<p className="hint">
Force a genre for an artist overrides always win over MusicBrainz, so you have full control.
</p>
{overrides.length > 0 && (
<div className="col" style={{ gap: 6 }}>
{overrides.map((o) => (
<div key={o.artist} className="row" style={{ gap: 8, alignItems: "center" }}>
<span
className="cell-strong"
style={{ flex: "1 1 auto", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
title={o.artist}
>
{o.artist}
</span>
<span className="badge badge-ok">{o.genre}</span>
<button className="btn btn-sm" title="Remove override" onClick={() => removeOverride(o.artist)}>
<IconTrash />
</button>
</div>
))}
</div>
)}
<div className="row" style={{ gap: 6 }}>
<input
className="input"
placeholder="Artist"
value={newArtist}
onChange={(e) => setNewArtist(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
<input
className="input"
placeholder="Genre"
value={newGenre}
onChange={(e) => setNewGenre(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addOverride()}
style={{ flex: 1, minWidth: 0 }}
/>
</div>
<button className="btn btn-sm btn-block" onClick={addOverride} disabled={!newArtist.trim() || !newGenre.trim()}>
<IconCheck /> Add override
</button>
</div>
</div>
</div>
{/* ── results ── */}
<div className="col" style={{ minWidth: 0 }}>
<div className="stat-row" style={{ gridTemplateColumns: "repeat(4, 1fr)" }}>
<Tile icon={<IconCheck />} value={stats.files} label="Files changed" />
<Tile icon={<IconTrash />} value={stats.junk} label="Junk tags removed" />
<Tile icon={<IconDisc />} value={stats.tracks} label="Tracks renumbered" />
<Tile icon={<IconMusic />} value={stats.genres} label="Genres set" />
</div>
<div className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<div className="panel-head">
<h3 className="grow">{dryRun ? "Planned changes" : "Applied changes"}</h3>
<span className="sub">
{running ? (
<>
<span className="spinner" /> {lastStatus || "working…"}
</>
) : (
`${groups.length} album(s) · ${rows.length} file(s)`
)}
</span>
</div>
<div style={{ overflow: "auto", maxHeight: "calc(100vh - 430px)", minHeight: 340 }}>
{rows.length === 0 ? (
<Empty icon={<IconMusic />}>
{running ? "Scanning your library…" : "Pick what to do on the left, then Run. Per-file changes appear here as a table."}
</Empty>
) : (
<table className="data-table">
<thead>
<tr>
{["Track", "Junk stripped", "Track #", "Genre"].map((h, i) => (
<th
key={h}
style={{
position: "sticky",
top: 0,
zIndex: 1,
background: "var(--surface)",
width: i === 0 ? "40%" : undefined,
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{groups.map((g) => (
<GroupBlock
key={g.key}
group={g}
overridden={overriddenKeys.has((g.subgroup || "").trim().toLowerCase())}
onPin={saveOverride}
/>
))}
</tbody>
</table>
)}
</div>
</div>
{statusLog.length > 0 && (
<div className="panel">
<button
className="panel-head"
onClick={() => setShowLog((s) => !s)}
style={{ width: "100%", background: "none", border: 0, cursor: "pointer", color: "inherit" }}
>
<h3 className="grow" style={{ textAlign: "left" }}>
Activity log
</h3>
<span className="sub">{statusLog.length} entries</span>
<IconChevron className={`nav-caret ${showLog ? "open" : ""}`} />
</button>
{showLog && (
<div className="panel-body">
<div className="console" ref={logRef} style={{ maxHeight: 240 }}>
{statusLog.map((a, i) => (
<div className="log-line" key={i}>
<span className={`log-tag ${logClass(a)}`}>{a.level === "dry" ? "plan" : a.level}</span>
<span>{a.message}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</>
);
}
function Tile({ icon, value, label }: { icon: JSX.Element; value: number; label: string }) {
return (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-meta">
<div className="stat-value">{value.toLocaleString()}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
function GroupBlock({
group,
overridden,
onPin,
}: {
group: { group: string; subgroup: string; rows: Action[] };
overridden: boolean;
onPin: (artist: string, genre: string) => void;
}) {
const groupGenre = group.rows.find((r) => r.genre)?.genre || null;
const canPin = !!(group.subgroup && groupGenre && !overridden);
return (
<>
<tr>
<td colSpan={4} style={{ background: "var(--surface3)", padding: "9px 14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span className="cell-strong">{group.group}</span>
{group.subgroup && <span className="cell-sub">{group.subgroup}</span>}
{overridden && <span className="badge badge-accent">override</span>}
<span className="badge" style={{ marginLeft: "auto" }}>
{group.rows.length} file{group.rows.length === 1 ? "" : "s"}
</span>
{canPin && (
<button
className="btn btn-sm"
title={`Pin ${group.subgroup}${groupGenre} as a permanent override`}
onClick={() => onPin(group.subgroup, groupGenre as string)}
>
<IconWand /> Pin genre
</button>
)}
</div>
</td>
</tr>
{group.rows.map((r, i) => (
<tr key={r.path || `${group.group}-${i}`}>
<td>
<div className="cell-strong" style={{ wordBreak: "break-word" }}>
{r.file}
</div>
</td>
<td>
{r.junk && r.junk.length > 0 ? (
<div className="row wrap" style={{ gap: 6, alignItems: "center" }}>
<span className="badge badge-warn">{r.junk.length}</span>
<span className="cell-sub" title={r.junk.join(", ")} style={{ wordBreak: "break-word" }}>
{r.junk.slice(0, 4).join(", ")}
{r.junk.length > 4 ? ` +${r.junk.length - 4} more` : ""}
</span>
</div>
) : (
<span className="cell-sub"></span>
)}
</td>
<td>{r.track ? <span className="badge badge-accent mono">{r.track}</span> : <span className="cell-sub"></span>}</td>
<td>{r.genre ? <span className="badge badge-ok">{r.genre}</span> : <span className="cell-sub"></span>}</td>
</tr>
))}
</>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useState } from "react";
import { apiGet } from "../../api";
import { Empty, Loading, PageHead, StatCard, fmtDuration, fmtNumber } from "../../components/ui";
import { IconCalendar, IconDisc, IconMusic, IconPlay, IconRefresh, IconUser } from "../../components/icons";
import { useToast } from "../../lib/toast";
interface Album {
id: string;
name: string;
artist: string;
year?: number;
song_count: number;
duration: number;
cover_url: string | null;
}
interface Song {
id: string;
title: string;
artist: string;
album: string;
year?: number;
duration: number;
play_count: number;
created?: string | null;
cover_url?: string | null;
}
interface Genre {
name: string;
song_count: number;
album_count: number;
}
interface ReportingData {
summary: {
artist_count: number;
album_count: number;
song_count: number;
genre_count: number;
library_tracks_scanned: number;
tracks_with_plays: number;
total_play_count: number;
favorite_song_count: number;
favorite_album_count: number;
favorite_artist_count: number;
now_playing_count: number;
scan_pages: number;
truncated: boolean;
};
top_tracks: Song[];
favorite_tracks: Song[];
recently_added_tracks: Song[];
top_albums: Album[];
recent_albums: Album[];
newest_albums: Album[];
top_genres: Genre[];
now_playing: Song[];
}
function AlbumStrip({ title, items }: { title: string; items: Album[] }) {
return (
<div className="panel">
<div className="panel-head">
<h3>{title}</h3>
<span className="sub">{items.length}</span>
</div>
{items.length === 0 ? (
<Empty icon={<IconDisc />}>No albums available.</Empty>
) : (
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", padding: 18 }}>
{items.map((a) => (
<div key={a.id} className="media-card" style={{ cursor: "default" }}>
{a.cover_url ? (
<img className="media-cover" src={`${a.cover_url}?size=300`} loading="lazy" alt={a.name} />
) : (
<div className="media-cover" style={{ display: "grid", placeItems: "center" }}>
<IconDisc className="dim" />
</div>
)}
<div className="media-body">
<div className="media-title">{a.name}</div>
<div className="media-sub">
{a.artist}
{a.year ? ` · ${a.year}` : ""}
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}
function SongTable({ title, songs, secondary = "plays" }: { title: string; songs: Song[]; secondary?: "plays" | "added" }) {
return (
<div className="panel">
<div className="panel-head">
<h3>{title}</h3>
<span className="sub">{songs.length}</span>
</div>
{songs.length === 0 ? (
<Empty icon={<IconMusic />}>No tracks available.</Empty>
) : (
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Track</th>
<th>Artist</th>
<th>Album</th>
<th>Length</th>
<th>{secondary === "plays" ? "Plays" : "Added"}</th>
</tr>
</thead>
<tbody>
{songs.map((song) => (
<tr key={song.id}>
<td className="cell-strong">{song.title}</td>
<td>{song.artist || "—"}</td>
<td className="cell-sub">{song.album || "—"}</td>
<td className="mono">{fmtDuration(song.duration)}</td>
<td className="mono">{secondary === "plays" ? fmtNumber(song.play_count) : song.created || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
export default function Reporting() {
const toast = useToast();
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
const [data, setData] = useState<ReportingData | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false }));
}, []);
async function load(refresh = false) {
refresh ? setRefreshing(true) : setLoading(true);
try {
const result = await apiGet<ReportingData>(`/api/navidrome/reporting${refresh ? "?refresh=true" : ""}`);
setData(result);
} catch (e: any) {
toast(e.message, "err");
setData(null);
} finally {
setLoading(false);
setRefreshing(false);
}
}
useEffect(() => {
if (status?.connected) load();
}, [status?.connected]); // eslint-disable-line react-hooks/exhaustive-deps
if (status && !status.configured) {
return (
<>
<PageHead title="Reporting" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Navidrome is not configured yet.</Empty>
</div>
</>
);
}
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Reporting" icon={<IconDisc />} />
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
</>
);
}
return (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Reporting" icon={<IconDisc />} />
<button className="btn btn-sm" onClick={() => load(true)} disabled={refreshing || loading}>
{refreshing ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
{loading && !data ? (
<Loading label="Loading Navidrome reporting…" />
) : !data ? (
<div className="panel">
<Empty icon={<IconMusic />}>No reporting data available.</Empty>
</div>
) : (
<>
<div className="stat-row" style={{ marginBottom: 18 }}>
<StatCard icon={<IconPlay />} value={fmtNumber(data.summary.total_play_count)} label="Total plays" />
<StatCard icon={<IconMusic />} value={fmtNumber(data.summary.tracks_with_plays)} label="Tracks with plays" />
<StatCard icon={<IconDisc />} value={fmtNumber(data.summary.favorite_song_count)} label="Starred songs" />
<StatCard icon={<IconUser />} value={fmtNumber(data.summary.now_playing_count)} label="Now playing" />
</div>
<div className="stat-row" style={{ marginBottom: 24 }}>
<StatCard icon={<IconUser />} value={fmtNumber(data.summary.artist_count)} label="Artists" />
<StatCard icon={<IconDisc />} value={fmtNumber(data.summary.album_count)} label="Albums" />
<StatCard icon={<IconMusic />} value={fmtNumber(data.summary.song_count)} label="Tracks" />
<StatCard icon={<IconCalendar />} value={fmtNumber(data.summary.library_tracks_scanned)} label="Tracks scanned" />
</div>
<div className="panel" style={{ marginBottom: 24 }}>
<div className="panel-head">
<h3>Scan notes</h3>
</div>
<div className="panel-body">
<div className="row gap-sm wrap">
<span className="badge">{fmtNumber(data.summary.scan_pages)} page(s) scanned</span>
{data.summary.truncated ? <span className="badge badge-warn">results truncated by safety cap</span> : <span className="badge badge-ok">full library sampled</span>}
<span className="badge">{fmtNumber(data.summary.favorite_album_count)} starred albums</span>
<span className="badge">{fmtNumber(data.summary.favorite_artist_count)} starred artists</span>
</div>
<p className="hint" style={{ marginTop: 12, marginBottom: 0 }}>
Play counts and starred state are specific to the authenticated Navidrome user because they come from the Subsonic-compatible API.
</p>
</div>
</div>
<div className="dash-split" style={{ marginBottom: 24 }}>
<SongTable title="Top Tracks" songs={data.top_tracks} />
<SongTable title="Starred Tracks" songs={data.favorite_tracks} />
</div>
<div className="dash-split" style={{ marginBottom: 24 }}>
<SongTable title="Recently Added Tracks" songs={data.recently_added_tracks} secondary="added" />
<div className="panel">
<div className="panel-head">
<h3>Top Genres</h3>
<span className="sub">{data.top_genres.length}</span>
</div>
<div className="panel-body">
{data.top_genres.length === 0 ? (
<Empty icon={<IconDisc />}>No genre data.</Empty>
) : (
data.top_genres.map((genre) => (
<div key={genre.name} className="genre-row">
<span className="genre-name">{genre.name}</span>
<span className="genre-bar">
<span
className="genre-bar-fill"
style={{
width: `${(genre.song_count / Math.max(...data.top_genres.map((g) => g.song_count), 1)) * 100}%`,
}}
/>
</span>
<span className="genre-count">{fmtNumber(genre.song_count)}</span>
</div>
))
)}
</div>
</div>
</div>
<AlbumStrip title="Most Played Albums" items={data.top_albums} />
<div style={{ height: 18 }} />
<AlbumStrip title="Recently Played Albums" items={data.recent_albums} />
<div style={{ height: 18 }} />
<AlbumStrip title="Newest Albums" items={data.newest_albums} />
<div style={{ height: 18 }} />
<SongTable title="Now Playing" songs={data.now_playing} secondary="added" />
</>
)}
</>
);
}
+599 -23
View File
@@ -98,6 +98,9 @@ a {
grid-template-columns: 244px 1fr;
min-height: 100vh;
}
.app.app-compact {
display: block;
}
.main {
min-width: 0;
display: flex;
@@ -113,6 +116,38 @@ a {
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border);
z-index: 40;
}
.nav-backdrop {
display: none;
}
.app.app-compact .nav {
position: fixed;
left: 0;
top: 0;
width: min(320px, 86vw);
max-width: 100%;
transform: translateX(-100%);
transition: transform 220ms var(--ease-out);
box-shadow: var(--shadow-strong);
}
.app.app-compact .nav.mobile-open {
transform: translateX(0);
}
.app.app-compact .nav-backdrop {
position: fixed;
inset: 0;
border: 0;
background: rgba(6, 10, 14, 0.72);
opacity: 0;
pointer-events: none;
transition: opacity 180ms var(--ease-out);
z-index: 35;
display: block;
}
.app.app-compact .nav-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.nav-brand {
display: flex;
@@ -332,10 +367,18 @@ a {
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.topbar-menu {
display: none;
}
.app.app-compact .topbar-menu {
display: inline-flex;
flex-shrink: 0;
}
.topbar .crumbs {
font-size: 12.5px;
color: var(--text-3);
letter-spacing: 0.02em;
min-width: 0;
}
.topbar .crumbs b {
color: var(--text-2);
@@ -349,41 +392,67 @@ a {
padding: 26px 36px 40px;
width: 100%;
}
.page-head {
margin-bottom: 22px;
}
.page-head-row {
display: flex;
align-items: center;
.app.app-compact .topbar {
padding: 0 20px;
gap: 12px;
}
.app.app-compact .content {
padding: 22px 20px 32px;
}
.app.app-compact .cmdk {
width: min(300px, 46vw);
max-width: none;
}
.page-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.page-toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.page-head {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 26px;
}
.page-head-icon {
width: 36px;
height: 36px;
width: 50px;
height: 50px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent-h);
border-radius: 15px;
flex-shrink: 0;
color: #05161b;
background: linear-gradient(140deg, var(--accent-h) 0%, var(--accent) 42%, var(--purple) 100%);
box-shadow:
0 14px 30px -12px var(--accent-glow),
0 0 0 1px rgba(255, 255, 255, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.page-head-icon svg {
width: 20px;
height: 20px;
width: 24px;
height: 24px;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.18));
}
.page-head h1 {
margin: 0;
font-size: 28px;
font-size: 30px;
font-weight: 700;
letter-spacing: -0.02em;
}
.page-head p {
margin: 8px 0 0;
font-size: 15px;
color: var(--text-2);
max-width: 72ch;
line-height: 1.6;
letter-spacing: -0.025em;
line-height: 1.1;
background: linear-gradient(180deg, var(--text) 30%, var(--text-2));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* ── Cards / panels ───────────────────────────────────────────────────────── */
@@ -413,6 +482,59 @@ a {
padding: 18px;
}
.task-stack {
display: grid;
gap: 14px;
}
.task-panel {
overflow: hidden;
}
.task-toggle {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.task-toggle:hover {
background: rgba(255, 255, 255, 0.02);
}
.task-meta {
min-width: 0;
}
.task-title-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.task-title-row h3 {
margin: 0;
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.task-summary {
color: var(--text-3);
font-size: 13px;
line-height: 1.45;
}
.task-body {
border-top: 1px solid var(--border);
}
.task-toolbar {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
justify-content: flex-end;
flex-wrap: wrap;
}
/* ── Section labels (dashboard groupings) ─────────────────────────────────── */
.section-label {
display: flex;
@@ -543,6 +665,205 @@ a {
font-variant-numeric: tabular-nums;
}
/* user activity cards */
.activity-shell {
display: grid;
gap: 18px;
}
.activity-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px;
}
.activity-summary-card {
display: flex;
flex-direction: column;
gap: 6px;
min-height: 108px;
padding: 16px;
border-radius: 14px;
border: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)),
var(--surface2);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
.activity-summary-card strong {
font-size: 28px;
line-height: 1;
letter-spacing: -0.03em;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.activity-summary-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.activity-summary-sub {
font-size: 12px;
color: var(--text-2);
}
.activity-summary-platform {
position: relative;
padding-left: 54px;
}
.activity-summary-icon {
position: absolute;
left: 16px;
top: 16px;
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--surface3);
color: var(--text-2);
}
.activity-summary-icon.apple {
background: rgba(54, 214, 224, 0.12);
color: var(--accent-h);
}
.activity-summary-icon.android {
background: rgba(70, 217, 154, 0.12);
color: #8ef0be;
}
.activity-summary-icon.web {
background: rgba(243, 201, 105, 0.12);
color: #f6d98c;
}
.activity-summary-icon svg {
width: 15px;
height: 15px;
}
.activity-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.activity-card {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
border-radius: 16px;
border: 1px solid var(--border);
background:
radial-gradient(circle at top right, rgba(54, 214, 224, 0.09), transparent 34%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)),
var(--surface2);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
transition: transform 180ms var(--ease-out), border-color 180ms var(--ease-out), box-shadow 180ms var(--ease-out);
}
.activity-card:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.22);
}
.activity-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.activity-user-meta {
min-width: 0;
}
.activity-user-name {
font-size: 16px;
font-weight: 600;
color: var(--text);
letter-spacing: -0.015em;
}
.activity-user-when {
margin-top: 3px;
font-size: 12px;
color: var(--text-3);
}
.activity-device-chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-2);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.activity-device-chip.apple {
border-color: rgba(54, 214, 224, 0.24);
background: rgba(54, 214, 224, 0.12);
color: var(--accent-h);
}
.activity-device-chip.android {
border-color: rgba(70, 217, 154, 0.24);
background: rgba(70, 217, 154, 0.12);
color: #8ef0be;
}
.activity-device-chip.web {
border-color: rgba(243, 201, 105, 0.24);
background: rgba(243, 201, 105, 0.12);
color: #f6d98c;
}
.activity-device-chip svg {
width: 14px;
height: 14px;
}
.activity-device-title {
color: var(--text);
font-size: 14px;
font-weight: 600;
line-height: 1.45;
}
.activity-card-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.activity-meta-block {
display: flex;
flex-direction: column;
gap: 5px;
padding: 12px 13px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.025);
}
.activity-meta-label {
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-3);
}
.activity-meta-value {
font-size: 13px;
color: var(--text-2);
word-break: break-word;
}
@media (max-width: 640px) {
.activity-card {
padding: 16px;
}
.activity-card-head {
flex-direction: column;
align-items: stretch;
}
.activity-device-chip {
align-self: flex-start;
}
.activity-card-meta {
grid-template-columns: 1fr;
}
}
/* ── Completeness rows ────────────────────────────────────────────────────── */
.completeness-row {
display: flex;
@@ -574,6 +895,7 @@ a {
align-items: center;
gap: 12px;
padding: 14px 16px;
min-width: 0;
}
.tool-card:hover {
border-color: var(--border-strong);
@@ -1112,6 +1434,121 @@ input[type="color"] {
padding-left: 36px;
}
/* ── Command palette (topbar) ─────────────────────────────────────────────── */
.cmdk {
position: relative;
width: 340px;
max-width: 42vw;
flex-shrink: 0;
}
.cmdk-field {
position: relative;
display: flex;
align-items: center;
}
.cmdk-field > svg {
position: absolute;
left: 11px;
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.cmdk-input {
width: 100%;
padding: 8px 44px 8px 34px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 13px;
font-family: inherit;
transition: border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
}
.cmdk-input::placeholder {
color: var(--text-3);
}
.cmdk-input:focus {
border-color: var(--border-active);
box-shadow: 0 0 0 3px var(--accent-glow);
outline: none;
}
.cmdk-kbd {
position: absolute;
right: 9px;
font-size: 10.5px;
font-weight: 600;
color: var(--text-3);
background: var(--surface3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1px 6px;
pointer-events: none;
}
.cmdk-menu {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: min(420px, 80vw);
max-height: 60vh;
overflow-y: auto;
padding: 6px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
z-index: 60;
}
.cmdk-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 9px 11px;
border: 0;
border-radius: 9px;
background: none;
color: var(--text-2);
font: inherit;
font-size: 13.5px;
text-align: left;
cursor: pointer;
}
.cmdk-item.active {
background: var(--accent-soft);
color: var(--accent-h);
}
.cmdk-icon {
display: grid;
place-items: center;
flex-shrink: 0;
}
.cmdk-icon svg {
width: 16px;
height: 16px;
opacity: 0.9;
}
.cmdk-label {
flex: 1;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cmdk-section {
font-size: 11px;
color: var(--text-3);
letter-spacing: 0.03em;
text-transform: uppercase;
flex-shrink: 0;
}
.cmdk-empty {
padding: 16px 12px;
text-align: center;
color: var(--text-3);
font-size: 13px;
}
/* ── Two-column workbench (generator) ─────────────────────────────────────── */
.workbench {
display: grid;
@@ -1167,6 +1604,145 @@ input[type="color"] {
max-height: 360px;
overflow-y: auto;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
}
.settings-update-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 14px;
}
@media (max-width: 1180px) {
.topbar {
padding: 0 20px;
gap: 12px;
}
.content {
padding: 22px 20px 32px;
}
.cmdk {
width: min(300px, 46vw);
max-width: none;
}
}
@media (max-width: 720px) {
.topbar {
flex-wrap: wrap;
align-items: center;
padding: 10px 16px;
min-height: auto;
}
.topbar .crumbs {
order: 1;
flex: 1 1 0;
font-size: 12px;
line-height: 1.5;
}
.topbar-spacer {
display: none;
}
.cmdk {
order: 3;
width: 100%;
max-width: none;
}
.cmdk-kbd {
display: none;
}
.content {
padding: 18px 16px 28px;
}
.page-toolbar {
flex-direction: column;
align-items: stretch;
}
.page-toolbar-actions {
justify-content: stretch;
}
.page-toolbar-actions > * {
width: 100%;
}
.page-head {
gap: 12px;
margin-bottom: 18px;
}
.page-head-icon {
width: 42px;
height: 42px;
border-radius: 12px;
}
.page-head h1 {
font-size: 24px;
background: none;
-webkit-text-fill-color: initial;
color: var(--text);
}
.mini-grid,
.legend,
.settings-grid,
.settings-update-grid,
.activity-card-grid,
.activity-summary-grid,
.stat-row {
grid-template-columns: 1fr;
}
.genre-row {
grid-template-columns: 1fr;
gap: 8px;
}
.genre-count {
text-align: left;
}
.tool-card {
padding: 13px 14px;
}
.toast-wrap {
left: 16px;
right: 16px;
bottom: 16px;
max-width: none;
}
}
@media (max-width: 520px) {
.nav-brand {
min-height: 58px;
padding: 0 14px;
}
.nav-scroll {
padding: 10px 8px;
}
.nav-foot {
padding: 10px;
}
.panel-head,
.panel-body {
padding-left: 14px;
padding-right: 14px;
}
.panel-head {
flex-wrap: wrap;
align-items: flex-start;
}
.panel-head h3 {
font-size: 16px;
}
.btn,
.chip,
.status-chip {
max-width: 100%;
}
.row.between {
flex-wrap: wrap;
}
.cmdk-menu {
width: 100%;
}
}
.log-line {
display: flex;
gap: 10px;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/sidebar.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/toast.tsx","./src/pages/dashboard.tsx","./src/pages/settings.tsx","./src/pages/emby/airing.tsx","./src/pages/emby/bulkassign.tsx","./src/pages/emby/collections.tsx","./src/pages/emby/favorites.tsx","./src/pages/emby/generator.tsx","./src/pages/navidrome/collectioncompleteness.tsx","./src/pages/navidrome/covermanager.tsx","./src/pages/navidrome/library.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/api.ts","./src/main.tsx","./src/components/commandpalette.tsx","./src/components/sidebar.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/commands.tsx","./src/lib/toast.tsx","./src/pages/dashboard.tsx","./src/pages/settings.tsx","./src/pages/tasks.tsx","./src/pages/audiobookshelf/overview.tsx","./src/pages/emby/airing.tsx","./src/pages/emby/avatargenerator.tsx","./src/pages/emby/bulkassign.tsx","./src/pages/emby/collections.tsx","./src/pages/emby/favorites.tsx","./src/pages/emby/generator.tsx","./src/pages/emby/homescreeneditor.tsx","./src/pages/navidrome/collectioncompleteness.tsx","./src/pages/navidrome/covermanager.tsx","./src/pages/navidrome/library.tsx","./src/pages/navidrome/metadata.tsx","./src/pages/navidrome/reporting.tsx"],"version":"5.9.3"}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>HomeScreenPal</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
@@ -0,0 +1,635 @@
<script>
import { createEventDispatcher } from 'svelte';
export let open = false;
export let users = [];
export let selectedUserId = null;
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let seedType = 'Movie';
let collectionName = '';
let seedSearchTerm = '';
let seedResults = [];
let selectedSeeds = [];
let recommendations = [];
let includeSeeds = true;
let recommendationLimit = 18;
let searchBusy = false;
let previewBusy = false;
let createBusy = false;
let searchError = '';
let actionError = '';
let actionMessage = '';
let existingCollection = null;
let wasOpen = false;
$: sourceUsers = users.filter((user) => user.embyGuid);
$: if (open && !wasOpen) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
wasOpen = true;
} else if (!open && wasOpen) {
resetState();
wasOpen = false;
}
$: if (open && !sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: if (open && targetUserIds.length === 0 && selectedUserId != null) {
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: lookupType = seedType === 'Movie' ? 'Movie' : 'Series';
$: canPreview = !!sourceUser?.embyGuid && selectedSeeds.length > 0;
$: canCreate = canPreview && !!collectionName.trim() && !createBusy && recommendations.length > 0;
function close() {
dispatch('close');
}
function resetState() {
sourceUserId = null;
targetUserIds = [];
seedType = 'Movie';
collectionName = '';
seedSearchTerm = '';
seedResults = [];
selectedSeeds = [];
recommendations = [];
existingCollection = null;
includeSeeds = true;
recommendationLimit = 18;
searchBusy = false;
previewBusy = false;
createBusy = false;
searchError = '';
actionError = '';
actionMessage = '';
}
function toggleTargetUser(userId) {
if (targetUserIds.includes(userId)) {
targetUserIds = targetUserIds.filter((id) => id !== userId);
} else {
targetUserIds = [...targetUserIds, userId];
}
}
function addSeed(item) {
if (selectedSeeds.some((seed) => seed.id === item.id)) return;
selectedSeeds = [...selectedSeeds, item];
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function removeSeed(seedId) {
selectedSeeds = selectedSeeds.filter((seed) => seed.id !== seedId);
recommendations = [];
existingCollection = null;
}
async function searchSeeds() {
if (!sourceUser?.embyGuid) {
searchError = 'Select a source user with a linked Emby account.';
return;
}
if (!seedSearchTerm.trim()) {
seedResults = [];
searchError = '';
return;
}
searchBusy = true;
searchError = '';
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: seedSearchTerm.trim(),
types: lookupType,
limit: '10'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
seedResults = body.items || [];
} catch (err) {
searchError = err.message;
seedResults = [];
} finally {
searchBusy = false;
}
}
async function previewRecommendations() {
if (!canPreview) return;
previewBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'preview',
userId: sourceUser.embyGuid,
seedIds: selectedSeeds.map((seed) => seed.id),
name: collectionName.trim(),
limit: recommendationLimit
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
recommendations = body.recommendations || [];
existingCollection = body.collection || null;
actionMessage = recommendations.length
? existingCollection?.updated
? `Generated ${recommendations.length} recommendations. Re-running create will update "${existingCollection.name}".`
: `Generated ${recommendations.length} recommendations.`
: 'No recommendations were returned for these seeds.';
} catch (err) {
actionError = err.message;
recommendations = [];
existingCollection = null;
} finally {
previewBusy = false;
}
}
async function createCollection() {
if (!canCreate) return;
createBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'create',
userId: sourceUser.embyGuid,
name: collectionName.trim(),
seedIds: selectedSeeds.map((seed) => seed.id),
limit: recommendationLimit,
includeSeeds
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
dispatch('created', {
collection: body.collection,
targetUserIds,
seeds: selectedSeeds,
recommendations: body.recommendations || []
});
existingCollection = body.collection || null;
actionMessage = body.collection?.updated
? `Updated collection "${body.collection.name}".`
: `Created collection "${body.collection?.name || collectionName.trim()}".`;
} catch (err) {
actionError = err.message;
} finally {
createBusy = false;
}
}
</script>
{#if open}
<div
class="overlay"
role="button"
tabindex="0"
aria-label="Close collection builder"
on:click|self={close}
on:keydown={(event) => (event.key === 'Escape' || event.key === 'Enter') && close()}
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Recommendation collection builder">
<div class="modal-header">
<div>
<h3>Recommendation Collection Builder</h3>
<p>Pick seed movies or shows, generate similar items, then create a reusable Emby collection.</p>
</div>
<button class="close-btn" on:click={close}>×</button>
</div>
<div class="modal-body">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Seed type</span>
<select bind:value={seedType}>
<option value="Movie">Movies</option>
<option value="Series">Shows</option>
</select>
</label>
<label class="field span2">
<span class="field-label">Collection name</span>
<input
type="text"
bind:value={collectionName}
placeholder="Recommended for Family Movie Night"
/>
</label>
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Seed lookup</span>
<label class="field-inline compact">
<input type="checkbox" bind:checked={includeSeeds} />
<span>Include seeds in final collection</span>
</label>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={seedSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchSeeds()}
placeholder={`Search ${seedType === 'Movie' ? 'movies' : 'shows'}...`}
/>
<button class="btn ghost" on:click={searchSeeds} disabled={searchBusy || !sourceUser?.embyGuid}>
{searchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if searchError}
<div class="status error">{searchError}</div>
{/if}
{#if seedResults.length}
<div class="result-list">
{#each seedResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
{#if item.overview}
<div class="result-overview">{item.overview}</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Selected seeds</span>
<label class="field-inline compact">
<span>Recommendation count</span>
<select bind:value={recommendationLimit}>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
<option value={30}>30</option>
</select>
</label>
</div>
{#if selectedSeeds.length}
<div class="seed-list">
{#each selectedSeeds as item}
<div class="seed-chip">
<div>
<div class="seed-name">{item.name}</div>
<div class="seed-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
</div>
<button class="remove-btn" on:click={() => removeSeed(item.id)}>×</button>
</div>
{/each}
</div>
{:else}
<div class="empty-note">Add at least one seed item to generate recommendations.</div>
{/if}
</div>
<div class="field-group">
<span class="field-label">Apply linked section to users</span>
<div class="target-list">
{#each users.filter((user) => user.embyGuid) as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTargetUser(user.id)}
/>
<span>{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
</div>
{#if recommendations.length}
<div class="field-group">
<span class="field-label">Recommendation preview</span>
<div class="preview-list">
{#each recommendations as item}
<div class="preview-item">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''} · matched {item.matchCount} seed{item.matchCount === 1 ? '' : 's'}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if existingCollection?.id}
<div class="status">Collection target: {existingCollection.name} · existing box set will be updated</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn ghost" on:click={previewRecommendations} disabled={!canPreview || previewBusy || createBusy}>
{previewBusy ? 'Generating…' : 'Preview Recommendations'}
</button>
<button class="btn accent" on:click={createCollection} disabled={!canCreate || previewBusy || createBusy}>
{createBusy ? (existingCollection?.updated ? 'Updating…' : 'Creating…') : (existingCollection?.updated ? 'Update Collection' : 'Create Collection')}
</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 120;
}
.modal {
width: min(980px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
border-radius: 20px;
background: var(--surface-strong);
border: 1px solid var(--border);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
}
.modal-header,
.modal-actions {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.modal-actions {
border-bottom: none;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.modal-header h3 {
margin: 0 0 6px;
font-size: 18px;
color: var(--text);
}
.modal-header p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.close-btn,
.remove-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
font-size: 22px;
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field,
.field-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field-label-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.field-inline.compact {
font-size: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.result-list,
.preview-list,
.target-list,
.seed-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-card,
.preview-item,
.seed-chip,
.target-option {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
}
.result-card {
all: unset;
cursor: pointer;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
transition: border-color 0.12s ease, background 0.12s ease;
}
.result-card:hover,
.target-option:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.result-name,
.seed-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.seed-meta,
.target-count {
font-size: 12px;
color: var(--text-muted);
}
.result-overview {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.seed-chip,
.target-option {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.target-option {
cursor: pointer;
}
.target-option.selected {
background: var(--surface-active);
border-color: var(--border-strong);
}
.empty-note {
padding: 14px;
border-radius: 14px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 13px;
}
.status {
padding: 10px 12px;
border-radius: 14px;
font-size: 12px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
border: 1px solid transparent;
}
.btn.ghost {
color: #d9e7f8;
border-color: var(--border);
background: var(--surface);
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
@media (max-width: 720px) {
.field-grid,
.lookup-row {
grid-template-columns: 1fr;
}
.modal-header,
.modal-body,
.modal-actions {
padding: 16px;
}
.modal-actions {
flex-direction: column;
}
.field.span2 {
grid-column: span 1;
}
}
</style>
@@ -0,0 +1,857 @@
<script>
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/Icon.svelte';
import { RECOMMENDATION_PROFILES } from '$lib/collection-tools.js';
export let users = [];
export let selectedUserId = null;
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let collectionName = '';
let selectedSeeds = [];
let recommendations = [];
let includeSeeds = true;
let recommendationLimit = 18;
let recommendationProfile = 'balanced';
let recentItems = [];
let recentBusy = false;
let recentError = '';
let movieSearchTerm = '';
let showSearchTerm = '';
let movieResults = [];
let showResults = [];
let movieSearchBusy = false;
let showSearchBusy = false;
let movieSearchError = '';
let showSearchError = '';
let previewBusy = false;
let createBusy = false;
let actionError = '';
let actionMessage = '';
let existingCollection = null;
$: sourceUsers = users.filter((user) => user.embyGuid);
$: profileOptions = Object.values(RECOMMENDATION_PROFILES);
$: selectedProfile = RECOMMENDATION_PROFILES[recommendationProfile] || RECOMMENDATION_PROFILES.balanced;
$: if (!sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: canPreview = !!sourceUser?.embyGuid && selectedSeeds.length > 0;
$: canCreate = canPreview && !!collectionName.trim() && recommendations.length > 0 && !createBusy;
$: if (sourceUser?.embyGuid) {
loadRecentActivity(sourceUser.embyGuid);
}
let lastRecentUserId = '';
async function loadRecentActivity(embyGuid) {
if (!embyGuid || lastRecentUserId === embyGuid) return;
lastRecentUserId = embyGuid;
recentBusy = true;
recentError = '';
recentItems = [];
try {
const params = new URLSearchParams({ embyGuid });
const response = await fetch(`/api/emby-user-context?${params.toString()}`);
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
const activity = Array.isArray(body.recentlyPlayed) ? body.recentlyPlayed : [];
recentItems = activity.filter((item) => item.type === 'Movie' || item.type === 'Series');
} catch (error) {
recentError = error.message;
} finally {
recentBusy = false;
}
}
function toggleTargetUser(userId) {
if (targetUserIds.includes(userId)) {
targetUserIds = targetUserIds.filter((id) => id !== userId);
} else {
targetUserIds = [...targetUserIds, userId];
}
}
function addSeed(item) {
if (!item?.id || selectedSeeds.some((seed) => seed.id === item.id)) return;
selectedSeeds = [...selectedSeeds, item];
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function removeSeed(seedId) {
selectedSeeds = selectedSeeds.filter((seed) => seed.id !== seedId);
recommendations = [];
existingCollection = null;
}
function handleProfileChange() {
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function logPreviewDiagnostics(requestPayload, responseBody) {
if (typeof window === 'undefined') return;
const diagnostics = responseBody?.diagnostics || {};
const perSeed = Array.isArray(diagnostics.perSeed) ? diagnostics.perSeed : [];
console.groupCollapsed('[Collections] Recommendation preview');
console.info('Request', requestPayload);
console.info('Response summary', {
profile: responseBody?.profile,
recommendationCount: (responseBody?.recommendations || []).length,
libraryItemCount: diagnostics.libraryItemCount ?? 0,
watchedItemCount: diagnostics.watchedItemCount ?? 0,
seedCount: diagnostics.seedCount ?? 0,
embyCandidates: diagnostics.embyCandidates ?? 0,
tmdbEnabled: diagnostics.tmdbEnabled ?? false,
tmdbCandidates: diagnostics.tmdbCandidates ?? 0,
tmdbResolved: diagnostics.tmdbResolved ?? 0,
uniqueCandidateCount: diagnostics.uniqueCandidateCount ?? 0,
excludedSeedCandidateCount: diagnostics.excludedSeedCandidateCount ?? 0
});
console.info('Seeds returned by API', responseBody?.seeds || []);
if ((diagnostics.sampleExcludedSeedCandidates || []).length) {
console.warn('Candidates dropped because they resolved back onto the seed items', diagnostics.sampleExcludedSeedCandidates);
}
if (perSeed.length) {
console.table(
perSeed.map((seed) => ({
seed: seed.seedName,
type: seed.seedType,
year: seed.seedYear,
tmdbProviderId: seed.providerTmdbId || '',
tmdbMatchId: seed.tmdbMatch?.tmdbId || '',
tmdbMediaType: seed.tmdbMatch?.mediaType || '',
similar: seed.similarCount ?? 0,
recommendations: seed.recommendationCount ?? 0,
localSimilar: seed.localSimilarCount ?? 0,
localRecommendations: seed.localRecommendationCount ?? 0,
error: seed.error || ''
}))
);
console.info('Per-seed detail', perSeed);
}
if ((diagnostics.discoverQueries || []).length) {
console.table(
diagnostics.discoverQueries.map((query) => ({
query: query.label,
tmdbCount: query.tmdbCount,
localCount: query.localCount,
error: query.error || ''
}))
);
console.info('Discover query detail', diagnostics.discoverQueries);
}
if ((responseBody?.recommendations || []).length) {
console.info('Recommendations', responseBody.recommendations);
}
console.groupEnd();
}
async function searchItems(type) {
const searchTerm = type === 'Movie' ? movieSearchTerm.trim() : showSearchTerm.trim();
if (!sourceUser?.embyGuid) {
if (type === 'Movie') movieSearchError = 'Select a source user first.';
else showSearchError = 'Select a source user first.';
return;
}
if (!searchTerm) {
if (type === 'Movie') movieResults = [];
else showResults = [];
return;
}
if (type === 'Movie') {
movieSearchBusy = true;
movieSearchError = '';
} else {
showSearchBusy = true;
showSearchError = '';
}
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: searchTerm,
types: type,
limit: '10'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
if (type === 'Movie') movieResults = body.items || [];
else showResults = body.items || [];
} catch (error) {
if (type === 'Movie') {
movieSearchError = error.message;
movieResults = [];
} else {
showSearchError = error.message;
showResults = [];
}
} finally {
if (type === 'Movie') movieSearchBusy = false;
else showSearchBusy = false;
}
}
async function previewRecommendations() {
if (!canPreview) return;
previewBusy = true;
actionError = '';
actionMessage = '';
const requestPayload = {
mode: 'preview',
userId: sourceUser.embyGuid,
seedIds: selectedSeeds.map((seed) => seed.id),
seeds: selectedSeeds.map((seed) => ({
id: seed.id,
name: seed.name,
type: seed.type,
year: seed.year
})),
name: collectionName.trim(),
limit: recommendationLimit,
profile: recommendationProfile
};
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestPayload)
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
logPreviewDiagnostics(requestPayload, body);
recommendations = body.recommendations || [];
existingCollection = body.collection || null;
if (recommendations.length) {
actionMessage = existingCollection?.updated
? `Generated ${recommendations.length} recommendations. Re-running create will update "${existingCollection.name}".`
: `Generated ${recommendations.length} recommendations.`;
} else if (body?.diagnostics?.tmdbEnabled && body?.diagnostics?.tmdbCandidates > 0 && body?.diagnostics?.tmdbResolved === 0) {
actionMessage = 'TMDB found similar titles, but none of them matched items currently in your Emby library.';
} else {
actionMessage = 'No recommendations were returned for these seeds.';
}
} catch (error) {
console.error('[Collections] Recommendation preview failed', {
request: requestPayload,
error
});
actionError = error.message;
recommendations = [];
existingCollection = null;
} finally {
previewBusy = false;
}
}
async function createCollection() {
if (!canCreate) return;
createBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'create',
userId: sourceUser.embyGuid,
name: collectionName.trim(),
seedIds: selectedSeeds.map((seed) => seed.id),
limit: recommendationLimit,
includeSeeds,
profile: recommendationProfile
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
dispatch('created', {
collection: body.collection,
targetUserIds,
seeds: selectedSeeds,
recommendations: body.recommendations || []
});
existingCollection = body.collection || null;
actionMessage = body.collection?.updated
? `Updated collection "${body.collection.name}".`
: `Created collection "${body.collection?.name || collectionName.trim()}".`;
} catch (error) {
actionError = error.message;
} finally {
createBusy = false;
}
}
</script>
<div class="collection-page">
<div class="page-header">
<div>
<div class="page-title-row">
<span class="page-icon"><Icon name="collections" size={18} /></span>
<h2>Collections</h2>
</div>
<p class="page-copy">Build recommendation collections from recent activity, seed movies, and seed shows.</p>
</div>
<div class="page-actions">
<label class="field-inline compact field-select">
<span>Profile</span>
<select bind:value={recommendationProfile} on:change={handleProfileChange}>
{#each profileOptions as profile}
<option value={profile.id}>{profile.label}</option>
{/each}
</select>
</label>
<label class="field-inline compact">
<input type="checkbox" bind:checked={includeSeeds} />
<span>Include seeds in collection</span>
</label>
<label class="field-inline compact">
<span>Recommendation count</span>
<select bind:value={recommendationLimit}>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
<option value={30}>30</option>
</select>
</label>
</div>
</div>
<div class="builder-grid">
<div class="builder-main">
<div class="builder-card hero-card">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Collection name</span>
<input type="text" bind:value={collectionName} placeholder="Recommended for Family Night" />
</label>
</div>
<p class="profile-note">{selectedProfile.description}</p>
</div>
<div class="seed-grid">
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="activity" size={16} />
<h3>Watched Activity</h3>
</div>
<span class="card-copy">Quick-add from recent movies and shows.</span>
</div>
{#if recentError}
<div class="status error">{recentError}</div>
{:else if recentBusy}
<div class="status">Loading recent activity…</div>
{:else if recentItems.length}
<div class="result-list">
{#each recentItems as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.datePlayed ? ` · ${new Date(item.datePlayed).toLocaleDateString()}` : ''}</div>
</button>
{/each}
</div>
{:else}
<div class="empty-note">No recent movie or show activity was available for this user.</div>
{/if}
</section>
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="search" size={16} />
<h3>Seed Movies</h3>
</div>
<span class="card-copy">Search Emby and add movie seeds.</span>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={movieSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchItems('Movie')}
placeholder="Search movies..."
/>
<button class="btn ghost" on:click={() => searchItems('Movie')} disabled={movieSearchBusy}>
{movieSearchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if movieSearchError}
<div class="status error">{movieSearchError}</div>
{/if}
{#if movieResults.length}
<div class="result-list">
{#each movieResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.year ? `${item.year} · ` : ''}{item.type}</div>
</button>
{/each}
</div>
{/if}
</section>
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="search" size={16} />
<h3>Seed Shows</h3>
</div>
<span class="card-copy">Search Emby and add TV seeds.</span>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={showSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchItems('Series')}
placeholder="Search shows..."
/>
<button class="btn ghost" on:click={() => searchItems('Series')} disabled={showSearchBusy}>
{showSearchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if showSearchError}
<div class="status error">{showSearchError}</div>
{/if}
{#if showResults.length}
<div class="result-list">
{#each showResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.year ? `${item.year} · ` : ''}{item.type}</div>
</button>
{/each}
</div>
{/if}
</section>
</div>
</div>
<div class="builder-side">
<section class="builder-card sticky-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="spark" size={16} />
<h3>Selected Seeds</h3>
</div>
<span class="card-copy">Mix watched activity with manual seeds.</span>
</div>
{#if selectedSeeds.length}
<div class="seed-list">
{#each selectedSeeds as item}
<div class="seed-chip">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
</div>
<button class="remove-btn" on:click={() => removeSeed(item.id)}>×</button>
</div>
{/each}
</div>
{:else}
<div class="empty-note">Add at least one seed to generate recommendations.</div>
{/if}
<div class="divider"></div>
<div class="card-header compact">
<div class="card-title-row">
<Icon name="boxset" size={16} />
<h3>Apply Section To</h3>
</div>
</div>
<div class="target-list">
{#each users.filter((user) => user.embyGuid) as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTargetUser(user.id)}
/>
<span>{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
{#if recommendations.length}
<div class="divider"></div>
<div class="card-header compact">
<div class="card-title-row">
<Icon name="collections" size={16} />
<h3>Recommendation Preview</h3>
</div>
</div>
<div class="preview-list">
{#each recommendations as item}
<div class="preview-item">
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''} · matched {item.matchCount}
{#if item.styleScore !== null && item.styleScore !== undefined}
· style {item.styleScore}
{/if}
{#if item.qualityScore !== null && item.qualityScore !== undefined}
· quality {item.qualityScore}
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if existingCollection?.id}
<div class="divider"></div>
<div class="status">
Collection target: {existingCollection.name} · existing box set will be updated
</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
<div class="action-row">
<button class="btn ghost" on:click={previewRecommendations} disabled={!canPreview || previewBusy || createBusy}>
{previewBusy ? 'Generating…' : 'Preview Recommendations'}
</button>
<button class="btn accent" on:click={createCollection} disabled={!canCreate || previewBusy || createBusy}>
{createBusy ? (existingCollection?.updated ? 'Updating…' : 'Creating…') : (existingCollection?.updated ? 'Update Collection' : 'Create Collection')}
</button>
</div>
</section>
</div>
</div>
</div>
<style>
.collection-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title-row,
.card-title-row {
display: flex;
align-items: center;
gap: 10px;
}
h2,
h3 {
margin: 0;
color: var(--text);
}
h2 {
font-size: 24px;
font-weight: 800;
}
h3 {
font-size: 15px;
font-weight: 800;
}
.page-copy,
.card-copy {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.field-select {
min-width: 220px;
}
.builder-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
gap: 18px;
align-items: start;
}
.builder-main,
.builder-side,
.seed-grid {
display: flex;
flex-direction: column;
gap: 18px;
}
.seed-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.builder-card {
background: #0f1116;
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
}
.hero-card {
padding-bottom: 14px;
}
.profile-note {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
.sticky-card {
position: sticky;
top: 24px;
}
.card-header.compact {
margin-bottom: 8px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.field-inline.compact {
font-size: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 12px;
}
.result-list,
.seed-list,
.target-list,
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.result-card,
.seed-chip,
.target-option,
.preview-item {
border: 1px solid var(--border);
border-radius: 10px;
background: #111419;
padding: 12px 14px;
}
.result-card {
all: unset;
cursor: pointer;
border: 1px solid var(--border);
border-radius: 10px;
background: #111419;
padding: 12px 14px;
transition: background 0.12s ease, border-color 0.12s ease;
}
.result-card:hover,
.target-option:hover {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.result-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.target-count {
font-size: 12px;
color: var(--text-muted);
}
.seed-chip,
.target-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.target-option {
cursor: pointer;
}
.target-option.selected {
background: var(--surface-active);
border-color: var(--border-strong);
}
.preview-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.remove-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
font-size: 20px;
line-height: 1;
}
.remove-btn:hover {
color: var(--danger);
}
.empty-note {
padding: 14px;
border-radius: 10px;
background: #111419;
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 13px;
margin-top: 12px;
}
.divider {
height: 1px;
background: rgba(148, 163, 184, 0.1);
margin: 16px 0;
}
.action-row {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.status {
padding: 10px 12px;
border-radius: 10px;
font-size: 12px;
background: #111419;
border: 1px solid var(--border);
color: var(--text-muted);
margin-top: 12px;
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
border: 1px solid transparent;
text-align: center;
}
.btn.ghost {
color: var(--text);
border-color: var(--border);
background: #15181e;
}
.btn.accent {
background: var(--accent);
border-color: rgba(42, 215, 239, 0.35);
color: #031014;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.page-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: rgba(40, 193, 220, 0.1);
color: var(--accent);
}
@media (max-width: 1100px) {
.builder-grid {
grid-template-columns: 1fr;
}
.sticky-card {
position: static;
}
}
@media (max-width: 860px) {
.seed-grid,
.field-grid,
.lookup-row {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
.page-actions {
width: 100%;
}
}
</style>
@@ -0,0 +1,662 @@
<script>
import Icon from '$lib/Icon.svelte';
export let users = [];
export let selectedUserId = null;
let sourceUserId = null;
let mediaType = 'Movie';
let searchTerm = '';
let searchResults = [];
let inspections = {};
let searchBusy = false;
let inspectAllBusy = false;
let applyAllBusy = false;
let searchError = '';
let actionError = '';
let actionMessage = '';
$: sourceUsers = users.filter((user) => user.embyGuid);
$: if (!sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: inspectedCount = Object.values(inspections).filter((entry) => entry?.item).length;
$: readyToApplyCount = Object.values(inspections).filter(
(entry) => entry?.selectedGenre && !entry?.error
).length;
let lastSearchContext = '';
$: {
const nextSearchContext = `${sourceUserId || ''}:${mediaType}`;
if (nextSearchContext !== lastSearchContext) {
lastSearchContext = nextSearchContext;
searchResults = [];
inspections = {};
searchError = '';
actionError = '';
actionMessage = '';
}
}
async function searchLibrary() {
actionError = '';
actionMessage = '';
searchError = '';
if (!sourceUser?.embyGuid) {
searchError = 'Select a linked Emby user first.';
return;
}
if (!searchTerm.trim()) {
searchResults = [];
inspections = {};
return;
}
searchBusy = true;
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: searchTerm.trim(),
types: mediaType,
limit: '25'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
searchResults = body.items || [];
inspections = {};
actionMessage = searchResults.length
? `Found ${searchResults.length} ${mediaType === 'Movie' ? 'movie' : 'show'} matches.`
: 'No matching items were found.';
} catch (err) {
searchError = err.message;
searchResults = [];
inspections = {};
} finally {
searchBusy = false;
}
}
async function inspectItem(item) {
if (!sourceUser?.embyGuid || !item?.id) return;
inspections = {
...inspections,
[item.id]: {
...(inspections[item.id] || {}),
loading: true,
error: '',
updated: false
}
};
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
itemId: item.id
});
const response = await fetch(`/api/emby-genre-cleanup?${params.toString()}`);
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
inspections = {
...inspections,
[item.id]: {
loading: false,
error: '',
updated: inspections[item.id]?.updated || false,
...body,
selectedGenre: body.suggestedGenre || body.tmdb?.genres?.[0] || ''
}
};
} catch (err) {
inspections = {
...inspections,
[item.id]: {
...(inspections[item.id] || {}),
loading: false,
error: err.message
}
};
}
}
function updateSelectedGenre(itemId, genreName) {
inspections = {
...inspections,
[itemId]: {
...(inspections[itemId] || {}),
selectedGenre: genreName
}
};
}
function syncSearchResultGenres(itemId, genreName) {
searchResults = searchResults.map((item) =>
item.id === itemId
? {
...item,
genres: genreName ? [genreName] : []
}
: item
);
}
async function applyGenre(item) {
const inspection = inspections[item.id];
if (!sourceUser?.embyGuid || !inspection?.selectedGenre) return;
actionError = '';
actionMessage = '';
inspections = {
...inspections,
[item.id]: {
...inspection,
applying: true,
error: ''
}
};
try {
const response = await fetch('/api/emby-genre-cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: sourceUser.embyGuid,
itemId: item.id,
genreName: inspection.selectedGenre
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
inspections = {
...inspections,
[item.id]: {
loading: false,
applying: false,
error: '',
updated: true,
...body,
selectedGenre: inspection.selectedGenre
}
};
syncSearchResultGenres(item.id, inspection.selectedGenre);
actionMessage = `Updated "${item.name}" to ${inspection.selectedGenre}.`;
} catch (err) {
inspections = {
...inspections,
[item.id]: {
...inspection,
applying: false,
error: err.message
}
};
actionError = err.message;
}
}
async function inspectAllResults() {
if (!searchResults.length) return;
inspectAllBusy = true;
actionError = '';
actionMessage = '';
try {
for (const item of searchResults) {
if (!inspections[item.id]?.item) {
await inspectItem(item);
}
}
actionMessage = `Inspected ${searchResults.length} item${searchResults.length === 1 ? '' : 's'}.`;
} finally {
inspectAllBusy = false;
}
}
async function applyToAllInspected() {
const pendingItems = searchResults.filter((item) => {
const inspection = inspections[item.id];
return inspection?.selectedGenre && !inspection?.error;
});
if (!pendingItems.length) return;
applyAllBusy = true;
actionError = '';
actionMessage = '';
try {
for (const item of pendingItems) {
await applyGenre(item);
}
actionMessage = `Applied single-genre cleanup to ${pendingItems.length} item${pendingItems.length === 1 ? '' : 's'}.`;
} finally {
applyAllBusy = false;
}
}
</script>
<div class="genre-page">
<div class="page-header">
<div>
<div class="page-title-row">
<span class="page-icon"><Icon name="search" size={18} /></span>
<h2>Genre Cleanup</h2>
</div>
<p class="page-copy">Search movies or shows, compare Emby genres with TMDB genres, and reduce each title to one genre.</p>
</div>
<div class="page-actions">
<button class="btn ghost" on:click={inspectAllResults} disabled={!searchResults.length || inspectAllBusy || searchBusy || applyAllBusy}>
{inspectAllBusy ? 'Inspecting…' : 'Inspect all'}
</button>
<button class="btn accent" on:click={applyToAllInspected} disabled={!readyToApplyCount || inspectAllBusy || searchBusy || applyAllBusy}>
{applyAllBusy ? 'Applying…' : `Apply all (${readyToApplyCount})`}
</button>
</div>
</div>
<div class="genre-grid">
<div class="builder-main">
<section class="builder-card hero-card">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Media type</span>
<select bind:value={mediaType}>
<option value="Movie">Movies</option>
<option value="Series">TV Shows</option>
</select>
</label>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={searchTerm}
on:keydown={(event) => event.key === 'Enter' && searchLibrary()}
placeholder={mediaType === 'Movie' ? 'Search movies...' : 'Search shows...'}
/>
<button class="btn ghost" on:click={searchLibrary} disabled={searchBusy}>
{searchBusy ? 'Searching…' : 'Search'}
</button>
</div>
<p class="profile-note">The suggested genre uses TMDB order as a hint, but you can change the choice before writing it back to Emby.</p>
</section>
{#if searchError}
<div class="status error">{searchError}</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="items" size={16} />
<h3>Matches</h3>
</div>
<span class="card-copy">Inspect one title at a time or batch the current search results.</span>
</div>
{#if !searchBusy && !searchResults.length}
<div class="empty-note">Search for a movie or show to start cleaning up its genres.</div>
{:else}
<div class="result-list">
{#each searchResults as item}
{@const inspection = inspections[item.id]}
<div class="result-card">
<div class="result-header">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''}
{#if inspection?.updated} · updated{/if}
</div>
</div>
<button class="btn ghost small" on:click={() => inspectItem(item)} disabled={inspection?.loading || inspection?.applying || applyAllBusy}>
{inspection?.loading ? 'Inspecting…' : 'Inspect'}
</button>
</div>
<div class="genre-row">
<span class="genre-label">Emby</span>
<span class="genre-value">{(inspection?.currentGenres || item.genres || []).join(', ') || 'None'}</span>
</div>
{#if inspection?.error}
<div class="status error inline-status">{inspection.error}</div>
{:else if inspection?.item}
<div class="genre-panel">
<div class="genre-row">
<span class="genre-label">TMDB</span>
<span class="genre-value">{inspection.tmdb?.genres?.join(', ') || 'No genres returned'}</span>
</div>
<div class="genre-row">
<span class="genre-label">Match</span>
<span class="genre-value">
{#if inspection.tmdb?.tmdbId}
{inspection.tmdb.source === 'providerId' ? 'Provider ID match' : 'Title search match'} · TMDB {inspection.tmdb.tmdbId}
{:else}
No TMDB match
{/if}
</span>
</div>
{#if inspection.tmdb?.genres?.length}
<div class="apply-grid">
<label class="field">
<span class="field-label">Single genre</span>
<select
value={inspection.selectedGenre}
on:change={(event) => updateSelectedGenre(item.id, event.target.value)}
>
{#each inspection.tmdb.genres as genre}
<option value={genre}>{genre}</option>
{/each}
</select>
</label>
<div class="apply-actions">
<button class="btn accent" on:click={() => applyGenre(item)} disabled={!inspection.selectedGenre || inspection.applying || applyAllBusy}>
{inspection.applying ? 'Applying…' : 'Apply single genre'}
</button>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
</div>
<aside class="builder-side">
<section class="builder-card sticky-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="spark" size={16} />
<h3>Summary</h3>
</div>
</div>
<div class="detail-row">
<span>Search results</span>
<strong>{searchResults.length}</strong>
</div>
<div class="detail-row">
<span>Inspected</span>
<strong>{inspectedCount}</strong>
</div>
<div class="detail-row">
<span>Ready to apply</span>
<strong>{readyToApplyCount}</strong>
</div>
<p class="detail-message">
TMDB can return multiple genres. This tool lets you use the first returned genre as a starting point without forcing that choice.
</p>
</section>
</aside>
</div>
</div>
<style>
.genre-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title-row,
.card-title-row {
display: flex;
align-items: center;
gap: 10px;
}
h2,
h3 {
margin: 0;
color: var(--text);
}
h2 {
font-size: 24px;
font-weight: 800;
}
h3 {
font-size: 15px;
font-weight: 800;
}
.page-copy,
.card-copy {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.genre-grid {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.85fr);
gap: 18px;
align-items: start;
}
.builder-main,
.builder-side {
display: flex;
flex-direction: column;
gap: 18px;
}
.builder-card {
background: #0f1116;
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px;
}
.hero-card {
padding-bottom: 14px;
}
.sticky-card {
position: sticky;
top: 24px;
}
.field-grid,
.apply-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.profile-note,
.detail-message {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
.result-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.result-card {
border: 1px solid var(--border);
border-radius: 10px;
background: #111419;
padding: 14px;
}
.result-header,
.detail-row,
.genre-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.result-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.genre-label {
font-size: 12px;
color: var(--text-muted);
}
.genre-value {
font-size: 13px;
color: var(--text);
text-align: right;
}
.genre-panel {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.apply-actions {
display: flex;
align-items: flex-end;
}
.status {
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
}
.status.ok {
border-color: rgba(34, 197, 94, 0.32);
color: #b7f3ca;
}
.status.error {
border-color: rgba(239, 68, 68, 0.32);
color: #ffc2c2;
}
.inline-status {
margin-top: 12px;
}
.empty-note {
margin-top: 12px;
font-size: 13px;
color: var(--text-muted);
}
.btn {
all: unset;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border);
font-size: 13px;
font-weight: 700;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.btn.ghost {
background: #12151b;
color: var(--text);
}
.btn.accent {
background: linear-gradient(135deg, #2ad7ef 0%, #1798b4 100%);
border-color: rgba(42, 215, 239, 0.35);
color: #071217;
}
.btn.small {
padding: 8px 12px;
font-size: 12px;
}
@media (max-width: 1100px) {
.genre-grid {
grid-template-columns: 1fr;
}
.sticky-card {
position: static;
}
}
@media (max-width: 700px) {
.page-header,
.apply-grid,
.field-grid {
grid-template-columns: 1fr;
display: grid;
}
.page-actions {
width: 100%;
}
.lookup-row {
grid-template-columns: 1fr;
}
.result-header,
.detail-row,
.genre-row {
flex-direction: column;
}
.genre-value {
text-align: left;
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script>
export let name = 'spark';
export let size = 16;
export let stroke = 1.8;
const paths = {
edit: [
'M3 21h3.8L19.4 8.4a2.2 2.2 0 0 0 0-3.1l-.7-.7a2.2 2.2 0 0 0-3.1 0L3 17.2V21Z',
'M13.5 6.5l4 4'
],
sync: [
'M17 3l4 4-4 4',
'M3 7h18',
'M7 21l-4-4 4-4',
'M21 17H3'
],
settings: [
'M12 8.5a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7Z',
'M19.4 15a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 0 1 0 1.7l-1.4 1.4a1.2 1.2 0 0 1-1.7 0l-.1-.1a1 1 0 0 0-1.1-.2a1 1 0 0 0-.6.9V21a1.2 1.2 0 0 1-1.2 1.2h-2a1.2 1.2 0 0 1-1.2-1.2v-.2a1 1 0 0 0-.6-.9a1 1 0 0 0-1.1.2l-.1.1a1.2 1.2 0 0 1-1.7 0L4.3 18a1.2 1.2 0 0 1 0-1.7l.1-.1a1 1 0 0 0 .2-1.1a1 1 0 0 0-.9-.6H3.5A1.2 1.2 0 0 1 2.3 13v-2a1.2 1.2 0 0 1 1.2-1.2h.2a1 1 0 0 0 .9-.6a1 1 0 0 0-.2-1.1l-.1-.1a1.2 1.2 0 0 1 0-1.7l1.4-1.4a1.2 1.2 0 0 1 1.7 0l.1.1a1 1 0 0 0 1.1.2a1 1 0 0 0 .6-.9V3A1.2 1.2 0 0 1 10.5 1.8h2A1.2 1.2 0 0 1 13.7 3v.2a1 1 0 0 0 .6.9a1 1 0 0 0 1.1-.2l.1-.1a1.2 1.2 0 0 1 1.7 0L18.6 5a1.2 1.2 0 0 1 0 1.7l-.1.1a1 1 0 0 0-.2 1.1a1 1 0 0 0 .9.6h.2A1.2 1.2 0 0 1 20.6 11v2a1.2 1.2 0 0 1-1.2 1.2h-.2a1 1 0 0 0-.9.8Z'
],
collections: [
'M4 7.5h16',
'M4 12h16',
'M4 16.5h10',
'M17.5 14.5v6',
'M14.5 17.5h6'
],
user: [
'M12 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z',
'M4 20a8 8 0 0 1 16 0'
],
resume: [
'M7 5v14l11-7Z'
],
items: [
'M4 6.5h16',
'M4 12h16',
'M4 17.5h16'
],
userviews: [
'M4 5h7v6H4Z',
'M13 5h7v6h-7Z',
'M4 13h7v6H4Z',
'M13 13h7v6h-7Z'
],
boxset: [
'M4 8l8-4l8 4-8 4-8-4Z',
'M4 8v8l8 4l8-4V8'
],
latestepisodereleases: [
'M4 18h16',
'M7 18V9l5-4l5 4v9'
],
latestmoviereleases: [
'M4 7h16v10H4Z',
'M8 7V5',
'M16 7V5',
'M8 17v2',
'M16 17v2'
],
latestmediablock: [
'M4 6h16',
'M4 12h10',
'M4 18h16'
],
spark: [
'M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z'
],
search: [
'M11 18a7 7 0 1 0 0-14a7 7 0 0 0 0 14Z',
'M20 20l-3.5-3.5'
],
activity: [
'M5 12h3l2-5l4 10l2-5h3'
],
plus: [
'M12 5v14',
'M5 12h14'
]
};
$: selectedPaths = paths[name] || paths.spark;
</script>
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={stroke}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#each selectedPaths as path}
<path d={path}></path>
{/each}
</svg>
<style>
.icon {
display: block;
flex: none;
}
</style>
+848
View File
@@ -0,0 +1,848 @@
<script>
import {
SECTION_TYPES,
COLLECTION_TYPES,
ITEM_TYPES,
SORT_OPTIONS,
IMAGE_TYPES,
GENRES,
getSectionIconName,
getSectionTypeLabel,
getGenreNames
} from '$lib/constants.js';
import Icon from '$lib/Icon.svelte';
export let section;
export let index;
export let total;
export let expanded = false;
export let excludedFolderLookup = {};
export let lookupUserId = '';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
$: genreNames = getGenreNames(section.Query?.GenreIds);
$: typeLabel = getSectionTypeLabel(section.SectionType);
$: sectionIcon = getSectionIconName(section.SectionType);
$: showFilters =
section.SectionType === 'items' ||
section.SectionType === 'collections' ||
section.SectionType === 'latestepisodereleases' ||
section.SectionType === 'latestmoviereleases';
// ExcludedFolders as a comma-separated string for editing
$: excludedFoldersStr = (section.ExcludedFolders || []).join(', ');
$: excludedFolderDetails = (section.ExcludedFolders || []).map((id) => ({
id,
label: excludedFolderLookup?.[id]?.name || null,
type: excludedFolderLookup?.[id]?.type || null
}));
// TagIds as a comma-separated string for editing
$: tagIdsStr = (section.Query?.TagIds || []).join(', ');
let collectionSearchTerm = '';
let collectionResults = [];
let collectionLookupBusy = false;
let collectionLookupError = '';
function toggleGenre(id) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const idx = section.Query.GenreIds.indexOf(id);
if (idx >= 0) {
section.Query.GenreIds = section.Query.GenreIds.filter((g) => g !== id);
} else {
section.Query.GenreIds = [...section.Query.GenreIds, id];
}
dispatch('change');
}
function toggleItemType(type) {
const idx = section.ItemTypes.indexOf(type);
if (idx >= 0) {
section.ItemTypes = section.ItemTypes.filter((t) => t !== type);
} else {
section.ItemTypes = [...section.ItemTypes, type];
}
dispatch('change');
}
function handleChange() {
dispatch('change');
}
function handleExcludedFoldersChange(e) {
const raw = e.target.value;
section.ExcludedFolders = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
function handleTagIdsChange(e) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const raw = e.target.value;
section.Query.TagIds = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
async function searchCollections() {
if (!lookupUserId) {
collectionLookupError = 'Select a user with a linked Emby account to search collections.';
return;
}
if (!collectionSearchTerm.trim()) {
collectionResults = [];
collectionLookupError = '';
return;
}
collectionLookupBusy = true;
collectionLookupError = '';
try {
const params = new URLSearchParams({
userId: lookupUserId,
term: collectionSearchTerm.trim(),
types: 'BoxSet',
limit: '8'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
collectionResults = body.items || [];
} catch (err) {
collectionLookupError = err.message;
collectionResults = [];
} finally {
collectionLookupBusy = false;
}
}
function applyCollection(item) {
const previousName = section.ParentItem?.Name || section.Name || section.CustomName || '';
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = item.name;
section.ParentItem.Id = item.id;
section.ParentId = item.id;
if (!section.CustomName || section.CustomName === previousName) {
section.CustomName = item.name;
}
if (!section.Name || section.Name === previousName) {
section.Name = item.name;
}
collectionSearchTerm = item.name;
collectionResults = [];
dispatch('change');
}
</script>
<div class="section-card" class:expanded>
<!-- Header row - always visible -->
<button class="section-header" on:click={() => dispatch('toggle')}>
<div class="reorder-btns">
<button
class="move-btn"
disabled={index === 0}
on:click|stopPropagation={() => dispatch('move', -1)}
title="Move up">▲</button
>
<button
class="move-btn"
disabled={index === total - 1}
on:click|stopPropagation={() => dispatch('move', 1)}
title="Move down">▼</button
>
</div>
<span class="section-index">{index + 1}</span>
<span class="section-icon">
<Icon name={sectionIcon} size={16} />
</span>
<div class="section-info">
<div class="section-name">{section.CustomName || section.Name || '(unnamed)'}</div>
<div class="section-meta">
{typeLabel}
{#if genreNames} · {genreNames}{/if}
{#if section.CollectionType} · {section.CollectionType}{/if}
{#if section.SortBy} · {SORT_OPTIONS.find((s) => s.value === section.SortBy)?.label || section.SortBy}{/if}
{#if section.ExcludedFolders?.length} · {section.ExcludedFolders.length} excluded{/if}
</div>
</div>
<button
class="remove-btn"
on:click|stopPropagation={() => dispatch('remove')}
title="Remove section">×</button
>
<span class="expand-indicator">{expanded ? '▾' : '▸'}</span>
</button>
<!-- Expanded editor -->
{#if expanded}
<div class="section-editor">
<!-- Core fields -->
<div class="field-grid">
<label class="field span2">
<span class="field-label">Display name</span>
<input
type="text"
bind:value={section.CustomName}
on:input={() => {
section.Name = section.CustomName;
handleChange();
}}
placeholder="Section name"
/>
</label>
<label class="field">
<span class="field-label">Section type</span>
<select bind:value={section.SectionType} on:change={handleChange}>
{#each SECTION_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Image type</span>
<select bind:value={section.ImageType} on:change={handleChange}>
{#each IMAGE_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort by</span>
<select bind:value={section.SortBy} on:change={handleChange}>
{#each SORT_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort order</span>
<select bind:value={section.SortOrder} on:change={handleChange}>
<option value={undefined}>(none)</option>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</label>
{#if showFilters}
<label class="field">
<span class="field-label">Collection type</span>
<select bind:value={section.CollectionType} on:change={handleChange}>
{#each COLLECTION_TYPES as c}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</label>
{/if}
{#if section.SectionType === 'userviews'}
<label class="field">
<span class="field-label">View type</span>
<select bind:value={section.ViewType} on:change={handleChange}>
<option value={undefined}>(default)</option>
<option value="buttons">Buttons</option>
<option value="list">List</option>
</select>
</label>
{/if}
<label class="field">
<span class="field-label">Card size offset</span>
<select
value={section.CardSizeOffset ?? 0}
on:change={(e) => { section.CardSizeOffset = Number(e.target.value); handleChange(); }}
>
<option value={-2}>-2 (Smaller)</option>
<option value={-1}>-1 (Small)</option>
<option value={0}>0 (Default)</option>
<option value={1}>+1 (Large)</option>
<option value={2}>+2 (Larger)</option>
</select>
</label>
</div>
<!-- Checkboxes -->
<div class="field-grid checkboxes">
{#if section.SectionType === 'resume'}
<label class="field-inline">
<input
type="checkbox"
checked={section.IncludeNextUpInResume ?? true}
on:change={(e) => { section.IncludeNextUpInResume = e.target.checked; handleChange(); }}
/>
<span>Include Next Up in Resume</span>
</label>
{/if}
{#if showFilters}
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsFavorite || false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsFavorite = e.target.checked || undefined;
handleChange();
}}
/>
<span>Favourites only</span>
</label>
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsPlayed === false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsPlayed = e.target.checked ? false : undefined;
handleChange();
}}
/>
<span>Unplayed only</span>
</label>
{/if}
</div>
{#if showFilters}
<!-- Item types -->
<div class="field-group">
<span class="field-label">Item types</span>
<div class="chip-group">
{#each ITEM_TYPES as type}
<button
class="chip"
class:active={section.ItemTypes?.includes(type)}
on:click={() => toggleItemType(type)}
>
{type}
</button>
{/each}
</div>
</div>
<!-- Genres -->
<div class="field-group">
<span class="field-label">Genres</span>
<div class="chip-group">
{#each Object.entries(GENRES).sort((a, b) => a[1].localeCompare(b[1])) as [id, name]}
<button
class="chip"
class:active={section.Query?.GenreIds?.includes(id)}
on:click={() => toggleGenre(id)}
>
{name}
</button>
{/each}
</div>
</div>
<!-- Tag IDs -->
<div class="field-group">
<span class="field-label">Tag IDs <span class="hint-inline">(comma-separated Emby tag IDs)</span></span>
<input
type="text"
class="ids-input"
value={tagIdsStr}
on:change={handleTagIdsChange}
placeholder="e.g. 1317339, 1336097"
/>
{#if section.Query?.TagIds?.length}
<div class="ids-preview">
{#each section.Query.TagIds as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.Query.TagIds = section.Query.TagIds.filter(t => t !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Boxset parent -->
{#if section.SectionType === 'boxset'}
<div class="field-group">
<span class="field-label">Linked box set</span>
<div class="lookup-row">
<input
type="text"
class="ids-input"
bind:value={collectionSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchCollections()}
placeholder="Search existing collections..."
/>
<button class="chip lookup-btn" on:click={searchCollections} disabled={collectionLookupBusy}>
{collectionLookupBusy ? 'Searching…' : 'Lookup'}
</button>
</div>
{#if collectionLookupError}
<div class="inline-status error">{collectionLookupError}</div>
{/if}
{#if collectionResults.length}
<div class="lookup-results">
{#each collectionResults as item}
<button class="resolved-item lookup-result" on:click={() => applyCollection(item)}>
<span class="resolved-name">{item.name}</span>
<span class="resolved-meta">{item.id}</span>
</button>
{/each}
</div>
{/if}
<div class="field-grid">
<label class="field">
<span class="field-label">Box set name</span>
<input
type="text"
value={section.ParentItem?.Name || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = e.target.value;
section.Name = e.target.value;
handleChange();
}}
placeholder="Display name"
/>
</label>
<label class="field">
<span class="field-label">Box set ID</span>
<input
type="text"
value={section.ParentItem?.Id || section.ParentId || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Id = e.target.value;
section.ParentId = e.target.value;
handleChange();
}}
placeholder="Emby item ID"
/>
</label>
</div>
{#if section.ParentItem?.Id}
<div class="boxset-info">
<span>{section.ParentItem.Name}</span>
<span class="text-muted">ID: {section.ParentItem.Id}</span>
</div>
{/if}
</div>
{/if}
<!-- Excluded folders -->
<div class="field-group">
<span class="field-label">
Excluded folders
{#if section.ExcludedFolders?.length}
<span class="badge">{section.ExcludedFolders.length}</span>
{/if}
<span class="hint-inline">(comma-separated folder IDs)</span>
</span>
<input
type="text"
class="ids-input"
value={excludedFoldersStr}
on:change={handleExcludedFoldersChange}
placeholder="e.g. 4309, 39975, 462878"
/>
{#if section.ExcludedFolders?.length}
<div class="ids-preview">
{#each section.ExcludedFolders as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.ExcludedFolders = section.ExcludedFolders.filter(f => f !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
{#if excludedFolderDetails.some((folder) => folder.label)}
<div class="resolved-list">
{#each excludedFolderDetails.filter((folder) => folder.label) as folder}
<div class="resolved-item">
<span class="resolved-name">{folder.label}</span>
<span class="resolved-meta">{folder.id}{folder.type ? ` · ${folder.type}` : ''}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.section-card {
background: #101318;
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.15s ease, background 0.15s ease;
}
.section-card:hover {
background: #12161d;
}
.section-card.expanded {
border-color: var(--border-strong);
}
.section-header {
all: unset;
display: flex;
align-items: center;
padding: 14px 16px;
gap: 10px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.section-header:hover {
background: var(--surface-hover);
}
.reorder-btns {
display: flex;
flex-direction: column;
gap: 1px;
}
.move-btn {
all: unset;
cursor: pointer;
font-size: 10px;
line-height: 1;
opacity: 0.7;
padding: 3px 4px;
color: var(--text-muted);
}
.move-btn:disabled {
opacity: 0.15;
cursor: default;
}
.move-btn:not(:disabled):hover {
opacity: 1;
}
.section-index {
font-size: 11px;
color: var(--text-muted);
min-width: 24px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.section-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(40, 193, 220, 0.1);
border: 1px solid var(--border);
color: var(--accent);
flex-shrink: 0;
}
.section-info {
flex: 1;
min-width: 0;
}
.section-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.section-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 20px;
line-height: 1;
padding: 0 4px;
opacity: 0.55;
}
.remove-btn:hover {
opacity: 1;
}
.expand-indicator {
font-size: 12px;
color: var(--text-muted);
min-width: 14px;
}
/* Editor panel */
.section-editor {
padding: 10px 16px 18px;
border-top: 1px solid var(--border);
background: #0d1015;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 14px;
}
.field-grid.checkboxes {
grid-template-columns: auto auto auto;
justify-content: start;
gap: 16px;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hint-inline {
font-size: 10px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.7;
}
.field input[type='text'],
.field select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--border-strong);
}
.field-group {
margin-bottom: 12px;
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.lookup-btn {
padding: 10px 12px;
border-radius: 10px;
text-align: center;
color: var(--text);
background: #15181e;
}
.lookup-btn:disabled {
opacity: 0.45;
cursor: default;
}
.lookup-results {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.lookup-result {
all: unset;
cursor: pointer;
}
.inline-status {
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
font-size: 12px;
color: var(--text-muted);
}
.inline-status.error {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.24);
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.chip {
all: unset;
cursor: pointer;
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
background: #12151b;
transition: all 0.12s;
}
.chip:hover {
border-color: var(--accent);
color: var(--text);
}
.chip.active {
background: var(--accent);
border-color: rgba(42, 215, 239, 0.3);
color: #031014;
}
.field-inline {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
/* IDs input (excluded folders, tag IDs) */
.ids-input {
width: 100%;
margin-top: 6px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 12px;
font-family: monospace;
box-sizing: border-box;
}
.ids-input:focus {
outline: none;
border-color: var(--border-strong);
}
.ids-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.id-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: #12151b;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 8px 4px 10px;
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
}
.id-remove {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 14px;
line-height: 1;
opacity: 0.6;
}
.id-remove:hover {
opacity: 1;
}
.badge {
display: inline-block;
background: var(--accent);
color: #031014;
border-radius: 999px;
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
text-transform: none;
letter-spacing: 0;
}
.boxset-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text);
margin-top: 6px;
padding: 10px 12px;
background: #12151b;
border-radius: 10px;
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
.resolved-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.resolved-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
background: #12151b;
border: 1px solid var(--border);
}
.lookup-result:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.resolved-name {
color: var(--text);
font-size: 12px;
font-weight: 600;
}
.resolved-meta {
color: var(--text-muted);
font-size: 11px;
font-family: monospace;
text-align: right;
}
@media (max-width: 640px) {
.lookup-row {
grid-template-columns: 1fr;
}
}
</style>
+335
View File
@@ -0,0 +1,335 @@
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
let localConfig = { ...config };
let status = '';
let statusType = ''; // 'ok' | 'error' | 'info'
let busy = false;
function setStatus(msg, type = 'info') {
status = msg;
statusType = type;
}
async function fetchEmbyUsers() {
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const payload = await res.json();
return Array.isArray(payload)
? { users: payload, source: 'live', lastSyncedAt: null, message: '' }
: payload;
}
async function saveConfig() {
busy = true;
setStatus('Saving…');
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localConfig)
});
if (!res.ok) throw new Error(await res.text());
config = { ...localConfig };
dispatch('configSaved', { ...localConfig });
setStatus('Settings saved.', 'ok');
} catch (e) {
setStatus(`Save failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function testConnection() {
if (!localConfig.embyUrl || !localConfig.apiKey) {
setStatus('Enter Emby URL and API key first.', 'error');
return;
}
busy = true;
setStatus('Connecting to Emby…');
try {
// Save config first so the server endpoint can read it
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localConfig)
});
const payload = await fetchEmbyUsers();
if (payload.source === 'cache') {
setStatus(
`${payload.message} Loaded ${payload.users.length} cached users from ${payload.lastSyncedAt || 'the last successful sync'}.`,
'ok'
);
} else {
setStatus(`Connected — ${payload.users.length} users found and cached locally.`, 'ok');
}
} catch (e) {
setStatus(`Connection failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function refreshNames() {
busy = true;
setStatus('Fetching user names from Emby…');
try {
const payload = await fetchEmbyUsers();
dispatch('namesRefreshed', payload);
if (payload.source === 'cache') {
setStatus(
`${payload.message} Refreshed ${payload.users.length} users from the local cache.`,
'ok'
);
} else {
setStatus(`Names refreshed — ${payload.users.length} users from Emby and saved locally.`, 'ok');
}
} catch (e) {
setStatus(`Failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function loadFromDb() {
if (!localConfig.dbPath) {
setStatus('Enter the DB path first.', 'error');
return;
}
busy = true;
setStatus('Reading database…');
try {
const res = await fetch('/api/db-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dbPath: localConfig.dbPath })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const { users, validation } = await res.json();
dispatch('usersLoaded', { users, validation });
if (validation?.mismatchedUsers > 0 || validation?.missingSectionUserIds > 0) {
setStatus(
`Loaded ${users.length} users from ${validation.userSource}. ` +
`${validation.mismatchedUsers} user(s) had mismatched section UserIds and ` +
`${validation.normalizedUsers} user(s) were normalized on load.`,
'ok'
);
} else {
setStatus(
`Loaded ${users.length} users from ${validation?.userSource || 'database'}. ` +
'UserSettings IDs and section UserIds match.',
'ok'
);
}
} catch (e) {
setStatus(`Failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
</script>
<div class="panel">
<h3>Settings</h3>
<section>
<h4>Emby connection</h4>
<label>
<span>Server URL</span>
<input type="text" bind:value={localConfig.embyUrl} placeholder="http://localhost:8096" />
</label>
<label>
<span>API key</span>
<input type="password" bind:value={localConfig.apiKey} placeholder="Paste your API key" />
</label>
<p class="hint">
Get your API key from Emby: Dashboard → Advanced → API Keys → New API Key.
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save</button>
<button class="btn ghost" on:click={testConnection} disabled={busy}>Test connection</button>
<button class="btn ghost" on:click={refreshNames} disabled={busy || !config.apiKey}>
Refresh user names
</button>
</div>
</section>
<section>
<h4>Recommendations</h4>
<label>
<span>TMDB API key</span>
<input
type="password"
bind:value={localConfig.tmdbApiKey}
placeholder="Paste your TMDB v3 API key"
/>
</label>
<p class="hint">
Stored locally in the app config so recommendation features can reuse it without re-entering
the key each time.
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save TMDB key</button>
</div>
</section>
<section>
<h4>Database file</h4>
<label>
<span>Path to users.db</span>
<input
type="text"
bind:value={localConfig.dbPath}
placeholder="C:\ProgramData\Emby-Server\data\users.db"
/>
</label>
<p class="hint">
Stop Emby before loading or writing to avoid corruption. Typical path:
<code>C:\ProgramData\Emby-Server\data\users.db</code>
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save path</button>
<button class="btn accent" on:click={loadFromDb} disabled={busy || !localConfig.dbPath}>
Load from DB
</button>
</div>
</section>
{#if status}
<div class="status" class:ok={statusType === 'ok'} class:error={statusType === 'error'}>
{status}
</div>
{/if}
</div>
<style>
.panel {
padding: 0;
}
h3 {
font-size: 16px;
font-weight: 800;
margin: 0 0 16px;
color: var(--text);
}
h4 {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
margin: 0 0 10px;
}
section {
margin-bottom: 18px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: #111419;
}
section:last-of-type {
margin-bottom: 12px;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
label span {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
input {
background: #12151b;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
font-size: 13px;
color: var(--text);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--border-strong);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin: 0 0 10px;
line-height: 1.5;
}
code {
font-size: 11px;
background: #0c0e12;
padding: 2px 5px;
border-radius: 6px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: all 0.12s;
}
.btn.ghost {
color: var(--text);
border: 1px solid var(--border);
background: #15181e;
}
.btn.ghost:hover:not(:disabled) {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.btn.accent {
background: var(--accent);
color: #031014;
border: 1px solid rgba(42, 215, 239, 0.35);
}
.btn.accent:hover:not(:disabled) {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.4;
cursor: default;
}
.status {
margin-top: 8px;
padding: 10px 12px;
border-radius: 10px;
font-size: 12px;
background: #111419;
color: var(--text-muted);
border: 1px solid var(--border);
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
</style>
+144
View File
@@ -0,0 +1,144 @@
<script>
import { createEventDispatcher } from 'svelte';
export let sql = '';
const dispatch = createEventDispatcher();
let copied = false;
function copyToClipboard() {
navigator.clipboard.writeText(sql).then(() => {
copied = true;
setTimeout(() => (copied = false), 2000);
});
}
function download() {
const blob = new Blob([sql], { type: 'text/sql' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `emby-homescreen-update-${new Date().toISOString().slice(0, 10)}.sql`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div
class="overlay"
role="button"
tabindex="0"
aria-label="Close generated SQL modal"
on:click|self={() => dispatch('close')}
on:keydown={(event) => (event.key === 'Escape' || event.key === 'Enter') && dispatch('close')}
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Generated SQL">
<div class="modal-header">
<h3>Generated SQL</h3>
<button class="close-btn" on:click={() => dispatch('close')}>×</button>
</div>
<div class="modal-body">
<pre class="sql-output">{sql}</pre>
</div>
<div class="modal-actions">
<button class="action-btn secondary" on:click={copyToClipboard}>
{copied ? '✓ Copied' : 'Copy to clipboard'}
</button>
<button class="action-btn primary" on:click={download}> Download .sql file </button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: #0f1116;
border: 1px solid var(--border);
border-radius: 14px;
width: 100%;
max-width: 720px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 16px;
color: var(--text);
}
.close-btn {
all: unset;
cursor: pointer;
font-size: 22px;
color: var(--text-muted);
line-height: 1;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.sql-output {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
background: #12151b;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
}
.modal-actions {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--border);
justify-content: flex-end;
}
.action-btn {
all: unset;
cursor: pointer;
padding: 8px 18px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
}
.action-btn.secondary {
border: 1px solid var(--border);
color: var(--text);
background: #15181e;
}
.action-btn.secondary:hover {
background: var(--surface-hover);
}
.action-btn.primary {
background: var(--accent);
border: 1px solid rgba(42, 215, 239, 0.35);
color: #031014;
}
.action-btn.primary:hover {
background: #35d2ea;
}
</style>
+331
View File
@@ -0,0 +1,331 @@
<script>
import { createEventDispatcher } from 'svelte';
import { getSectionTypeLabel, getGenreNames } from '$lib/constants.js';
export let users = [];
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let selectedSectionIndices = [];
let syncMode = 'replace'; // 'replace' = overwrite all, 'append' = add to end, 'selected' = only selected sections
$: sourceUser = users.find((u) => u.id === sourceUserId);
$: sourceSections = sourceUser?.sections || [];
$: availableTargets = users.filter((u) => u.id !== sourceUserId && u.sections?.length >= 0);
function toggleTarget(id) {
if (targetUserIds.includes(id)) {
targetUserIds = targetUserIds.filter((t) => t !== id);
} else {
targetUserIds = [...targetUserIds, id];
}
}
function selectAllTargets() {
targetUserIds = availableTargets.map((u) => u.id);
}
function deselectAllTargets() {
targetUserIds = [];
}
function toggleSectionIndex(idx) {
if (selectedSectionIndices.includes(idx)) {
selectedSectionIndices = selectedSectionIndices.filter((i) => i !== idx);
} else {
selectedSectionIndices = [...selectedSectionIndices, idx];
}
}
function doSync() {
if (!sourceUser || targetUserIds.length === 0) return;
let sectionsToSync;
if (syncMode === 'selected') {
sectionsToSync = selectedSectionIndices.map((i) => sourceSections[i]).filter(Boolean);
} else {
sectionsToSync = [...sourceSections];
}
dispatch('sync', {
sourceUserId,
targetUserIds,
sections: sectionsToSync,
mode: syncMode
});
}
</script>
<div class="sync-panel">
<h3>Sync sections between users</h3>
<div class="sync-step">
<span class="step-label">1. Source user</span>
<select bind:value={sourceUserId} class="select-input">
<option value={null}>Select source user...</option>
{#each users.filter((u) => u.sections?.length > 0) as user}
<option value={user.id}>{user.name} ({user.sections.length} sections)</option>
{/each}
</select>
</div>
{#if sourceUser}
<div class="sync-step">
<span class="step-label">2. Sync mode</span>
<div class="mode-group">
<label class="mode-option" class:active={syncMode === 'replace'}>
<input type="radio" bind:group={syncMode} value="replace" />
<div>
<strong>Replace all</strong>
<span class="mode-desc">Overwrite target's sections entirely</span>
</div>
</label>
<label class="mode-option" class:active={syncMode === 'append'}>
<input type="radio" bind:group={syncMode} value="append" />
<div>
<strong>Append all</strong>
<span class="mode-desc">Add source sections to end of target</span>
</div>
</label>
<label class="mode-option" class:active={syncMode === 'selected'}>
<input type="radio" bind:group={syncMode} value="selected" />
<div>
<strong>Selected only</strong>
<span class="mode-desc">Pick specific sections to sync</span>
</div>
</label>
</div>
</div>
{#if syncMode === 'selected'}
<div class="sync-step">
<span class="step-label">Sections to sync</span>
<div class="section-pick-list">
{#each sourceSections as section, idx}
<label class="section-pick" class:selected={selectedSectionIndices.includes(idx)}>
<input
type="checkbox"
checked={selectedSectionIndices.includes(idx)}
on:change={() => toggleSectionIndex(idx)}
/>
<span class="pick-name">{section.CustomName || section.Name || '(unnamed)'}</span>
<span class="pick-type">{getSectionTypeLabel(section.SectionType)}</span>
</label>
{/each}
</div>
</div>
{/if}
<div class="sync-step">
<div class="step-label-row">
<span class="step-label">3. Target users</span>
<div class="select-btns">
<button class="link-btn" on:click={selectAllTargets}>All</button>
<button class="link-btn" on:click={deselectAllTargets}>None</button>
</div>
</div>
<div class="target-list">
{#each availableTargets as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTarget(user.id)}
/>
<span class="target-name">{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
</div>
<button
class="sync-btn"
disabled={targetUserIds.length === 0 ||
(syncMode === 'selected' && selectedSectionIndices.length === 0)}
on:click={doSync}
>
Sync {syncMode === 'selected' ? `${selectedSectionIndices.length} section(s)` : 'all sections'}
to {targetUserIds.length} user{targetUserIds.length !== 1 ? 's' : ''}
</button>
{/if}
</div>
<style>
.sync-panel {
padding: 0;
}
h3 {
font-size: 16px;
font-weight: 800;
margin: 0 0 18px;
color: var(--text);
}
.sync-step {
margin-bottom: 16px;
}
.step-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
display: block;
margin-bottom: 6px;
}
.step-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.select-input {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #12151b;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.mode-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.mode-option {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: #111419;
cursor: pointer;
transition: all 0.12s;
}
.mode-option.active {
border-color: var(--border-strong);
background: var(--surface-active);
}
.mode-option input {
margin-top: 2px;
}
.mode-option strong {
font-size: 13px;
color: var(--text);
display: block;
}
.mode-desc {
font-size: 12px;
color: var(--text-muted);
}
.section-pick-list {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 200px;
overflow-y: auto;
}
.section-pick {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
font-size: 13px;
}
.section-pick:hover {
background: var(--surface-hover);
}
.section-pick.selected {
background: var(--surface-active);
border: 1px solid var(--border-strong);
}
.pick-name {
flex: 1;
color: var(--text);
}
.pick-type {
font-size: 11px;
color: var(--text-muted);
}
.target-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.target-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid transparent;
cursor: pointer;
font-size: 13px;
}
.target-option:hover {
background: var(--surface-hover);
}
.target-option.selected {
background: var(--surface-active);
border: 1px solid var(--border-strong);
}
.target-name {
flex: 1;
color: var(--text);
}
.target-count {
font-size: 11px;
color: var(--text-muted);
}
.link-btn {
all: unset;
cursor: pointer;
font-size: 12px;
color: var(--accent);
}
.link-btn:hover {
text-decoration: underline;
}
.sync-btn {
all: unset;
cursor: pointer;
display: block;
width: 100%;
text-align: center;
padding: 12px;
border-radius: 10px;
background: var(--accent);
border: 1px solid rgba(42, 215, 239, 0.35);
color: #031014;
font-size: 14px;
font-weight: 600;
font-family: inherit;
box-sizing: border-box;
transition: opacity 0.12s, background 0.12s ease;
}
.sync-btn:hover:not(:disabled) {
background: #35d2ea;
}
.sync-btn:disabled {
opacity: 0.4;
cursor: default;
}
.select-btns {
display: flex;
gap: 10px;
}
</style>
+386
View File
@@ -0,0 +1,386 @@
export function normalizeLookupItem(item) {
const derivedYear = Number(
String(
item?.year ??
item?.ProductionYear ??
item?.release_date ??
item?.first_air_date ??
''
).slice(0, 4)
) || null;
const providerIds =
item?.providerIds ||
item?.ProviderIds ||
(item?.tmdbId ? { Tmdb: String(item.tmdbId) } : {});
return {
id: String(item?.id ?? item?.Id ?? ''),
name: item?.name || item?.Name || item?.SeriesName || item?.title || 'Unnamed item',
type:
item?.type ||
item?.Type ||
item?.CollectionType ||
(item?.mediaType === 'tv' ? 'Series' : item?.mediaType === 'movie' ? 'Movie' : 'Item'),
overview: item?.overview || item?.Overview || '',
year: derivedYear,
communityRating: item?.communityRating ?? item?.CommunityRating ?? item?.voteAverage ?? item?.vote_average ?? null,
providerIds,
genres: Array.isArray(item?.genres)
? item.genres.filter(Boolean)
: Array.isArray(item?.Genres)
? item.Genres.filter(Boolean)
: Array.isArray(item?.GenreItems)
? item.GenreItems.map((genre) => genre?.Name).filter(Boolean)
: [],
parentId: item?.parentId ?? item?.ParentId ?? null
};
}
const POSITIVE_GENRES = {
Romance: 18,
Comedy: 16,
Drama: 10,
Music: 4
};
const NEGATIVE_GENRES = {
Animation: -30,
Horror: -25,
'Science Fiction': -20,
Action: -16,
Thriller: -14,
Crime: -10,
War: -18,
Western: -14,
Documentary: -18,
Fantasy: -8,
Family: -6
};
const POSITIVE_TERMS = [
'wedding',
'love',
'romance',
'relationship',
'bride',
'best friend',
'friendship',
'family',
'holiday',
'food',
'small town',
'bookstore',
'restaurant',
'writer',
'divorce',
'second chance',
'starting over',
'feel-good',
'mother',
'daughter',
'sisters',
'chosen family',
'comedy of manners'
];
const NEGATIVE_TERMS = [
'war',
'serial killer',
'murder spree',
'mercenary',
'zombie',
'post-apocalyptic',
'gang',
'assassin',
'combat',
'superhero',
'multiverse',
'alien invasion',
'dystopian',
'dragon',
'animated adventure'
];
const SEED_BONUS_TERMS = [
'ensemble',
'relationship',
'family',
'comedy',
'romantic',
'wedding',
'friendship',
'identity',
'midlife',
'second chance'
];
export const RECOMMENDATION_PROFILES = {
balanced: {
id: 'balanced',
label: 'Balanced',
description: 'Default Emby-style recommendation overlap ranking.'
},
classicComfort: {
id: 'classicComfort',
label: 'Classic Comfort',
description: 'Bias toward older, warm, highly-rated movies and shows inspired by test.py.'
}
};
function normalizeLimit(limit) {
return Math.min(Math.max(Number(limit || 24), 1), 48);
}
function resolveRankingOptions(limitOrOptions) {
if (typeof limitOrOptions === 'number' || limitOrOptions === undefined) {
return {
limit: normalizeLimit(limitOrOptions),
profile: 'balanced',
seeds: [],
excludeIds: [],
allowedTypes: []
};
}
return {
limit: normalizeLimit(limitOrOptions?.limit),
profile: RECOMMENDATION_PROFILES[limitOrOptions?.profile] ? limitOrOptions.profile : 'balanced',
seeds: Array.isArray(limitOrOptions?.seeds) ? limitOrOptions.seeds.map(normalizeLookupItem) : [],
excludeIds: Array.isArray(limitOrOptions?.excludeIds)
? limitOrOptions.excludeIds.map((id) => String(id).trim()).filter(Boolean)
: [],
allowedTypes: Array.isArray(limitOrOptions?.allowedTypes)
? limitOrOptions.allowedTypes.map((type) => String(type).trim()).filter(Boolean)
: []
};
}
function genreBiasScore(genreNames, overview) {
let score = 0;
for (const genre of genreNames || []) {
score += POSITIVE_GENRES[genre] || 0;
score += NEGATIVE_GENRES[genre] || 0;
}
const text = String(overview || '').toLowerCase();
for (const term of POSITIVE_TERMS) {
if (text.includes(term)) score += 2;
}
for (const term of NEGATIVE_TERMS) {
if (text.includes(term)) score -= 2;
}
return score;
}
function classicBonus(year, seedHits, overview, seedGenres) {
let bonus = 0;
if (year !== null && year !== undefined) {
if (year >= 1985 && year <= 2008) bonus += 10;
else if (year >= 2009 && year <= 2012) bonus += 4;
else if (year < 1985) bonus += 2;
else bonus -= 8;
}
bonus += Math.min(seedHits * 5, 20);
const text = String(overview || '').toLowerCase();
for (const term of SEED_BONUS_TERMS) {
if (text.includes(term)) bonus += 1;
}
for (const genre of seedGenres) {
if ((overview || '').toLowerCase().includes(genre.toLowerCase())) {
bonus += 1;
}
}
return bonus;
}
function buildSeedContext(seeds) {
const genreHits = new Set();
const mediaTypes = new Set();
for (const seed of seeds || []) {
for (const genre of seed.genres || []) {
genreHits.add(genre);
}
if (seed.type) mediaTypes.add(seed.type);
}
return { genres: genreHits, mediaTypes };
}
function scoreSeedAffinity(item, seedContext) {
const genres = new Set(item.genres || []);
const text = `${item.name || ''} ${item.overview || ''}`.toLowerCase();
let score = 0;
for (const genre of seedContext.genres) {
if (genres.has(genre)) score += 8;
}
const warmSeed =
seedContext.genres.has('Romance') ||
seedContext.genres.has('Drama') ||
seedContext.genres.has('Comedy') ||
seedContext.genres.has('Family');
if (warmSeed) {
if (genres.has('Romance')) score += 12;
if (genres.has('Drama')) score += 10;
if (genres.has('Comedy')) score += 8;
if (genres.has('Family')) score += 5;
if (genres.has('Horror')) score -= 28;
if (genres.has('Science Fiction')) score -= 18;
if (genres.has('Action')) score -= 16;
if (genres.has('Thriller')) score -= 14;
if (genres.has('War')) score -= 10;
if (genres.has('Crime')) score -= 8;
for (const term of POSITIVE_TERMS) {
if (text.includes(term)) score += 1;
}
for (const term of NEGATIVE_TERMS) {
if (text.includes(term)) score -= 2;
}
}
return score;
}
function normalizeResultSet(resultSet) {
if (Array.isArray(resultSet)) {
return {
items: resultSet,
sourceWeight: 1
};
}
return {
items: Array.isArray(resultSet?.items) ? resultSet.items : [],
sourceWeight: Math.max(1, Number(resultSet?.sourceWeight || 1))
};
}
function evaluateClassicComfort(item, matches, seedContext) {
const genres = item.genres || [];
const year = Number.isFinite(Number(item.year)) ? Number(item.year) : null;
const rating = Number.isFinite(Number(item.communityRating)) ? Number(item.communityRating) : null;
const hasAnimation = genres.includes('Animation');
if (hasAnimation) return { keep: false };
if (year !== null && (year < 1980 || year > 2012)) return { keep: false };
if (rating !== null && rating < 6.3) return { keep: false };
const bias = genreBiasScore(genres, item.overview);
const bonus = classicBonus(year, matches, `${item.name} ${item.overview}`, seedContext.genres);
const styleScore = bias + bonus;
if (styleScore < 16) {
return { keep: false };
}
return {
keep: true,
styleScore,
qualityScore: rating !== null ? Math.round(rating * 10) : null
};
}
export function rankRecommendationResults(seedIds, resultSets, limitOrOptions = 24) {
const options = resolveRankingOptions(limitOrOptions);
const excluded = new Set([
...(seedIds || []).map((id) => String(id).trim()),
...options.excludeIds
].filter(Boolean));
const allowedTypes = new Set(options.allowedTypes);
const scored = new Map();
for (const resultSet of resultSets || []) {
const normalizedSet = normalizeResultSet(resultSet);
const seenInSet = new Set();
const items = normalizedSet.items;
for (let index = 0; index < items.length; index += 1) {
const item = normalizeLookupItem(items[index]);
if (!item.id || excluded.has(item.id)) continue;
if (allowedTypes.size > 0 && !allowedTypes.has(item.type)) continue;
if (seenInSet.has(item.id)) continue;
seenInSet.add(item.id);
const weight = Math.max(1, items.length - index) * normalizedSet.sourceWeight;
const current = scored.get(item.id) || {
item,
score: 0,
matches: 0,
sourceStrength: 0,
bestRank: Number.POSITIVE_INFINITY
};
current.item = item;
current.score += weight;
current.matches += 1;
current.sourceStrength += normalizedSet.sourceWeight;
current.bestRank = Math.min(current.bestRank, index);
scored.set(item.id, current);
}
}
const seedContext = buildSeedContext(options.seeds);
return [...scored.values()]
.map((entry) => {
if (options.profile !== 'classicComfort') {
const affinityScore = scoreSeedAffinity(entry.item, seedContext);
return {
...entry,
affinityScore,
totalScore:
(entry.sourceStrength * 20) +
entry.score +
affinityScore,
styleScore: null,
qualityScore: entry.item.communityRating !== null
? Math.round(Number(entry.item.communityRating) * 10)
: null
};
}
const style = evaluateClassicComfort(entry.item, entry.matches, seedContext);
if (!style.keep) return null;
return {
...entry,
styleScore: style.styleScore,
qualityScore: style.qualityScore,
totalScore:
(entry.sourceStrength * 20) +
entry.score +
(style.styleScore * 2) +
(style.qualityScore || 0)
};
})
.filter(Boolean)
.sort((a, b) => {
if (b.totalScore !== a.totalScore) return b.totalScore - a.totalScore;
if ((b.sourceStrength || 0) !== (a.sourceStrength || 0)) return (b.sourceStrength || 0) - (a.sourceStrength || 0);
if (b.matches !== a.matches) return b.matches - a.matches;
if ((b.styleScore || 0) !== (a.styleScore || 0)) return (b.styleScore || 0) - (a.styleScore || 0);
if ((b.qualityScore || 0) !== (a.qualityScore || 0)) return (b.qualityScore || 0) - (a.qualityScore || 0);
if (b.score !== a.score) return b.score - a.score;
if (a.bestRank !== b.bestRank) return a.bestRank - b.bestRank;
return a.item.name.localeCompare(b.item.name);
})
.slice(0, options.limit)
.map((entry) => ({
...entry.item,
matchCount: entry.matches,
score: entry.score,
totalScore: entry.totalScore,
sourceStrength: entry.sourceStrength,
affinityScore: entry.affinityScore ?? null,
styleScore: entry.styleScore,
qualityScore: entry.qualityScore
}));
}
+388
View File
@@ -0,0 +1,388 @@
export const GENRES = {
'2042': 'Action',
'1212': 'Sci-Fi',
'4910': 'Crime',
'62': 'Drama',
'82': 'Comedy',
'293': 'Animation',
'36': 'Documentary',
'5024': 'Horror',
'4835': 'Romance',
'4428': 'Thriller',
'5709': 'War',
'14124': 'Western',
'218': 'Food',
'396654': 'Reality',
'19618': 'Travel',
'17565': 'Mini Series',
'2008': 'Mystery',
'235': 'Family',
'480': 'Fantasy'
};
export const SECTION_TYPES = [
{ value: 'resume', label: 'Resume / Next Up' },
{ value: 'items', label: 'Items (filtered)' },
{ value: 'userviews', label: 'Libraries' },
{ value: 'boxset', label: 'Box Set' },
{ value: 'collections', label: 'Collections' },
{ value: 'latestepisodereleases', label: 'Latest episode releases' },
{ value: 'latestmoviereleases', label: 'Latest movie releases' },
{ value: 'latestmediablock', label: 'Latest media' }
];
export const COLLECTION_TYPES = [
{ value: '', label: '(none)' },
{ value: 'movies', label: 'Movies' },
{ value: 'tvshows', label: 'TV Shows' },
{ value: 'boxsets', label: 'Box Sets' }
];
export const ITEM_TYPES = ['Movie', 'Series', 'Episode', 'BoxSet'];
export const SORT_OPTIONS = [
{ value: '', label: '(none)' },
{ value: 'default', label: 'Default (boxset)' },
{ value: 'DatePlayed', label: 'Date played' },
{ value: 'DateLastContentAdded,SortName', label: 'Date added' },
{ value: 'ProductionYear,PremiereDate,SortName', label: 'Release year' },
{ value: 'CommunityRating', label: 'Community rating' },
{ value: 'CriticRating,SortName', label: 'Critic rating' },
{ value: 'DateCreated,SortName', label: 'Date created' },
{ value: 'Random', label: 'Random' },
{ value: 'SortName', label: 'Name' }
];
export const IMAGE_TYPES = [
{ value: '', label: 'Default' },
{ value: 'Thumb', label: 'Thumb' },
{ value: 'Primary', label: 'Primary / Poster' }
];
export const PAGE_ICONS = {
edit: 'edit',
sync: 'sync',
collections: 'collections',
settings: 'settings'
};
export const SECTION_ICONS = {
resume: 'resume',
items: 'items',
userviews: 'userviews',
boxset: 'boxset',
collections: 'collections',
latestepisodereleases: 'latestepisodereleases',
latestmoviereleases: 'latestmoviereleases',
latestmediablock: 'latestmediablock'
};
export function genId() {
return crypto.randomUUID().replace(/-/g, '').slice(0, 32);
}
export function createEmptySection(userId) {
return {
UserId: userId,
Name: 'New Section',
CustomName: 'New Section',
Id: genId(),
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
}
};
}
export function createRecentlyWatchedSection(userId, userName = '') {
return {
UserId: userId,
Name: `Recently Watched${userName ? ` - ${userName}` : ''}`,
CustomName: `Recently Watched${userName ? ` - ${userName}` : ''}`,
Id: genId(),
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
}
};
}
export function createBoxSetSection(userId, collectionName, collectionId) {
return {
UserId: userId,
Name: collectionName || 'New Collection',
CustomName: collectionName || 'New Collection',
Id: genId(),
SectionType: 'boxset',
ImageType: 'Thumb',
ItemTypes: [],
SortBy: 'Random',
SortOrder: 'Descending',
Monitor: [],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
ParentItem: {
Name: collectionName || 'New Collection',
Id: String(collectionId || '')
},
ParentId: String(collectionId || '')
};
}
export function getSectionTypeLabel(type) {
const found = SECTION_TYPES.find((t) => t.value === type);
return found ? found.label : type;
}
export function getSectionIconName(type) {
return SECTION_ICONS[type] || 'spark';
}
export function getGenreNames(genreIds) {
if (!genreIds || genreIds.length === 0) return '';
return genreIds.map((id) => GENRES[id] || id).join(', ');
}
export function extractUserName(sections) {
for (const s of sections) {
const name = s.CustomName || s.Name || '';
const extracted = extractWatchlistOwnerName(name);
if (extracted && extracted !== name) return extracted;
}
return null;
}
export function isWatchlistSection(section) {
if (!section) return false;
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
return !!section.Query?.IsFavorite || name.includes('watchlist') || name.includes('watch list');
}
export function isUpNextSection(section) {
if (!section) return false;
if (section.SectionType === 'resume') return true;
const name = `${section.CustomName || ''} ${section.Name || ''}`.trim().toLowerCase();
return name === 'up next' || name === 'next up' || name === 'resume / up next';
}
export function isNewToEmbySection(section) {
if (!section) return false;
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
return name.includes('new to emby');
}
export function isRecentlyWatchedSection(section) {
if (!section) return false;
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
return section.Query?.IsPlayed === true || name.includes('recently watched');
}
export function isFixedOrderSection(section) {
if (!section) return false;
return (
isUpNextSection(section) ||
isWatchlistSection(section) ||
isNewToEmbySection(section) ||
isRecentlyWatchedSection(section) ||
section.SectionType === 'latestepisodereleases' ||
section.SectionType === 'latestmoviereleases' ||
section.SectionType === 'latestmediablock' ||
section.SectionType === 'userviews'
);
}
function renameWatchlistLabel(label, targetName) {
if (!label || !targetName) return label;
if (/^\s*watch\s+list\s*$/i.test(label)) return 'Watch List';
if (/^\s*watchlist\s*$/i.test(label)) return 'Watchlist';
if (/watch\s+list/i.test(label)) {
return label.replace(/^.*?watch\s+list/i, `${targetName}'s Watch List`);
}
if (/watchlist/i.test(label)) {
return label.replace(/^.*?watchlist/i, `${targetName}'s Watchlist`);
}
return label;
}
function extractWatchlistOwnerName(label) {
if (!label) return '';
const normalized = String(label).trim();
if (!/watchlist|watch\s+list/i.test(normalized)) return '';
return normalized
.replace(/\bwatchlist\b/i, '')
.replace(/\bwatch\s+list\b/i, '')
.replace(/[']s$/i, '')
.trim();
}
function normalizeNameForCompare(name) {
return String(name || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function labelsMatchUser(label, targetName) {
const owner = extractWatchlistOwnerName(label);
if (!owner || !targetName) return false;
return normalizeNameForCompare(owner) === normalizeNameForCompare(targetName);
}
function getPreferredUserName(user) {
return user?.embyName || user?.name || '';
}
export function getWatchlistLabelsForTarget(sourceSection, targetUser) {
const existingTargetWatchlist = targetUser?.sections?.find((section) => isWatchlistSection(section));
const targetName = getPreferredUserName(targetUser);
if (existingTargetWatchlist) {
const existingName = existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || '';
if (!targetName || labelsMatchUser(existingName, targetName)) {
return {
Name: existingTargetWatchlist.Name || sourceSection.Name,
CustomName: existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name
};
}
return {
Name: renameWatchlistLabel(existingTargetWatchlist.Name || sourceSection.Name, targetName),
CustomName: renameWatchlistLabel(
existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name,
targetName
)
};
}
return {
Name: renameWatchlistLabel(sourceSection.Name, targetName),
CustomName: renameWatchlistLabel(sourceSection.CustomName, targetName)
};
}
export function applySectionStandards(sourceSection, targetUser) {
const section = JSON.parse(JSON.stringify(sourceSection || {}));
if (isUpNextSection(section)) {
section.Name = 'Up Next';
section.CustomName = 'Up Next';
return section;
}
if (isWatchlistSection(section)) {
const labels = getWatchlistLabelsForTarget(section, targetUser);
if (labels.Name) section.Name = labels.Name;
if (labels.CustomName) section.CustomName = labels.CustomName;
return section;
}
if (isNewToEmbySection(section)) {
section.Name = 'New to Emby';
section.CustomName = 'New to Emby';
section.SortBy = 'DateLastContentAdded,SortName';
section.SortOrder = 'Descending';
return section;
}
if (isRecentlyWatchedSection(section)) {
const targetName = getPreferredUserName(targetUser);
const label = `Recently Watched${targetName ? ` - ${targetName}` : ''}`;
section.Name = label;
section.CustomName = label;
section.SortBy = 'DatePlayed';
section.SortOrder = 'Descending';
return section;
}
if (!isFixedOrderSection(section) && ['items', 'collections', 'boxset'].includes(section.SectionType)) {
section.SortBy = 'Random';
section.SortOrder = 'Descending';
}
if (section.SectionType === 'userviews') {
section.Name = 'Libraries';
section.CustomName = 'Libraries';
}
return section;
}
/**
* Build SQL UPDATE statements from modified user data.
* Each user's entire homescreensettings JSON is replaced.
*/
export function generateSQL(users, originalUsers) {
const statements = [];
for (const user of users) {
if (!user.sections || user.sections.length === 0) continue;
const original = originalUsers.find((u) => u.id === user.id);
if (!original) continue;
const origJSON = JSON.stringify({ Sections: original.sections });
const newJSON = JSON.stringify({ Sections: user.sections });
if (origJSON === newJSON) continue;
const escapedValue = newJSON.replace(/'/g, "''");
statements.push(
`-- User: ${user.name} (DB ID: ${user.id})`,
`UPDATE UserSettings SET Value = '${escapedValue}' WHERE UserId = ${user.id} AND UserSettingsKeyId = (SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');`,
''
);
}
if (statements.length === 0) {
return '-- No changes detected';
}
return [
'-- ===========================================',
'-- Emby Home Screen Settings Update',
`-- Generated: ${new Date().toISOString()}`,
'-- ===========================================',
'-- IMPORTANT: Stop Emby before running this!',
'-- sqlite3 /path/to/users.db < this_file.sql',
'-- Then restart Emby.',
'-- ===========================================',
'',
'BEGIN TRANSACTION;',
'',
...statements,
'COMMIT;'
].join('\n');
}
+54
View File
@@ -0,0 +1,54 @@
function normalizeGenreKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
export function normalizeGenreNames(values) {
const seen = new Set();
const names = [];
for (const value of values || []) {
const trimmed = String(value || '').trim();
if (!trimmed) continue;
const key = normalizeGenreKey(trimmed);
if (!key || seen.has(key)) continue;
seen.add(key);
names.push(trimmed);
}
return names;
}
export function pickSuggestedGenre(tmdbGenres, currentGenres = []) {
const normalizedTmdbGenres = normalizeGenreNames(tmdbGenres);
if (!normalizedTmdbGenres.length) return '';
const currentKeys = new Set(normalizeGenreNames(currentGenres).map(normalizeGenreKey));
const matched = normalizedTmdbGenres.find((genre) => currentKeys.has(normalizeGenreKey(genre)));
return matched || normalizedTmdbGenres[0];
}
export function buildSingleGenreUpdate(item, genreName) {
const selectedGenre = String(genreName || '').trim();
if (!selectedGenre) {
throw new Error('A genre is required');
}
const nextItem = JSON.parse(JSON.stringify(item || {}));
const existingGenreItems = Array.isArray(item?.GenreItems) ? item.GenreItems : [];
const matchedGenreItem = existingGenreItems.find(
(entry) => normalizeGenreKey(entry?.Name) === normalizeGenreKey(selectedGenre)
);
nextItem.Genres = [selectedGenre];
nextItem.GenreItems = [
matchedGenreItem
? { ...matchedGenreItem, Name: selectedGenre }
: { Name: selectedGenre }
];
return nextItem;
}
+69
View File
@@ -0,0 +1,69 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
const CONFIG_PATH = resolve('config.json');
export function loadEmbyConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
export function normalizeEmbyGuid(value) {
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
}
export function buildEmbyUrl(pathname, params = {}) {
const { embyUrl, apiKey } = loadEmbyConfig();
if (!embyUrl || !apiKey) {
throw new Error('Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
const path = pathname.startsWith('/') ? pathname : `/${pathname}`;
const url = new URL(`${base}${path}`);
for (const [key, value] of Object.entries({
...params,
api_key: apiKey
})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
return { url, apiKey };
}
export async function fetchEmby(pathname, options = {}) {
const {
params = {},
method = 'GET',
headers = {},
body
} = options;
const { url, apiKey } = buildEmbyUrl(pathname, params);
const response = await fetch(url, {
method,
headers: {
Accept: 'application/json',
'X-Emby-Token': apiKey,
...headers
},
body
});
if (!response.ok) {
const text = await response.text().catch(() => response.statusText);
throw new Error(text || `${response.status} ${response.statusText}`);
}
return response;
}
export async function fetchEmbyJson(pathname, options = {}) {
const response = await fetchEmby(pathname, options);
return response.json();
}
@@ -0,0 +1,139 @@
import { existsSync, mkdirSync } from 'fs';
import { dirname, resolve } from 'path';
import { DatabaseSync } from 'node:sqlite';
function getCacheDbPath() {
return process.env.EMBY_USER_CACHE_DB_PATH || resolve('.cache', 'emby-users.db');
}
function ensureCacheDir() {
const dir = dirname(getCacheDbPath());
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function openCacheDb() {
ensureCacheDir();
const db = new DatabaseSync(getCacheDbPath());
db.exec(`
CREATE TABLE IF NOT EXISTS EmbyUsers (
embyGuid TEXT PRIMARY KEY,
name TEXT NOT NULL,
fetchedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS CacheMeta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
return db;
}
function normalizeGuid(value) {
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
}
export function normalizeEmbyUsers(users) {
if (!Array.isArray(users)) return [];
return users
.map((user) => ({
embyGuid: normalizeGuid(user?.embyGuid ?? user?.Id),
name: String(user?.name ?? user?.Name ?? '').trim()
}))
.filter((user) => user.embyGuid && user.name);
}
export function writeCachedEmbyUsers(users) {
const normalizedUsers = normalizeEmbyUsers(users);
const fetchedAt = new Date().toISOString();
const db = openCacheDb();
try {
const clearStmt = db.prepare('DELETE FROM EmbyUsers');
const insertStmt = db.prepare(
'INSERT INTO EmbyUsers (embyGuid, name, fetchedAt) VALUES (?, ?, ?)'
);
const metaStmt = db.prepare(
'INSERT INTO CacheMeta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
);
db.exec('BEGIN');
try {
clearStmt.run();
for (const user of normalizedUsers) {
insertStmt.run(user.embyGuid, user.name, fetchedAt);
}
metaStmt.run('lastSyncedAt', fetchedAt);
db.exec('COMMIT');
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
} finally {
db.close();
}
return { users: normalizedUsers, lastSyncedAt: fetchedAt };
}
export function readCachedEmbyUsers() {
if (!existsSync(getCacheDbPath())) {
return { users: [], lastSyncedAt: null };
}
const db = openCacheDb();
try {
const users = db
.prepare('SELECT embyGuid, name, fetchedAt FROM EmbyUsers ORDER BY lower(name), embyGuid')
.all()
.map((row) => ({
embyGuid: normalizeGuid(row.embyGuid),
name: row.name
}));
const meta = db.prepare("SELECT value FROM CacheMeta WHERE key = 'lastSyncedAt'").get();
return {
users,
lastSyncedAt: meta?.value || users[0]?.fetchedAt || null
};
} finally {
db.close();
}
}
export function getCachedEmbyUserMap() {
return new Map(readCachedEmbyUsers().users.map((user) => [user.embyGuid, user]));
}
export function applyCachedEmbyNames(users) {
const cached = readCachedEmbyUsers();
const lookup = new Map(cached.users.map((user) => [user.embyGuid, user]));
let matchedCount = 0;
const hydratedUsers = (users || []).map((user) => {
const embyGuid = normalizeGuid(user?.embyGuid);
const cachedUser = lookup.get(embyGuid);
if (!cachedUser) {
return user;
}
matchedCount++;
return {
...user,
dbName: user.dbName || user.name,
embyName: cachedUser.name,
name: cachedUser.name
};
});
return {
users: hydratedUsers,
cache: {
matchedCount,
totalCachedUsers: lookup.size,
lastSyncedAt: cached.lastSyncedAt
}
};
}
@@ -0,0 +1,60 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, resolve } from 'path';
const CACHE_PATH = resolve('.cache', 'emby-user-context.json');
function ensureCacheDir() {
const dir = dirname(CACHE_PATH);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function normalizeGuid(value) {
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
}
function loadCacheFile() {
if (!existsSync(CACHE_PATH)) {
return { users: {} };
}
try {
const parsed = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : { users: {} };
} catch {
return { users: {} };
}
}
function saveCacheFile(cache) {
ensureCacheDir();
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
}
export function readCachedEmbyUserContext(embyGuid) {
const normalizedGuid = normalizeGuid(embyGuid);
if (!normalizedGuid) return null;
const cache = loadCacheFile();
return cache.users?.[normalizedGuid] || null;
}
export function writeCachedEmbyUserContext(embyGuid, context) {
const normalizedGuid = normalizeGuid(embyGuid);
if (!normalizedGuid) return null;
const cache = loadCacheFile();
const nextContext = {
views: Array.isArray(context?.views) ? context.views : [],
recentlyPlayed: Array.isArray(context?.recentlyPlayed) ? context.recentlyPlayed : [],
excludedFolderLookup: context?.excludedFolderLookup || {},
lastSyncedAt: context?.lastSyncedAt || new Date().toISOString()
};
cache.users ||= {};
cache.users[normalizedGuid] = nextContext;
saveCacheFile(cache);
return nextContext;
}
@@ -0,0 +1,211 @@
function parseJsonBlob(blob) {
if (!blob) return null;
try {
const text = typeof blob === 'string' ? blob : Buffer.from(blob).toString('utf8');
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Emby stores GUIDs in SQLite as 16-byte blobs using Microsoft mixed-endian ordering.
* Components 1-3 are little-endian; components 4-5 are big-endian.
*/
export function blobToEmbyGuid(blob) {
if (!blob) return '';
const b = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
if (b.length !== 16) return Buffer.from(b).toString('hex').toLowerCase();
const out = new Uint8Array([
b[3], b[2], b[1], b[0],
b[5], b[4],
b[7], b[6],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
]);
return Buffer.from(out).toString('hex').toLowerCase();
}
function hasTable(db, tableName) {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName);
return !!row?.found;
}
function normalizeSectionUserId(sectionUserId) {
return typeof sectionUserId === 'string' ? sectionUserId.trim().toLowerCase() : '';
}
export function normalizeSectionsForUser(sections, expectedEmbyGuid) {
if (!Array.isArray(sections)) return [];
if (!expectedEmbyGuid) return sections;
return sections.map((section) => ({
...section,
UserId: expectedEmbyGuid
}));
}
function loadUsersTableUsers(db) {
const cols = db.prepare('PRAGMA table_info(Users)').all().map((c) => c.name);
const nameCol = cols.find((c) => /^username$/i.test(c)) || cols.find((c) => /^name$/i.test(c));
const guidCol = cols.find((c) => /^guid$/i.test(c));
const idCol = cols.find((c) => /^id$/i.test(c)) || 'Id';
if (!nameCol) {
throw new Error(`Cannot find a name column in Users table. Columns found: ${cols.join(', ')}`);
}
const selectCols = [idCol, nameCol, guidCol].filter(Boolean).join(', ');
const rows = db.prepare(`SELECT ${selectCols} FROM Users`).all();
return rows.map((row) => {
const rawId = row[idCol] ?? row.Id ?? row.id;
const rawGuid = guidCol ? row[guidCol] : null;
let embyGuid = '';
let guid = '';
if (rawGuid) {
if (rawGuid instanceof Uint8Array || rawGuid instanceof Buffer) {
const buf = Buffer.from(rawGuid);
guid = buf.toString('hex').toUpperCase();
embyGuid = blobToEmbyGuid(rawGuid);
} else if (typeof rawGuid === 'string') {
const clean = rawGuid.replace(/-/g, '').toLowerCase();
embyGuid = clean;
guid = clean.toUpperCase();
}
}
return {
id: rawId,
name: row[nameCol] || `User ${rawId}`,
guid,
embyGuid,
sourceTable: 'Users'
};
});
}
function loadLocalUsers(db) {
const rows = db.prepare('SELECT Id, guid, data FROM LocalUsersv2').all();
return rows.map((row) => {
const parsed = parseJsonBlob(row.data);
const guid = row.guid ? Buffer.from(row.guid).toString('hex').toUpperCase() : '';
const embyGuidFromBlob = blobToEmbyGuid(row.guid);
const embyGuidFromJson = normalizeSectionUserId(parsed?.IdString);
const embyGuid = embyGuidFromJson || embyGuidFromBlob;
return {
id: row.Id,
name: parsed?.Name || `User ${row.Id}`,
guid,
embyGuid,
sourceTable: 'LocalUsersv2',
profile: parsed || null
};
});
}
export function loadCanonicalUsers(db) {
if (hasTable(db, 'LocalUsersv2')) {
return loadLocalUsers(db);
}
if (hasTable(db, 'Users')) {
return loadUsersTableUsers(db);
}
throw new Error('No supported user table found. Expected LocalUsersv2 or Users.');
}
export function loadHomeScreenUsers(db) {
const users = loadCanonicalUsers(db);
const settingsRows = db
.prepare(
`SELECT us.UserId, us.Value
FROM UserSettings us
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
WHERE usk.Name = 'homescreensettings'`
)
.all();
const settingsMap = new Map(settingsRows.map((row) => [String(row.UserId), row.Value]));
const userIds = new Set(users.map((user) => String(user.id)));
let matchedUsers = 0;
let mismatchedUsers = 0;
let normalizedUsers = 0;
let missingSectionUserIds = 0;
const hydratedUsers = users.map((user) => {
const rawValue = settingsMap.get(String(user.id));
let sections = [];
try {
if (rawValue) sections = JSON.parse(rawValue).Sections || [];
} catch {
sections = [];
}
const actualUserIds = [...new Set(sections.map((section) => normalizeSectionUserId(section?.UserId)).filter(Boolean))];
const mismatchedSectionUserIds = user.embyGuid
? actualUserIds.filter((id) => id !== user.embyGuid)
: actualUserIds;
const missingIdsForUser = sections.filter((section) => !normalizeSectionUserId(section?.UserId)).length;
const normalizedSections = normalizeSectionsForUser(sections, user.embyGuid);
const sectionsWereNormalized =
user.embyGuid &&
JSON.stringify(sections) !== JSON.stringify(normalizedSections);
if (mismatchedSectionUserIds.length === 0) matchedUsers++;
else mismatchedUsers++;
if (sectionsWereNormalized) normalizedUsers++;
missingSectionUserIds += missingIdsForUser;
return {
id: user.id,
name: user.name,
guid: user.guid,
embyGuid: user.embyGuid,
sections: normalizedSections,
details: {
sourceTable: user.sourceTable,
lastLoginDate: user.profile?.LastLoginDate || null,
lastActivityDate: user.profile?.LastActivityDate || null,
usesIdForConfigurationPath: user.profile?.UsesIdForConfigurationPath ?? null,
importedCollectionsCount: Array.isArray(user.profile?.ImportedCollections) ? user.profile.ImportedCollections.length : 0
},
match: {
sourceTable: user.sourceTable,
settingsUserId: user.id,
expectedSectionUserId: user.embyGuid,
actualSectionUserIds: actualUserIds,
mismatchedSectionUserIds,
missingSectionUserIds: missingIdsForUser,
ok: mismatchedSectionUserIds.length === 0
}
};
});
const orphanedSettingsUserIds = settingsRows
.map((row) => String(row.UserId))
.filter((userId, index, all) => all.indexOf(userId) === index && !userIds.has(userId));
return {
users: hydratedUsers,
validation: {
userSource: users[0]?.sourceTable || null,
userCount: hydratedUsers.length,
settingsCount: settingsRows.length,
matchedUsers,
mismatchedUsers,
normalizedUsers,
missingSectionUserIds,
orphanedSettingsUserIds
}
};
}
export function loadUserLookup(db) {
return new Map(loadCanonicalUsers(db).map((user) => [String(user.id), user]));
}
+123
View File
@@ -0,0 +1,123 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
const CONFIG_PATH = resolve('config.json');
const TMDB_BASE = 'https://api.themoviedb.org/3';
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
export function loadTmdbConfig() {
const config = loadConfig();
return {
tmdbApiKey: String(config?.tmdbApiKey || '').trim()
};
}
export function hasTmdbConfig() {
return !!loadTmdbConfig().tmdbApiKey;
}
async function fetchTmdb(pathname, params = {}) {
const { tmdbApiKey } = loadTmdbConfig();
if (!tmdbApiKey) {
throw new Error('TMDB API key not configured');
}
const url = new URL(`${TMDB_BASE}${pathname.startsWith('/') ? pathname : `/${pathname}`}`);
for (const [key, value] of Object.entries({
api_key: tmdbApiKey,
language: 'en-US',
include_adult: 'false',
...params
})) {
if (value === undefined || value === null || value === '') continue;
url.searchParams.set(key, String(value));
}
const response = await fetch(url, {
headers: { Accept: 'application/json' }
});
if (!response.ok) {
const text = await response.text().catch(() => response.statusText);
throw new Error(text || `${response.status} ${response.statusText}`);
}
return response.json();
}
function normalizeTmdbSearchItem(item, mediaType) {
return {
tmdbId: Number(item?.id || 0) || null,
name: item?.title || item?.name || '',
year: Number(String(item?.release_date || item?.first_air_date || '').slice(0, 4)) || null,
mediaType,
overview: item?.overview || '',
genreIds: Array.isArray(item?.genre_ids) ? item.genre_ids : [],
originalLanguage: item?.original_language || '',
popularity: Number(item?.popularity || 0),
voteAverage: Number(item?.vote_average || 0),
voteCount: Number(item?.vote_count || 0)
};
}
export async function searchTmdbByTitle({ mediaType, name, year }) {
const searchType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/search/${searchType}`, {
query: name,
page: 1,
...(year ? (searchType === 'movie' ? { year } : { first_air_date_year: year }) : {})
});
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, searchType));
}
export async function fetchTmdbSimilar({ mediaType, tmdbId, page = 1 }) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/similar`, { page });
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
}
export async function fetchTmdbRecommendations({ mediaType, tmdbId, page = 1 }) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/recommendations`, { page });
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
}
export async function fetchTmdbDetails({ mediaType, tmdbId }) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
return fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}`);
}
export async function fetchTmdbKeywords({ mediaType, tmdbId }) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/keywords`);
return payload?.keywords || payload?.results || [];
}
export async function fetchTmdbCredits({ mediaType, tmdbId }) {
if (mediaType === 'tv') {
return fetchTmdb(`/tv/${encodeURIComponent(tmdbId)}/aggregate_credits`);
}
return fetchTmdb(`/movie/${encodeURIComponent(tmdbId)}/credits`);
}
export async function fetchTmdbGenres(mediaType) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/genre/${pathType}/list`, { language: 'en' });
return payload?.genres || [];
}
export async function fetchTmdbDiscover({ mediaType, params = {} }) {
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
const payload = await fetchTmdb(`/discover/${pathType}`, params);
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
}
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { applyCachedEmbyNames } from '../lib/server/emby-user-cache.js';
/** @type {import('./$types').PageServerLoad} */
export async function load() {
const dataPath = resolve('static/db_export.json');
const raw = readFileSync(dataPath, 'utf-8');
const data = JSON.parse(raw);
const enrichedUsers = applyCachedEmbyNames(data.users);
const configPath = resolve('config.json');
let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
if (existsSync(configPath)) {
try {
config = {
...config,
...JSON.parse(readFileSync(configPath, 'utf-8'))
};
} catch { /* use defaults */ }
}
return {
users: enrichedUsers.users,
genres: data.genres,
enums: data.enums,
config,
embyCache: enrichedUsers.cache
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json } from '@sveltejs/kit';
const CONFIG_PATH = resolve('config.json');
const DEFAULT_CONFIG = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
try {
return {
...DEFAULT_CONFIG,
...JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
};
} catch {
return DEFAULT_CONFIG;
}
}
export async function GET() {
return json(loadConfig());
}
export async function POST({ request }) {
const body = await request.json();
const config = {
embyUrl: String(body.embyUrl || '').trim(),
apiKey: String(body.apiKey || '').trim(),
tmdbApiKey: String(body.tmdbApiKey || '').trim(),
dbPath: String(body.dbPath || '').trim()
};
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
return json({ ok: true });
}
@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadHomeScreenUsers } from '../../../lib/server/emby-user-db.js';
import { applyCachedEmbyNames } from '../../../lib/server/emby-user-cache.js';
export async function POST({ request }) {
const { dbPath } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const result = loadHomeScreenUsers(db);
const enriched = applyCachedEmbyNames(result.users);
db.close();
return json({
...result,
users: enriched.users,
validation: {
...result.validation,
embyCacheMatchedUsers: enriched.cache.matchedCount,
embyCacheUserCount: enriched.cache.totalCachedUsers,
embyCacheLastSyncedAt: enriched.cache.lastSyncedAt
}
});
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
@@ -0,0 +1,72 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadUserLookup, normalizeSectionsForUser } from '../../../lib/server/emby-user-db.js';
export async function POST({ request }) {
const { dbPath, changes } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
if (!changes?.length) return json({ ok: true, count: 0 });
let db;
try {
db = new DatabaseSync(dbPath);
const userLookup = loadUserLookup(db);
const keyRow = db
.prepare("SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'")
.get();
if (!keyRow) throw new Error("'homescreensettings' key not found in UserSettingsKeys table");
const keyId = keyRow.UserSettingsKeyId;
const checkStmt = db.prepare(
'SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const updateStmt = db.prepare(
'UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const insertStmt = db.prepare(
'INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)'
);
let count = 0;
let normalizedSections = 0;
// node:sqlite transactions: use db.exec('BEGIN') / db.exec('COMMIT') manually
// or wrap in a function with db.transaction() if supported
db.exec('BEGIN');
try {
for (const { userId, sections } of changes) {
const user = userLookup.get(String(userId));
if (!user) {
throw new Error(`UserId ${userId} does not exist in ${dbPath}`);
}
const nextSections = normalizeSectionsForUser(sections, user.embyGuid);
if (JSON.stringify(nextSections) !== JSON.stringify(sections)) {
normalizedSections += nextSections.length;
}
const value = JSON.stringify({ Sections: nextSections });
const exists = checkStmt.get(userId, keyId);
if (exists) {
updateStmt.run(value, userId, keyId);
} else {
insertStmt.run(userId, keyId, value);
}
count++;
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
db.close();
return json({ ok: true, count, normalizedSections });
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
@@ -0,0 +1,695 @@
import { error, json } from '@sveltejs/kit';
import {
RECOMMENDATION_PROFILES,
rankRecommendationResults,
normalizeLookupItem
} from '../../../lib/collection-tools.js';
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import {
fetchTmdbCredits,
fetchTmdbDetails,
fetchTmdbDiscover,
fetchTmdbGenres,
fetchTmdbKeywords,
fetchTmdbRecommendations,
fetchTmdbSimilar,
searchTmdbByTitle
} from '../../../lib/server/tmdb-api.js';
const EMBY_PAGE_SIZE = 200;
function normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
function normalizeYear(value) {
const year = Number(value || 0);
return Number.isFinite(year) && year > 0 ? year : null;
}
function titleYearKey(name, year) {
return `${normalizeTitle(name)}::${normalizeYear(year) || ''}`;
}
function normalizeCollectionName(name) {
return String(name || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function getSeedTmdbId(item) {
const providerIds = item?.providerIds || {};
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
}
function getTmdbMediaType(item) {
return item?.type === 'Series' ? 'tv' : 'movie';
}
function summarizeItems(items, limit = 5) {
return (items || []).slice(0, limit).map((item) => ({
name: item?.name || item?.title || item?.Name || '',
year: item?.year || item?.ProductionYear || null,
id: item?.id || item?.Id || '',
type: item?.type || item?.Type || ''
}));
}
function filterEnglishCandidates(items) {
return (items || []).filter((item) => !item?.originalLanguage || item.originalLanguage === 'en');
}
async function fetchAllUserItems(userId, params) {
const allItems = [];
let startIndex = 0;
while (true) {
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
GroupItemsIntoCollections: false,
Limit: EMBY_PAGE_SIZE,
StartIndex: startIndex,
...params
}
});
const pageItems = (payload.Items || payload || []).map(normalizeLookupItem);
allItems.push(...pageItems);
const total = Number(payload.TotalRecordCount || 0);
if (!pageItems.length) break;
if (total > 0 && allItems.length >= total) break;
if (pageItems.length < EMBY_PAGE_SIZE) break;
startIndex += pageItems.length;
}
return allItems;
}
async function fetchAllLibraryItems(userId) {
return fetchAllUserItems(userId, {
IncludeItemTypes: 'Movie,Series',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds'
});
}
async function fetchAllPlayedItems(userId) {
return fetchAllUserItems(userId, {
Filters: 'IsPlayed',
IncludeItemTypes: 'Movie,Series',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds,UserData'
});
}
function uniqueById(items) {
const seen = new Set();
const unique = [];
for (const item of items || []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
unique.push(item);
}
return unique;
}
function buildLibraryIndex(items) {
const byId = new Map();
const byTmdbId = new Map();
const byTitleYear = new Map();
const byTitle = new Map();
for (const item of items || []) {
if (!item?.id) continue;
byId.set(item.id, item);
const tmdbId = getSeedTmdbId(item);
if (tmdbId && !byTmdbId.has(tmdbId)) {
byTmdbId.set(tmdbId, item);
}
const titleKey = titleYearKey(item.name, item.year);
if (normalizeTitle(item.name) && !byTitleYear.has(titleKey)) {
byTitleYear.set(titleKey, item);
}
const normalizedName = normalizeTitle(item.name);
if (normalizedName && !byTitle.has(normalizedName)) {
byTitle.set(normalizedName, item);
}
}
return { byId, byTmdbId, byTitleYear, byTitle };
}
async function fetchSeedItems(userId, ids, libraryIndex) {
const fromLibrary = ids
.map((id) => libraryIndex.byId.get(String(id)))
.filter(Boolean);
if (fromLibrary.length === ids.length) {
return fromLibrary;
}
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Ids: ids.join(','),
Limit: ids.length,
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds',
GroupItemsIntoCollections: false
}
});
return uniqueById((payload.Items || payload || []).map(normalizeLookupItem));
}
async function findExistingCollection(userId, name) {
const trimmedName = String(name || '').trim();
if (!userId || !trimmedName) return null;
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
SearchTerm: trimmedName,
Limit: 20,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'Overview',
IncludeItemTypes: 'BoxSet',
GroupItemsIntoCollections: false
}
});
const normalizedTarget = normalizeCollectionName(trimmedName);
const exact = (payload.Items || payload || [])
.map(normalizeLookupItem)
.find((item) => item.type === 'BoxSet' && normalizeCollectionName(item.name) === normalizedTarget);
return exact || null;
}
async function resolveTmdbMatch(item) {
const directTmdbId = getSeedTmdbId(item);
if (directTmdbId) {
return {
tmdbId: Number(directTmdbId),
mediaType: getTmdbMediaType(item)
};
}
const matches = await searchTmdbByTitle({
mediaType: getTmdbMediaType(item),
name: item.name,
year: item.year
});
const best = matches[0];
if (!best?.tmdbId) return null;
return {
tmdbId: best.tmdbId,
mediaType: best.mediaType
};
}
function resolveLocalCandidate(index, candidate) {
const tmdbId = String(candidate?.tmdbId || '').trim();
if (tmdbId && index.byTmdbId.has(tmdbId)) {
return index.byTmdbId.get(tmdbId);
}
const exactTitleYear = index.byTitleYear.get(titleYearKey(candidate?.name, candidate?.year));
if (exactTitleYear) {
return exactTitleYear;
}
return index.byTitle.get(normalizeTitle(candidate?.name)) || null;
}
function addWeight(map, key, amount = 1) {
if (key === null || key === undefined || key === '') return;
map.set(key, (map.get(key) || 0) + amount);
}
function sortWeightedEntries(map, limit) {
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
}
function getGenreLookup(genres) {
return new Map((genres || []).map((genre) => [normalizeTitle(genre.name), Number(genre.id)]));
}
function resolveGenreIdsFromLocalItems(items, genreLookup, weight, targetMap) {
for (const item of items || []) {
for (const genreName of item.genres || []) {
const id = genreLookup.get(normalizeTitle(genreName));
if (id) addWeight(targetMap, id, weight);
}
}
}
function normalizeKeywordIds(keywords) {
return (keywords || []).map((keyword) => Number(keyword?.id || 0)).filter(Boolean);
}
function extractPeopleIds(credits, mediaType) {
const ids = new Set();
for (const person of (credits?.cast || []).slice(0, 5)) {
if (person?.id) ids.add(Number(person.id));
}
if (mediaType === 'movie') {
for (const crew of credits?.crew || []) {
if (!crew?.id) continue;
if (crew.job === 'Director' || crew.job === 'Writer' || crew.job === 'Screenplay') {
ids.add(Number(crew.id));
}
if (ids.size >= 8) break;
}
}
return [...ids];
}
function buildDiscoverQueries({ mediaType, genreIds, keywordIds, peopleIds }) {
const base = {
page: 1,
sort_by: 'popularity.desc',
'vote_count.gte': mediaType === 'tv' ? 10 : 25,
with_original_language: 'en'
};
const queries = [];
if (genreIds.length) {
queries.push({
label: `${mediaType}-genres`,
params: {
...base,
with_genres: genreIds.slice(0, 4).join('|')
}
});
}
if (keywordIds.length) {
queries.push({
label: `${mediaType}-keywords`,
params: {
...base,
with_keywords: keywordIds.slice(0, 6).join('|')
}
});
}
if (genreIds.length && keywordIds.length) {
queries.push({
label: `${mediaType}-genres-keywords`,
params: {
...base,
with_genres: genreIds.slice(0, 3).join('|'),
with_keywords: keywordIds.slice(0, 4).join('|')
}
});
}
if (mediaType === 'movie' && peopleIds.length) {
queries.push({
label: 'movie-people',
params: {
...base,
with_people: peopleIds.slice(0, 5).join('|')
}
});
}
return queries;
}
function inspectCandidateSets(seedIds, resultSets) {
const excluded = new Set((seedIds || []).map((id) => String(id)));
const uniqueIncluded = new Map();
const uniqueExcluded = new Map();
for (const resultSet of resultSets || []) {
const items = Array.isArray(resultSet) ? resultSet : (Array.isArray(resultSet?.items) ? resultSet.items : []);
for (const item of items) {
const normalized = normalizeLookupItem(item);
if (!normalized.id) continue;
if (excluded.has(normalized.id)) {
if (!uniqueExcluded.has(normalized.id)) uniqueExcluded.set(normalized.id, normalized);
continue;
}
if (!uniqueIncluded.has(normalized.id)) uniqueIncluded.set(normalized.id, normalized);
}
}
return {
uniqueCandidateCount: uniqueIncluded.size,
excludedSeedCandidateCount: uniqueExcluded.size,
sampleExcludedSeedCandidates: summarizeItems([...uniqueExcluded.values()])
};
}
async function buildSeedContext(seed, libraryIndex) {
const diagnostic = {
seedId: seed.id,
seedName: seed.name,
seedType: seed.type,
seedYear: seed.year,
providerTmdbId: getSeedTmdbId(seed) || null,
tmdbMatch: null,
similarCount: 0,
recommendationCount: 0,
localSimilarCount: 0,
localRecommendationCount: 0,
error: null
};
try {
const tmdbMatch = await resolveTmdbMatch(seed);
if (!tmdbMatch?.tmdbId) {
diagnostic.error = 'No TMDB match resolved for seed';
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
diagnostic.tmdbMatch = tmdbMatch;
const [details, keywords, credits, similar, recommendations] = await Promise.all([
fetchTmdbDetails(tmdbMatch),
fetchTmdbKeywords(tmdbMatch),
fetchTmdbCredits(tmdbMatch),
fetchTmdbSimilar({ ...tmdbMatch, page: 1 }),
fetchTmdbRecommendations({ ...tmdbMatch, page: 1 })
]);
const englishSimilar = filterEnglishCandidates(similar);
const englishRecommendations = filterEnglishCandidates(recommendations);
const localSimilar = uniqueById(englishSimilar.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean));
const localRecommendations = uniqueById(
englishRecommendations.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
diagnostic.similarCount = englishSimilar.length;
diagnostic.recommendationCount = englishRecommendations.length;
diagnostic.localSimilarCount = localSimilar.length;
diagnostic.localRecommendationCount = localRecommendations.length;
return {
seed,
details,
keywords,
peopleIds: extractPeopleIds(credits, tmdbMatch.mediaType),
diagnostic,
resultSets: [
{ items: localSimilar, sourceWeight: 5, label: 'tmdb-similar' },
{ items: localRecommendations, sourceWeight: 6, label: 'tmdb-recommendations' }
]
};
} catch (err) {
diagnostic.error = err.message;
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
}
async function buildPreview(userId, seedIds, limit, profile) {
const normalizedSeedIds = [...new Set(seedIds.map((id) => String(id).trim()).filter(Boolean))];
const libraryItems = uniqueById(await fetchAllLibraryItems(userId));
const playedItems = uniqueById(await fetchAllPlayedItems(userId));
const excludedIds = [...new Set([...normalizedSeedIds, ...playedItems.map((item) => item.id).filter(Boolean)])];
const libraryIndex = buildLibraryIndex(libraryItems);
const seeds = await fetchSeedItems(userId, normalizedSeedIds, libraryIndex);
const allowedTypes = [...new Set(seeds.map((item) => item.type).filter(Boolean))];
const includeMovies = allowedTypes.includes('Movie');
const includeSeries = allowedTypes.includes('Series');
if (!seeds.length) {
return {
seeds: [],
recommendations: [],
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: 0,
embyCandidates: 0,
tmdbEnabled: true,
tmdbCandidates: 0,
tmdbResolved: 0,
uniqueCandidateCount: 0,
excludedSeedCandidateCount: 0,
sampleExcludedSeedCandidates: [],
perSeed: [],
discoverQueries: []
}
};
}
const seedContexts = await Promise.all(seeds.map((seed) => buildSeedContext(seed, libraryIndex)));
const movieGenres = await fetchTmdbGenres('movie');
const tvGenres = await fetchTmdbGenres('tv');
const movieGenreLookup = getGenreLookup(movieGenres);
const tvGenreLookup = getGenreLookup(tvGenres);
const movieGenreWeights = new Map();
const tvGenreWeights = new Map();
const movieKeywordWeights = new Map();
const tvKeywordWeights = new Map();
const moviePeopleWeights = new Map();
for (const context of seedContexts) {
const mediaType = getTmdbMediaType(context.seed);
const genreTarget = mediaType === 'tv' ? tvGenreWeights : movieGenreWeights;
const keywordTarget = mediaType === 'tv' ? tvKeywordWeights : movieKeywordWeights;
for (const genre of context.details?.genres || []) {
addWeight(genreTarget, Number(genre.id), 5);
}
for (const keywordId of normalizeKeywordIds(context.keywords)) {
addWeight(keywordTarget, keywordId, 4);
}
if (mediaType === 'movie') {
for (const personId of context.peopleIds || []) {
addWeight(moviePeopleWeights, personId, 3);
}
}
}
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 3, movieGenreWeights);
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 3, tvGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 1, movieGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 1, tvGenreWeights);
const discoverQueries = [
...(includeMovies
? buildDiscoverQueries({
mediaType: 'movie',
genreIds: sortWeightedEntries(movieGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(movieKeywordWeights, 8).map(([id]) => id),
peopleIds: sortWeightedEntries(moviePeopleWeights, 6).map(([id]) => id)
})
: []),
...(includeSeries
? buildDiscoverQueries({
mediaType: 'tv',
genreIds: sortWeightedEntries(tvGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(tvKeywordWeights, 8).map(([id]) => id),
peopleIds: []
})
: [])
];
const queryResults = await Promise.all(
discoverQueries.map(async (query) => {
try {
const tmdbResults = filterEnglishCandidates(await fetchTmdbDiscover({
mediaType: query.label.startsWith('tv') ? 'tv' : 'movie',
params: query.params
}));
const localMatches = uniqueById(
tmdbResults.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
return {
label: query.label,
params: query.params,
tmdbCount: tmdbResults.length,
localCount: localMatches.length,
localMatches,
sourceWeight: 1
};
} catch (err) {
return {
label: query.label,
params: query.params,
tmdbCount: 0,
localCount: 0,
localMatches: [],
error: err.message
};
}
})
);
const resultSets = [
...seedContexts.flatMap((context) => context.resultSets),
...queryResults.map((query) => ({
items: query.localMatches,
sourceWeight: query.sourceWeight || 1,
label: query.label
}))
].filter((resultSet) => (Array.isArray(resultSet) ? resultSet.length : resultSet.items.length) > 0);
const candidateInspection = inspectCandidateSets(excludedIds, resultSets);
const recommendations = rankRecommendationResults(normalizedSeedIds, resultSets, {
limit,
profile,
seeds,
excludeIds: excludedIds,
allowedTypes
});
return {
seeds,
recommendations,
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: seeds.length,
embyCandidates: seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
),
tmdbEnabled: true,
tmdbCandidates:
seedContexts.reduce(
(total, context) => total + context.diagnostic.similarCount + context.diagnostic.recommendationCount,
0
) + queryResults.reduce((total, query) => total + query.tmdbCount, 0),
tmdbResolved:
seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
) + queryResults.reduce((total, query) => total + query.localCount, 0),
uniqueCandidateCount: candidateInspection.uniqueCandidateCount,
excludedSeedCandidateCount: candidateInspection.excludedSeedCandidateCount,
sampleExcludedSeedCandidates: candidateInspection.sampleExcludedSeedCandidates,
perSeed: seedContexts.map((context) => context.diagnostic),
discoverQueries: queryResults.map((query) => ({
label: query.label,
tmdbCount: query.tmdbCount,
localCount: query.localCount,
error: query.error || null,
params: query.params
}))
}
};
}
async function createOrUpdateCollection(userId, name, itemIds) {
const existingCollection = await findExistingCollection(userId, name);
if (existingCollection?.id) {
await fetchEmby(`/Collections/${encodeURIComponent(existingCollection.id)}/Items`, {
method: 'POST',
params: {
Ids: itemIds.join(',')
}
});
return {
id: existingCollection.id,
name: existingCollection.name || name,
updated: true
};
}
const response = await fetchEmby('/Collections', {
method: 'POST',
params: {
Name: name,
Ids: itemIds.join(',')
}
});
const created = await response.json();
return {
id: String(created?.Id || ''),
name: created?.Name || name,
updated: false
};
}
export async function POST({ request }) {
const body = await request.json();
const mode = body?.mode === 'create' ? 'create' : 'preview';
const userId = normalizeEmbyGuid(body?.userId);
const seedIds = Array.isArray(body?.seedIds) ? body.seedIds : [];
const limit = Math.min(Math.max(Number(body?.limit || 18), 1), 48);
const includeSeeds = body?.includeSeeds !== false;
const name = String(body?.name || '').trim();
const profile = RECOMMENDATION_PROFILES[body?.profile] ? body.profile : 'balanced';
if (!userId) {
throw error(400, 'Missing userId');
}
if (seedIds.length === 0) {
throw error(400, 'Select at least one seed item');
}
try {
const preview = await buildPreview(userId, seedIds, limit, profile);
const existingCollection = name ? await findExistingCollection(userId, name) : null;
if (mode !== 'create') {
return json({
...preview,
profile,
collection: existingCollection
? {
id: existingCollection.id,
name: existingCollection.name,
updated: true
}
: null
});
}
if (!name) {
throw error(400, 'Collection name is required');
}
const collectionIds = [
...(includeSeeds ? preview.seeds.map((item) => item.id) : []),
...preview.recommendations.map((item) => item.id)
];
const uniqueIds = [...new Set(collectionIds.filter(Boolean))];
if (uniqueIds.length === 0) {
throw error(400, 'No items were available to add to the collection');
}
const collection = await createOrUpdateCollection(userId, name, uniqueIds);
return json({
seeds: preview.seeds,
recommendations: preview.recommendations,
profile,
collection
});
} catch (err) {
if (err?.status) throw err;
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not build Emby collection: ${err.message}`);
}
}
@@ -0,0 +1,173 @@
import { error, json } from '@sveltejs/kit';
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
import {
buildSingleGenreUpdate,
normalizeGenreNames,
pickSuggestedGenre
} from '../../../lib/genre-cleanup.js';
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import { fetchTmdbDetails, searchTmdbByTitle } from '../../../lib/server/tmdb-api.js';
function getItemTmdbId(item) {
const providerIds = item?.ProviderIds || item?.providerIds || {};
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
}
function getTmdbMediaType(item) {
return item?.Type === 'Series' || item?.type === 'Series' ? 'tv' : 'movie';
}
function normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
async function fetchFullItem(userId, itemId) {
const candidates = [
() => fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items/${encodeURIComponent(itemId)}`),
() =>
fetchEmbyJson(`/Items/${encodeURIComponent(itemId)}`, {
params: { UserId: userId }
})
];
let lastError = null;
for (const candidate of candidates) {
try {
return await candidate();
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Could not fetch item details from Emby');
}
async function resolveTmdbMatch(item) {
const directTmdbId = getItemTmdbId(item);
if (directTmdbId) {
return {
tmdbId: Number(directTmdbId),
mediaType: getTmdbMediaType(item),
source: 'providerId'
};
}
const matches = await searchTmdbByTitle({
mediaType: getTmdbMediaType(item),
name: item?.Name || item?.name,
year: item?.ProductionYear || item?.year
});
const exactTitle = normalizeTitle(item?.Name || item?.name);
const bestMatch =
matches.find(
(candidate) =>
normalizeTitle(candidate?.name) === exactTitle &&
(!item?.ProductionYear || !candidate?.year || candidate.year === item.ProductionYear)
) || matches[0];
if (!bestMatch?.tmdbId) return null;
return {
tmdbId: Number(bestMatch.tmdbId),
mediaType: bestMatch.mediaType,
source: 'search'
};
}
async function inspectItemGenres(userId, itemId) {
const fullItem = await fetchFullItem(userId, itemId);
const normalizedItem = normalizeLookupItem(fullItem);
const currentGenres = normalizeGenreNames([
...(fullItem?.Genres || []),
...((fullItem?.GenreItems || []).map((entry) => entry?.Name))
]);
const tmdbMatch = await resolveTmdbMatch(fullItem);
if (!tmdbMatch?.tmdbId) {
return {
item: normalizedItem,
currentGenres,
tmdb: null,
suggestedGenre: ''
};
}
const details = await fetchTmdbDetails(tmdbMatch);
const tmdbGenres = normalizeGenreNames((details?.genres || []).map((genre) => genre?.name));
return {
item: normalizedItem,
currentGenres,
tmdb: {
tmdbId: tmdbMatch.tmdbId,
mediaType: tmdbMatch.mediaType,
source: tmdbMatch.source,
genres: tmdbGenres
},
suggestedGenre: pickSuggestedGenre(tmdbGenres, currentGenres)
};
}
export async function GET({ url }) {
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
const itemId = String(url.searchParams.get('itemId') || '').trim();
if (!userId) {
throw error(400, 'Missing userId');
}
if (!itemId) {
throw error(400, 'Missing itemId');
}
try {
return json(await inspectItemGenres(userId, itemId));
} catch (err) {
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not inspect item genres: ${err.message}`);
}
}
export async function POST({ request }) {
const body = await request.json();
const userId = normalizeEmbyGuid(body?.userId);
const itemId = String(body?.itemId || '').trim();
const genreName = String(body?.genreName || '').trim();
if (!userId) {
throw error(400, 'Missing userId');
}
if (!itemId) {
throw error(400, 'Missing itemId');
}
if (!genreName) {
throw error(400, 'Missing genreName');
}
try {
const fullItem = await fetchFullItem(userId, itemId);
const updatedItem = buildSingleGenreUpdate(fullItem, genreName);
await fetchEmby(`/Items/${encodeURIComponent(itemId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedItem)
});
return json(await inspectItemGenres(userId, itemId));
} catch (err) {
if (err?.status) throw err;
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not update item genres: ${err.message}`);
}
}
@@ -0,0 +1,45 @@
import { error, json } from '@sveltejs/kit';
import { fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
export async function GET({ url }) {
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
const term = String(url.searchParams.get('term') || '').trim();
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 12), 1), 50);
const types = (url.searchParams.get('types') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (!userId) {
throw error(400, 'Missing userId');
}
if (!term) {
return json({ items: [] });
}
try {
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
SearchTerm: term,
Limit: limit,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'Overview,Genres,ProviderIds,ProductionYear',
IncludeItemTypes: types.join(','),
GroupItemsIntoCollections: false
}
});
return json({
items: (payload.Items || payload || []).map(normalizeLookupItem)
});
} catch (err) {
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not search Emby items: ${err.message}`);
}
}
@@ -0,0 +1,160 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json, error } from '@sveltejs/kit';
import {
readCachedEmbyUserContext,
writeCachedEmbyUserContext
} from '../../../lib/server/emby-user-context-cache.js';
const CONFIG_PATH = resolve('config.json');
const EMBY_PAGE_SIZE = 200;
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
function normalizeGuid(value) {
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function fetchAllUserItems(base, embyGuid, apiKey, params = {}) {
const allItems = [];
let startIndex = 0;
while (true) {
const query = new URLSearchParams({
api_key: apiKey,
Recursive: 'true',
GroupItemsIntoCollections: 'false',
Limit: String(EMBY_PAGE_SIZE),
StartIndex: String(startIndex),
...Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, String(value)])
)
});
const payload = await fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Items?${query.toString()}`);
const pageItems = payload.Items || payload || [];
allItems.push(...pageItems);
const total = Number(payload.TotalRecordCount || 0);
if (!pageItems.length) break;
if (total > 0 && allItems.length >= total) break;
if (pageItems.length < EMBY_PAGE_SIZE) break;
startIndex += pageItems.length;
}
return allItems;
}
function normalizeViews(items = []) {
return items.map((item) => ({
id: String(item.Id || ''),
name: item.Name || 'Unnamed view',
type: item.CollectionType || item.Type || 'View'
}));
}
function normalizeRecentlyPlayed(items = []) {
return items.map((item) => ({
id: String(item.Id || ''),
name: item.Name || item.SeriesName || 'Unknown item',
type: item.Type || 'Item',
seriesName: item.SeriesName || null,
datePlayed: item.UserData?.LastPlayedDate || item.DateLastMediaAdded || null,
isPlayed: item.UserData?.Played ?? true
}));
}
function buildExcludedFolderLookup(items = []) {
return Object.fromEntries(
items
.filter((item) => item?.Id)
.map((item) => [
String(item.Id),
{
name: item.Name || item.Path || `Item ${item.Id}`,
type: item.CollectionType || item.Type || 'Item'
}
])
);
}
export async function GET({ url }) {
const embyGuid = normalizeGuid(url.searchParams.get('embyGuid'));
const excludedIds = (url.searchParams.get('excludedIds') || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (!embyGuid) {
throw error(400, 'Missing embyGuid');
}
const { embyUrl, apiKey } = loadConfig();
const cached = readCachedEmbyUserContext(embyGuid);
if (!embyUrl || !apiKey) {
if (cached) {
return json({ ...cached, source: 'cache' });
}
throw error(400, 'Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
try {
const [viewsPayload, recentlyPlayedPayload, excludedPayload] = await Promise.all([
fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Views?api_key=${encodeURIComponent(apiKey)}`),
fetchAllUserItems(base, embyGuid, apiKey, {
Filters: 'IsPlayed',
IncludeItemTypes: 'Movie,Series',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Fields: 'UserData'
}),
excludedIds.length > 0
? fetchJson(
`${base}/Users/${encodeURIComponent(embyGuid)}/Items?api_key=${encodeURIComponent(apiKey)}&Ids=${encodeURIComponent(excludedIds.join(','))}&Fields=Path`
)
: Promise.resolve({ Items: [] })
]);
const context = writeCachedEmbyUserContext(embyGuid, {
views: normalizeViews(viewsPayload.Items || viewsPayload || []),
recentlyPlayed: normalizeRecentlyPlayed(recentlyPlayedPayload),
excludedFolderLookup: buildExcludedFolderLookup(excludedPayload.Items || excludedPayload || []),
lastSyncedAt: new Date().toISOString()
});
return json({ ...context, source: 'live' });
} catch (err) {
if (cached) {
const filteredLookup = excludedIds.length
? Object.fromEntries(
Object.entries(cached.excludedFolderLookup || {}).filter(([id]) => excludedIds.includes(id))
)
: (cached.excludedFolderLookup || {});
return json({
...cached,
excludedFolderLookup: filteredLookup,
source: 'cache',
message: `Using cached Emby user context because live fetch failed: ${err.message}`
});
}
throw error(502, `Could not load Emby user context: ${err.message}`);
}
}
@@ -0,0 +1,68 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json, error } from '@sveltejs/kit';
import { readCachedEmbyUsers, writeCachedEmbyUsers } from '../../../lib/server/emby-user-cache.js';
const CONFIG_PATH = resolve('config.json');
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
export async function GET() {
const { embyUrl, apiKey } = loadConfig();
if (!embyUrl || !apiKey) {
throw error(400, 'Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
let res;
try {
res = await fetch(`${base}/Users?api_key=${encodeURIComponent(apiKey)}`);
} catch (e) {
const cached = readCachedEmbyUsers();
if (cached.users.length > 0) {
return json({
users: cached.users,
source: 'cache',
lastSyncedAt: cached.lastSyncedAt,
message: `Using cached Emby users because the server could not be reached: ${e.message}`
});
}
throw error(502, `Could not reach Emby server: ${e.message}`);
}
if (!res.ok) {
if (res.status >= 500) {
const cached = readCachedEmbyUsers();
if (cached.users.length > 0) {
return json({
users: cached.users,
source: 'cache',
lastSyncedAt: cached.lastSyncedAt,
message: `Using cached Emby users because the Emby API returned ${res.status} ${res.statusText}`
});
}
}
throw error(res.status, `Emby API error: ${res.status} ${res.statusText}`);
}
const users = await res.json();
const cached = writeCachedEmbyUsers(
users.map((u) => ({
embyGuid: u.Id,
name: u.Name
}))
);
return json({
users: cached.users,
source: 'live',
lastSyncedAt: cached.lastSyncedAt
});
}
+2 -1
View File
@@ -2,8 +2,9 @@ fastapi
uvicorn
httpx
Pillow
onnxruntime
python-multipart
requests
mutagen
musicbrainzngs
paramiko
psycopg[binary]
+282
View File
@@ -0,0 +1,282 @@
from pathlib import Path
from dataclasses import dataclass
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import json
import os
import shutil
import sys
import time
TIMEZONE = ZoneInfo("Pacific/Auckland")
def now() -> datetime:
return datetime.now(TIMEZONE)
DEFAULT_VIDEO_EXTENSIONS = tuple(
ext.strip().lower()
for ext in os.getenv("VIDEO_EXTENSIONS", ".mp4,.mkv,.mov,.avi,.webm").split(",")
if ext.strip()
)
@dataclass(frozen=True)
class PrerollConfig:
active_dir: Path
inactive_dir: Path
state_file: Path
rotate_weekday: int = 0
schedule_time: str = "02:00"
video_extensions: tuple[str, ...] = DEFAULT_VIDEO_EXTENSIONS
def config_from_env() -> PrerollConfig:
return PrerollConfig(
active_dir=Path(os.getenv("ACTIVE_DIR", os.getenv("PREROLL_ACTIVE_DIR", "/media/Prerolls"))),
inactive_dir=Path(os.getenv("INACTIVE_DIR", os.getenv("PREROLL_INACTIVE_DIR", "/media/Prerolls - Not Active"))),
state_file=Path(os.getenv("STATE_FILE", os.getenv("PREROLL_STATE_FILE", "cache/preroll-state.json"))),
rotate_weekday=int(os.getenv("ROTATE_WEEKDAY", os.getenv("PREROLL_WEEKDAY", "0"))),
schedule_time=os.getenv("SCHEDULE_TIME", os.getenv("PREROLL_TIME", "02:00")).strip(),
)
RUN_MODE = os.getenv("RUN_MODE", "schedule").strip().lower() # "schedule" or "once"
def log(message: str) -> None:
print(f"[{now().isoformat(timespec='seconds')}] {message}", flush=True)
def get_videos(folder: Path, video_extensions: tuple[str, ...]):
if not folder.exists():
raise FileNotFoundError(f"Folder does not exist: {folder}")
return sorted(
[
f for f in folder.iterdir()
if f.is_file() and f.suffix.lower() in video_extensions
],
key=lambda x: x.name.lower(),
)
def current_week_key() -> str:
# ISO year + ISO week is safer around New Year than %Y-%W.
iso = now().isocalendar()
return f"{iso.year}-W{iso.week:02d}"
def should_rotate(config: PrerollConfig, *, quiet: bool = False) -> bool:
today = now()
if today.weekday() != config.rotate_weekday:
if not quiet:
log(f"Today is weekday {today.weekday()}; rotation weekday is {config.rotate_weekday}. No rotation.")
return False
week_key = current_week_key()
if not config.state_file.exists():
if not quiet:
log("No previous rotation state found. Rotation allowed.")
return True
try:
state = json.loads(config.state_file.read_text(encoding="utf-8"))
last_week = state.get("last_week")
if last_week != week_key:
if not quiet:
log(f"Last rotation was {last_week}; current week is {week_key}. Rotation allowed.")
return True
if not quiet:
log(f"Already rotated for {week_key}. No rotation.")
return False
except Exception as exc:
if not quiet:
log(f"Could not read state file: {exc}. Rotation allowed.")
return True
def read_rotation_state(config: PrerollConfig) -> dict:
if not config.state_file.exists():
return {}
try:
state = json.loads(config.state_file.read_text(encoding="utf-8"))
except Exception:
return {}
return {k: state.get(k) for k in ("last_week", "last_rotation", "active_file")}
def save_rotation(config: PrerollConfig, active_name: str) -> dict:
config.state_file.parent.mkdir(parents=True, exist_ok=True)
payload = {
"last_week": current_week_key(),
"last_rotation": now().isoformat(timespec="seconds"),
"active_file": active_name,
}
config.state_file.write_text(
json.dumps(
payload,
indent=2,
),
encoding="utf-8",
)
return payload
def move_file(src: Path, dst_dir: Path) -> Path:
dst = dst_dir / src.name
if dst.exists():
raise FileExistsError(f"Destination already exists: {dst}")
shutil.move(str(src), str(dst))
return dst
def rotate(config: PrerollConfig) -> str | None:
config.active_dir.mkdir(parents=True, exist_ok=True)
config.inactive_dir.mkdir(parents=True, exist_ok=True)
active_files = get_videos(config.active_dir, config.video_extensions)
inactive_files = get_videos(config.inactive_dir, config.video_extensions)
if len(active_files) > 1:
log("More than one active preroll found. Moving extras to inactive.")
for extra in active_files[1:]:
move_file(extra, config.inactive_dir)
active_files = get_videos(config.active_dir, config.video_extensions)
inactive_files = get_videos(config.inactive_dir, config.video_extensions)
current_active = active_files[0] if active_files else None
all_files = sorted(active_files + inactive_files, key=lambda x: x.name.lower())
if not all_files:
log("No preroll videos found.")
return None
if len(all_files) == 1:
only_file = all_files[0]
if only_file.parent != config.active_dir:
moved = move_file(only_file, config.active_dir)
log(f"Only one preroll exists. Activated: {moved.name}")
return moved.name
log(f"Only one preroll exists and is already active: {only_file.name}")
return only_file.name
if current_active:
current_index = next(
i for i, f in enumerate(all_files)
if f.name.lower() == current_active.name.lower()
)
next_file = all_files[(current_index + 1) % len(all_files)]
else:
next_file = all_files[0]
if current_active:
moved_out = move_file(current_active, config.inactive_dir)
log(f"Moved current active to inactive: {moved_out.name}")
moved_in = move_file(next_file, config.active_dir)
log(f"Activated new preroll: {moved_in.name}")
return moved_in.name
def run_rotation(config: PrerollConfig, *, force: bool = False) -> dict:
try:
if not force and not should_rotate(config):
state = read_rotation_state(config)
return {
"ok": True,
"rotated": False,
"message": "Rotation not due yet.",
"state": state,
}
active_name = rotate(config)
if not active_name:
return {
"ok": True,
"rotated": False,
"message": "No preroll videos found.",
"state": read_rotation_state(config),
}
state = save_rotation(config, active_name)
return {
"ok": True,
"rotated": True,
"message": f"Activated {active_name}",
"active_file": active_name,
"state": state,
}
except Exception as exc:
log(f"ERROR: {exc}")
return {
"ok": False,
"rotated": False,
"message": str(exc),
"state": read_rotation_state(config),
}
def run_once(config: PrerollConfig | None = None) -> int:
result = run_rotation(config or config_from_env())
return 0 if result["ok"] else 1
def parse_schedule_time(value: str) -> tuple[int, int]:
hh, mm = value.split(":", 1)
hour = int(hh)
minute = int(mm)
if not (0 <= hour < 24 and 0 <= minute < 60):
raise ValueError(f"SCHEDULE_TIME out of range: {value}")
return hour, minute
def next_run_after(config: PrerollConfig, base: datetime | None = None) -> datetime:
current = base or now()
hour, minute = parse_schedule_time(config.schedule_time)
days_ahead = (config.rotate_weekday - current.weekday()) % 7
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=days_ahead)
if days_ahead == 0 and target <= current:
target += timedelta(days=7)
return target
def seconds_until_next(config: PrerollConfig) -> float:
target = next_run_after(config)
return (target - now()).total_seconds()
def run_scheduled(config: PrerollConfig | None = None) -> int:
config = config or config_from_env()
try:
parse_schedule_time(config.schedule_time)
except Exception as exc:
log(f"ERROR: invalid SCHEDULE_TIME '{config.schedule_time}': {exc}")
return 1
log(f"Scheduler started. Weekly run on weekday {config.rotate_weekday} at {config.schedule_time} local time.")
while True:
delay = seconds_until_next(config)
next_run = now() + timedelta(seconds=delay)
log(f"Sleeping {int(delay)}s until next run at {next_run.isoformat(timespec='seconds')}.")
time.sleep(delay)
log("Scheduled run triggered.")
run_rotation(config)
def main() -> int:
config = config_from_env()
log("Starting preroll rotator.")
log(f"Active folder: {config.active_dir}")
log(f"Inactive folder: {config.inactive_dir}")
log(f"Run mode: {RUN_MODE}")
if RUN_MODE == "once":
return run_once(config)
return run_scheduled(config)
if __name__ == "__main__":
sys.exit(main())
+159
View File
@@ -0,0 +1,159 @@
"""Audiobookshelf integration via its REST API.
Authentication uses a Bearer API token (Audiobookshelf account API token).
All functions take an ``httpx.AsyncClient`` so they share the app-wide client.
This is the foundation for upcoming audiobook tooling (renaming, etc.); for now
it covers connection status, libraries, and aggregate stats.
"""
from __future__ import annotations
import os
import httpx
ABS_URL = os.environ.get("AUDIOBOOKSHELF_URL", "")
ABS_TOKEN = os.environ.get("AUDIOBOOKSHELF_TOKEN", "")
class AudiobookshelfError(Exception):
def __init__(self, message: str, status: int = 502):
self.message = message
self.status = status
super().__init__(message)
def is_configured() -> bool:
return bool(ABS_URL and ABS_TOKEN)
def _require_configured() -> None:
if not is_configured():
raise AudiobookshelfError(
"Audiobookshelf is not configured. Set AUDIOBOOKSHELF_URL and AUDIOBOOKSHELF_TOKEN.",
status=503,
)
def _base_url() -> str:
return ABS_URL.rstrip("/")
def _headers() -> dict:
return {"Authorization": f"Bearer {ABS_TOKEN}", "Accept": "application/json"}
async def _call(client: httpx.AsyncClient, path: str, params: dict | None = None) -> dict:
_require_configured()
url = f"{_base_url()}{path}"
try:
response = await client.get(url, params=params, headers=_headers())
except httpx.RequestError as exc:
raise AudiobookshelfError(f"Could not reach Audiobookshelf at {ABS_URL}: {exc}") from exc
if response.status_code in (401, 403):
raise AudiobookshelfError("Audiobookshelf rejected the API token.", status=401)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
raise AudiobookshelfError(
f"Audiobookshelf returned HTTP {response.status_code} for {path}.", status=502
) from exc
try:
return response.json()
except ValueError as exc:
raise AudiobookshelfError("Audiobookshelf returned an invalid response.", status=502) from exc
async def ping(client: httpx.AsyncClient) -> dict:
if not is_configured():
return {"connected": False, "configured": False, "url": ABS_URL}
try:
me = await _call(client, "/api/me")
return {
"connected": True,
"configured": True,
"url": ABS_URL,
"username": me.get("username"),
}
except AudiobookshelfError as exc:
return {"connected": False, "configured": True, "url": ABS_URL, "error": exc.message}
async def get_libraries(client: httpx.AsyncClient) -> list[dict]:
payload = await _call(client, "/api/libraries")
raw = payload.get("libraries") or []
return [
{
"id": lib.get("id"),
"name": lib.get("name") or "",
"media_type": lib.get("mediaType") or "book",
"provider": lib.get("provider"),
}
for lib in raw
]
async def _library_item_total(client: httpx.AsyncClient, library_id: str) -> int:
try:
payload = await _call(client, f"/api/libraries/{library_id}/items", {"limit": 0})
return int(payload.get("total") or 0)
except AudiobookshelfError:
return 0
async def get_stats(client: httpx.AsyncClient) -> dict:
"""Aggregate stats across all libraries for the overview page."""
libraries = await get_libraries(client)
book_items = podcast_items = author_count = 0
total_duration = total_size = num_tracks = 0
library_views: list[dict] = []
for lib in libraries:
stats: dict = {}
try:
stats = await _call(client, f"/api/libraries/{lib['id']}/stats")
except AudiobookshelfError:
stats = {}
items = int(stats.get("totalItems") or 0)
if not items:
items = await _library_item_total(client, lib["id"])
duration = int(stats.get("totalDuration") or 0)
size = int(stats.get("totalSize") or 0)
authors = int(stats.get("totalAuthors") or 0)
tracks = int(stats.get("numAudioTracks") or 0)
if lib["media_type"] == "podcast":
podcast_items += items
else:
book_items += items
author_count += authors
total_duration += duration
total_size += size
num_tracks += tracks
library_views.append(
{
"id": lib["id"],
"name": lib["name"],
"media_type": lib["media_type"],
"items": items,
"authors": authors,
"duration": duration,
"size": size,
}
)
return {
"library_count": len(libraries),
"book_count": book_items,
"podcast_count": podcast_items,
"author_count": author_count,
"total_duration": total_duration,
"total_size": total_size,
"num_audio_tracks": num_tracks,
"libraries": library_views,
}
+93
View File
@@ -0,0 +1,93 @@
"""Bounded on-disk cache eviction so the container's disk footprint stays lean.
The app caches Emby source images, stamped posters, generated thumbnails and
uploaded backgrounds under ``cache/``. Every entry is regenerated on demand, so
none of it is precious it just needs an upper bound. :func:`prune` enforces two
limits, oldest-first:
1. **age** anything older than ``max_age_days`` is removed;
2. **size** if the cache is still over ``max_total_mb``, the oldest files are
removed until it fits.
All work is confined to the given cache directory and never raises (best-effort).
"""
from __future__ import annotations
import logging
import os
import time
from pathlib import Path
logger = logging.getLogger("homelabtoolkit.cache")
def _int_env(name: str, default: int) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return default
# Tunable via env so the deployment can trade disk for cache warmth.
MAX_AGE_DAYS = _int_env("CACHE_MAX_AGE_DAYS", 14)
MAX_TOTAL_MB = _int_env("CACHE_MAX_MB", 512)
SWEEP_INTERVAL_MIN = _int_env("CACHE_SWEEP_INTERVAL_MIN", 60)
def prune(cache_dir: Path, *, max_age_days: int = MAX_AGE_DAYS, max_total_mb: int = MAX_TOTAL_MB) -> dict:
"""Evict cache files by age, then by total size. Returns a small summary."""
if not cache_dir.exists():
return {"removed": 0, "freed_mb": 0.0, "kept_mb": 0.0}
now = time.time()
entries: list[tuple[Path, float, int]] = []
for path in cache_dir.rglob("*"):
if not path.is_file():
continue
try:
stat = path.stat()
except OSError:
continue
entries.append((path, stat.st_mtime, stat.st_size))
removed = 0
freed = 0
# 1) age pass
age_cutoff = now - max_age_days * 86400
survivors: list[tuple[Path, float, int]] = []
for path, mtime, size in entries:
if mtime < age_cutoff:
if _unlink(path):
removed += 1
freed += size
else:
survivors.append((path, mtime, size))
# 2) size pass — drop oldest first until under budget
budget = max_total_mb * 1024 * 1024
total = sum(size for _, _, size in survivors)
if total > budget:
survivors.sort(key=lambda entry: entry[1]) # oldest mtime first
for path, _mtime, size in survivors:
if total <= budget:
break
if _unlink(path):
total -= size
removed += 1
freed += size
return {
"removed": removed,
"freed_mb": round(freed / 1024 / 1024, 1),
"kept_mb": round(total / 1024 / 1024, 1),
}
def _unlink(path: Path) -> bool:
try:
path.unlink()
return True
except OSError:
return False
+7
View File
@@ -142,6 +142,13 @@ CREATE TABLE IF NOT EXISTS mb_cache (
fetched_at TEXT
);
CREATE TABLE IF NOT EXISTS genre_overrides (
artist_key TEXT PRIMARY KEY, -- normalized (lowercased) artist name
artist TEXT NOT NULL, -- original display casing
genre TEXT NOT NULL,
updated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_albums_artist ON library_albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_tracks_album ON library_tracks(album_id);
CREATE INDEX IF NOT EXISTS idx_tracks_path ON library_tracks(file_path);
+18
View File
@@ -129,6 +129,24 @@ async def list_collection_items(client, collection_id: str, user_id: str) -> lis
return [normalize_item(raw) for raw in items]
async def get_collection_item_count(client, collection_id: str) -> int:
"""Ask Emby for the authoritative child count for one collection."""
data = await client.get(
"/Items",
{
"ParentId": collection_id,
"Limit": "1",
"Recursive": "false",
},
)
if isinstance(data, dict):
try:
return int(data.get("TotalRecordCount", len(data.get("Items") or [])))
except (TypeError, ValueError):
return len(data.get("Items") or [])
return len(data or [])
async def add_collection_items(client, collection_id: str, item_ids: list[str]):
"""Add items to a collection by id. No-op for an empty list."""
if not item_ids:
+615
View File
@@ -0,0 +1,615 @@
from __future__ import annotations
import asyncio
import json
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from . import emby_collections, emby_users, favorites as favorites_service
from . import music_covers as music_service
from .music_covers import ProcessOptions
STATE_FILE = Path(os.environ.get("TASKS_STATE_FILE", os.environ.get("EMBY_TASKS_STATE_FILE", "cache/emby-tasks-state.json")))
CACHE_DIR = Path("cache")
EMBY_IMAGE_CACHE_DIR = CACHE_DIR / "emby_images"
CLEAN_POSTER_CACHE_DIR = CACHE_DIR / "clean_posters"
IMPORT_CACHE_DIR = CACHE_DIR / "imports"
TASK_DEFS: dict[str, dict[str, Any]] = {
"orphan_custom_images": {
"section": "emby",
"title": "Delete orphaned custom images for missing Emby items",
"description": "Requires access to Emby's internal metadata store. API-only deployments cannot safely locate these files.",
"supports_run": False,
"supports_automation": False,
"requires": "emby_server_data",
"run_label": "Run cleanup",
},
"empty_collections": {
"section": "emby",
"title": "Remove empty collections with zero items",
"description": "Deletes BoxSet collections that no longer contain any media.",
"supports_run": True,
"supports_automation": True,
"run_label": "Run cleanup",
},
"stale_favorites": {
"section": "emby",
"title": "Delete stale Favorites collections for removed users",
"description": "Removes '{User} Favorites' collections whose owner no longer exists in Emby.",
"supports_run": True,
"supports_automation": True,
"run_label": "Run cleanup",
},
"watched_favorites_cleanup": {
"section": "emby",
"title": "Bulk remove watched items from auto-managed collections",
"description": "Runs watched-item cleanup across every detected Favorites collection.",
"supports_run": True,
"supports_automation": True,
"run_label": "Run cleanup",
},
"duplicate_collections": {
"section": "emby",
"title": "Delete duplicate collections with the same normalized name",
"description": "Keeps the best candidate in each duplicate-name group and removes the extras.",
"supports_run": True,
"supports_automation": True,
"run_label": "Run cleanup",
},
"broken_library_paths": {
"section": "emby",
"title": "Remove broken library references where paths are not mounted",
"description": "Checks Emby library paths against the app host's filesystem and removes paths that no longer exist.",
"supports_run": True,
"supports_automation": True,
"run_label": "Run cleanup",
},
"generated_artwork_cache": {
"section": "emby",
"title": "Clear old generated artwork cache entries",
"description": "Prunes stale generated preview and artwork cache files created by HomelabToolkit.",
"supports_run": True,
"supports_automation": True,
"retention_days": 30,
"run_label": "Run cleanup",
},
"unused_metadata_cache": {
"section": "emby",
"title": "Delete unused people/metadata images from app-managed cache",
"description": "Removes stale Emby source image, poster-cleanup, and import cache files older than the configured retention window.",
"supports_run": True,
"supports_automation": True,
"retention_days": 45,
"run_label": "Run cleanup",
},
"navidrome_recent_maintenance": {
"section": "navidrome",
"title": "Maintain recently changed albums",
"description": "Scans albums changed in the last 2 hours and normalizes folders, track names, and missing covers.",
"supports_run": True,
"supports_automation": True,
"requires": "music_root",
"run_label": "Run maintenance",
},
"navidrome_cover_backfill": {
"section": "navidrome",
"title": "Backfill missing album covers",
"description": "Downloads missing cover.jpg artwork across the music library.",
"supports_run": True,
"supports_automation": True,
"requires": "music_root",
"run_label": "Fetch covers",
},
"navidrome_lyrics_backfill": {
"section": "navidrome",
"title": "Backfill missing lyrics sidecars",
"description": "Downloads missing .lrc or .txt lyrics sidecars for tracks that do not already have them.",
"supports_run": True,
"supports_automation": True,
"requires": "music_root",
"run_label": "Fetch lyrics",
},
"navidrome_file_cleanup": {
"section": "navidrome",
"title": "Remove extra non-library files",
"description": "Deletes non-audio, non-cover, and non-lyrics files from album folders.",
"supports_run": True,
"supports_automation": True,
"requires": "music_root",
"run_label": "Run cleanup",
},
}
SECTION_TITLES = {
"emby": "Emby Cleanup",
"navidrome": "Navidrome Cleanup",
}
def _default_task_settings(task_id: str) -> dict[str, Any]:
task = TASK_DEFS[task_id]
return {
"automation_enabled": False,
"weekday": 0,
"time": "03:00",
"retention_days": int(task.get("retention_days", 30)),
}
def default_settings() -> dict[str, dict[str, Any]]:
return {task_id: _default_task_settings(task_id) for task_id in TASK_DEFS}
def normalize_settings(value: Any) -> dict[str, dict[str, Any]]:
incoming = value if isinstance(value, dict) else {}
merged = default_settings()
for task_id, defaults in merged.items():
raw = incoming.get(task_id)
if not isinstance(raw, dict):
continue
defaults["automation_enabled"] = bool(raw.get("automation_enabled", defaults["automation_enabled"]))
try:
weekday = int(raw.get("weekday", defaults["weekday"]))
except (TypeError, ValueError):
weekday = defaults["weekday"]
defaults["weekday"] = min(6, max(0, weekday))
time_value = str(raw.get("time", defaults["time"])).strip() or defaults["time"]
defaults["time"] = time_value
try:
retention = int(raw.get("retention_days", defaults["retention_days"]))
except (TypeError, ValueError):
retention = defaults["retention_days"]
defaults["retention_days"] = min(3650, max(1, retention))
return merged
def load_state() -> dict:
if not STATE_FILE.exists():
return {}
try:
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def save_state(state: dict) -> None:
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
STATE_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
def _now() -> datetime:
return datetime.now().astimezone()
def _week_key(current: datetime | None = None) -> str:
iso = (current or _now()).isocalendar()
return f"{iso.year}-W{iso.week:02d}"
def parse_time(value: str) -> tuple[int, int]:
hh, mm = str(value).split(":", 1)
hour = int(hh)
minute = int(mm)
if not (0 <= hour < 24 and 0 <= minute < 60):
raise ValueError("time must be HH:MM")
return hour, minute
def next_run_at(config: dict[str, Any], base: datetime | None = None) -> datetime:
current = base or _now()
hour, minute = parse_time(config["time"])
days_ahead = (int(config["weekday"]) - current.weekday()) % 7
target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=days_ahead)
if days_ahead == 0 and target <= current:
target += timedelta(days=7)
return target
def is_due(task_id: str, config: dict[str, Any], state: dict) -> bool:
if not config.get("automation_enabled"):
return False
current = _now()
hour, minute = parse_time(config["time"])
if current.weekday() != int(config["weekday"]):
return False
if current < current.replace(hour=hour, minute=minute, second=0, microsecond=0):
return False
last_week = ((state.get("tasks") or {}).get(task_id) or {}).get("last_automation_week")
return last_week != _week_key(current)
def record_run(task_id: str, result: dict, *, automated: bool) -> dict:
state = load_state()
tasks = state.setdefault("tasks", {})
entry = tasks.setdefault(task_id, {})
entry["last_run_at"] = _now().isoformat(timespec="seconds")
entry["last_result"] = result
entry["last_status"] = "ok" if result.get("ok", True) else "error"
if automated:
entry["last_automation_week"] = _week_key()
entry["last_automated_at"] = entry["last_run_at"]
save_state(state)
return entry
def _music_root_available() -> bool:
try:
return music_service.MUSIC_ROOT.exists()
except OSError:
return False
def _supports_run(meta: dict[str, Any]) -> bool:
requirement = meta.get("requires")
if requirement == "emby_server_data":
return False
if requirement == "music_root":
return bool(meta.get("supports_run")) and _music_root_available()
return bool(meta.get("supports_run"))
def describe_tasks(settings: dict[str, dict[str, Any]]) -> list[dict]:
state = load_state().get("tasks", {})
items = []
for task_id, meta in TASK_DEFS.items():
config = settings[task_id]
try:
next_run = next_run_at(config).isoformat(timespec="seconds") if meta["supports_automation"] else None
schedule_error = None
except Exception as exc:
next_run = None
schedule_error = str(exc)
items.append(
{
"id": task_id,
"section": meta["section"],
"section_title": SECTION_TITLES.get(meta["section"], meta["section"].title()),
"title": meta["title"],
"description": meta["description"],
"supports_run": _supports_run(meta),
"supports_automation": meta["supports_automation"],
"requires": meta.get("requires"),
"run_label": meta.get("run_label", "Run task"),
"settings": config,
"status": state.get(task_id, {}),
"next_run_at": next_run,
"schedule_error": schedule_error,
}
)
return items
def _normalize_name(value: str) -> str:
return re.sub(r"[^a-z0-9]+", "", (value or "").casefold())
def _age_cutoff_days(retention_days: int) -> float:
return (_now() - timedelta(days=retention_days)).timestamp()
def _remove_paths(paths: list[Path], *, dry_run: bool) -> tuple[list[dict], int]:
removed: list[dict] = []
freed = 0
for path in paths:
try:
stat = path.stat()
except OSError:
continue
removed.append({"path": str(path), "size_bytes": stat.st_size, "modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds")})
freed += stat.st_size
if not dry_run:
try:
path.unlink()
except OSError:
pass
return removed, freed
async def _empty_collections(client, *, dry_run: bool, config: dict) -> dict:
collections = await emby_collections.find_all_collections(client)
zero_count_candidates = [c for c in collections if int(c.get("item_count") or 0) == 0]
targets = []
skipped = []
for collection in zero_count_candidates:
verified_count = await emby_collections.get_collection_item_count(client, collection["collection_id"])
if verified_count == 0:
targets.append({**collection, "verified_item_count": verified_count})
else:
skipped.append({**collection, "verified_item_count": verified_count})
if not dry_run:
for collection in targets:
await client.delete(f"/Items/{collection['collection_id']}")
return {
"ok": True,
"dry_run": dry_run,
"candidate_count": len(zero_count_candidates),
"verified_empty_count": len(targets),
"skipped_after_verification": len(skipped),
"matched_count": len(targets),
"removed_count": 0 if dry_run else len(targets),
"items": targets[:50],
"skipped_items": skipped[:50],
"message": (
f"{len(targets)} verified empty collection(s) "
f"{'would be removed' if dry_run else 'removed'}"
f"{f'; {len(skipped)} skipped after verification' if skipped else ''}."
),
}
async def _stale_favorites(client, *, dry_run: bool, config: dict) -> dict:
users = {u["name"].casefold() for u in await emby_users.fetch_users(client)}
targets = [c for c in await emby_collections.find_favorites_collections(client) if c["owner_name"] and c["owner_name"].casefold() not in users]
if not dry_run:
for collection in targets:
await client.delete(f"/Items/{collection['collection_id']}")
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(targets),
"removed_count": 0 if dry_run else len(targets),
"items": targets[:50],
"message": f"{len(targets)} stale Favorites collection(s) {'would be removed' if dry_run else 'removed'}.",
}
async def _watched_favorites_cleanup(client, *, dry_run: bool, config: dict) -> dict:
collections = await emby_collections.find_favorites_collections(client)
users = {u["name"].casefold(): u for u in await emby_users.fetch_users(client)}
summaries = []
watched_found = 0
removed_count = 0
for collection in collections:
owner = users.get((collection["owner_name"] or "").casefold())
if not owner:
continue
result = await favorites_service.cleanup_watched(client, collection["collection_id"], owner["id"], dry_run=dry_run)
if result["watched_found"]:
summaries.append({
"collection_id": collection["collection_id"],
"collection_name": collection["collection_name"],
"user_name": owner["name"],
"watched_found": result["watched_found"],
"removed_count": result["summary"]["removed_count"],
})
watched_found += result["watched_found"]
removed_count += result["summary"]["removed_count"]
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(summaries),
"watched_found": watched_found,
"removed_count": removed_count,
"items": summaries[:50],
"message": f"{watched_found} watched item(s) {'would be removed' if dry_run else 'removed'} across {len(summaries)} collection(s).",
}
async def _duplicate_collections(client, *, dry_run: bool, config: dict) -> dict:
collections = await emby_collections.find_all_collections(client)
groups: dict[str, list[dict]] = {}
for collection in collections:
groups.setdefault(_normalize_name(collection["collection_name"]), []).append(collection)
duplicates = []
for members in groups.values():
if len(members) < 2 or not members[0]["collection_name"]:
continue
ordered = sorted(members, key=lambda item: (-int(item.get("item_count") or 0), len(item["collection_name"]), item["collection_name"].casefold(), item["collection_id"]))
keep = ordered[0]
for dupe in ordered[1:]:
duplicates.append({
"keep_id": keep["collection_id"],
"keep_name": keep["collection_name"],
"remove_id": dupe["collection_id"],
"remove_name": dupe["collection_name"],
"remove_item_count": dupe.get("item_count"),
})
if not dry_run:
for dupe in duplicates:
await client.delete(f"/Items/{dupe['remove_id']}")
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(duplicates),
"removed_count": 0 if dry_run else len(duplicates),
"items": duplicates[:50],
"message": f"{len(duplicates)} duplicate collection(s) {'would be removed' if dry_run else 'removed'}.",
}
async def _broken_library_paths(client, *, dry_run: bool, config: dict) -> dict:
data = await client.get("/Library/SelectableMediaFolders")
folders = data if isinstance(data, list) else data.get("Items", []) if isinstance(data, dict) else []
broken = []
for folder in folders:
folder_id = folder.get("Id") or folder.get("Guid") or ""
folder_name = folder.get("Name") or "Library"
for sub in folder.get("SubFolders") or []:
path = str(sub.get("Path") or "").strip()
if not path:
continue
if not Path(path).exists():
broken.append({"library_id": folder_id, "library_name": folder_name, "path": path})
if not dry_run:
for entry in broken:
await client.post(
"/Library/VirtualFolders/Paths/Delete",
json={"Id": entry["library_id"], "Path": entry["path"], "RefreshLibrary": True},
)
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(broken),
"removed_count": 0 if dry_run else len(broken),
"items": broken[:50],
"message": f"{len(broken)} broken library path(s) {'would be removed' if dry_run else 'removed'}.",
}
async def _generated_artwork_cache(client, *, dry_run: bool, config: dict) -> dict:
cutoff = _age_cutoff_days(int(config["retention_days"]))
targets = []
if CACHE_DIR.exists():
for path in CACHE_DIR.glob("*.png"):
try:
if path.stat().st_mtime < cutoff:
targets.append(path)
except OSError:
continue
removed, freed = _remove_paths(targets, dry_run=dry_run)
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(removed),
"removed_count": 0 if dry_run else len(removed),
"freed_bytes": 0 if dry_run else freed,
"items": removed[:50],
"message": f"{len(removed)} generated artwork cache file(s) {'would be removed' if dry_run else 'removed'}.",
}
async def _unused_metadata_cache(client, *, dry_run: bool, config: dict) -> dict:
cutoff = _age_cutoff_days(int(config["retention_days"]))
targets = []
for root in (EMBY_IMAGE_CACHE_DIR, CLEAN_POSTER_CACHE_DIR, IMPORT_CACHE_DIR):
if not root.exists():
continue
for path in root.rglob("*"):
if not path.is_file():
continue
try:
if path.stat().st_mtime < cutoff:
targets.append(path)
except OSError:
continue
removed, freed = _remove_paths(targets, dry_run=dry_run)
return {
"ok": True,
"dry_run": dry_run,
"matched_count": len(removed),
"removed_count": 0 if dry_run else len(removed),
"freed_bytes": 0 if dry_run else freed,
"items": removed[:50],
"message": f"{len(removed)} metadata/source cache file(s) {'would be removed' if dry_run else 'removed'}.",
}
def _summarize_music_actions(result: dict, *, dry_run: bool, interesting_actions: set[str], message_builder) -> dict:
changes = [
action for action in result.get("actions", [])
if action.get("action") in interesting_actions and action.get("level") in {"dry", "ok"}
]
change_count = len(changes)
sample = changes[:50]
return {
"ok": True,
"dry_run": dry_run,
"matched_count": change_count,
"removed_count": 0 if dry_run else change_count,
"counts": result.get("counts", {}),
"items": sample,
"message": message_builder(change_count, dry_run),
}
def _music_root_missing_result(*, dry_run: bool) -> dict:
return {
"ok": False,
"dry_run": dry_run,
"matched_count": 0,
"removed_count": 0,
"items": [],
"message": f"Music root is not mounted: {music_service.MUSIC_ROOT}",
}
async def _navidrome_recent_maintenance(client, *, dry_run: bool, config: dict) -> dict:
if not _music_root_available():
return _music_root_missing_result(dry_run=dry_run)
options = ProcessOptions(folder_cleanup=True, rename=True, covers=True, dry_run=dry_run, recent_only=True)
result = await asyncio.to_thread(music_service.process_library, options)
return _summarize_music_actions(
result,
dry_run=dry_run,
interesting_actions={"folder", "rename", "cover"},
message_builder=lambda count, is_dry: f"{count} recent library change(s) {'would be applied' if is_dry else 'applied'}.",
)
async def _navidrome_cover_backfill(client, *, dry_run: bool, config: dict) -> dict:
if not _music_root_available():
return _music_root_missing_result(dry_run=dry_run)
options = ProcessOptions(covers=True, dry_run=dry_run)
result = await asyncio.to_thread(music_service.process_library, options)
return _summarize_music_actions(
result,
dry_run=dry_run,
interesting_actions={"cover"},
message_builder=lambda count, is_dry: f"{count} missing cover(s) {'would be downloaded' if is_dry else 'downloaded'}.",
)
async def _navidrome_lyrics_backfill(client, *, dry_run: bool, config: dict) -> dict:
if not _music_root_available():
return _music_root_missing_result(dry_run=dry_run)
options = ProcessOptions(lyrics=True, dry_run=dry_run, covers=False)
result = await asyncio.to_thread(music_service.process_library, options)
return _summarize_music_actions(
result,
dry_run=dry_run,
interesting_actions={"lyrics"},
message_builder=lambda count, is_dry: f"{count} lyric sidecar(s) {'would be downloaded' if is_dry else 'downloaded'}.",
)
async def _navidrome_file_cleanup(client, *, dry_run: bool, config: dict) -> dict:
if not _music_root_available():
return _music_root_missing_result(dry_run=dry_run)
options = ProcessOptions(file_cleanup=True, dry_run=dry_run, covers=False)
result = await asyncio.to_thread(music_service.process_library, options)
return _summarize_music_actions(
result,
dry_run=dry_run,
interesting_actions={"remove"},
message_builder=lambda count, is_dry: f"{count} extra file(s) {'would be removed' if is_dry else 'removed'}.",
)
async def _unsupported(task_id: str, *, dry_run: bool) -> dict:
return {
"ok": False,
"dry_run": dry_run,
"matched_count": 0,
"removed_count": 0,
"items": [],
"message": f"{TASK_DEFS[task_id]['title']} requires Emby server data access and is not available from this deployment yet.",
}
RUNNERS = {
"orphan_custom_images": _unsupported,
"empty_collections": _empty_collections,
"stale_favorites": _stale_favorites,
"watched_favorites_cleanup": _watched_favorites_cleanup,
"duplicate_collections": _duplicate_collections,
"broken_library_paths": _broken_library_paths,
"generated_artwork_cache": _generated_artwork_cache,
"unused_metadata_cache": _unused_metadata_cache,
"navidrome_recent_maintenance": _navidrome_recent_maintenance,
"navidrome_cover_backfill": _navidrome_cover_backfill,
"navidrome_lyrics_backfill": _navidrome_lyrics_backfill,
"navidrome_file_cleanup": _navidrome_file_cleanup,
}
async def run_task(task_id: str, client, settings: dict[str, dict[str, Any]], *, dry_run: bool) -> dict:
if task_id not in TASK_DEFS:
raise KeyError(task_id)
runner = RUNNERS[task_id]
if runner is _unsupported:
return await runner(task_id, dry_run=dry_run)
return await runner(client, dry_run=dry_run, config=settings[task_id])
+634
View File
@@ -0,0 +1,634 @@
from __future__ import annotations
import json
import os
import sqlite3
import uuid
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Any
EMBY_USERS_CACHE_PATH = Path(os.environ.get("EMBY_USER_CACHE_PATH", "cache/homescreen-emby-users.json"))
EMBY_USER_CONTEXT_CACHE_PATH = Path(os.environ.get("EMBY_USER_CONTEXT_CACHE_PATH", "cache/homescreen-emby-user-context.json"))
HOMESCREEN_UPLOAD_DIR = Path(os.environ.get("HOMESCREEN_UPLOAD_DIR", "cache/homescreen_uploads"))
HOMESCREEN_UPLOAD_STATE_PATH = Path(os.environ.get("HOMESCREEN_UPLOAD_STATE_PATH", "cache/homescreen-upload-state.json"))
SECTION_TYPES = [
{"value": "resume", "label": "Resume / Next Up"},
{"value": "items", "label": "Items (filtered)"},
{"value": "userviews", "label": "Libraries"},
{"value": "boxset", "label": "Box Set"},
{"value": "collections", "label": "Collections"},
{"value": "latestepisodereleases", "label": "Latest episode releases"},
{"value": "latestmoviereleases", "label": "Latest movie releases"},
{"value": "latestmediablock", "label": "Latest media"},
]
COLLECTION_TYPES = [
{"value": "", "label": "(none)"},
{"value": "movies", "label": "Movies"},
{"value": "tvshows", "label": "TV Shows"},
{"value": "boxsets", "label": "Box Sets"},
]
ITEM_TYPES = ["Movie", "Series", "Episode", "BoxSet"]
SORT_OPTIONS = [
{"value": "", "label": "(none)"},
{"value": "default", "label": "Default (boxset)"},
{"value": "DatePlayed", "label": "Date played"},
{"value": "DateLastContentAdded,SortName", "label": "Date added"},
{"value": "ProductionYear,PremiereDate,SortName", "label": "Release year"},
{"value": "CommunityRating", "label": "Community rating"},
{"value": "CriticRating,SortName", "label": "Critic rating"},
{"value": "DateCreated,SortName", "label": "Date created"},
{"value": "Random", "label": "Random"},
{"value": "SortName", "label": "Name"},
]
IMAGE_TYPES = [
{"value": "", "label": "Default"},
{"value": "Thumb", "label": "Thumb"},
{"value": "Primary", "label": "Primary / Poster"},
]
def enums_payload() -> dict[str, Any]:
return {
"section_types": SECTION_TYPES,
"collection_types": COLLECTION_TYPES,
"item_types": ITEM_TYPES,
"sort_options": SORT_OPTIONS,
"image_types": IMAGE_TYPES,
}
def normalize_guid(value: str | None) -> str:
return str(value or "").replace("-", "").strip().lower()
def gen_id() -> str:
return uuid.uuid4().hex[:32]
def create_empty_section(user_id: str) -> dict[str, Any]:
return {
"UserId": user_id,
"Name": "New Section",
"CustomName": "New Section",
"Id": gen_id(),
"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,
},
}
def create_recently_watched_section(user_id: str, user_name: str = "") -> dict[str, Any]:
label = f"Recently Watched - {user_name}" if user_name else "Recently Watched"
return {
"UserId": user_id,
"Name": label,
"CustomName": label,
"Id": gen_id(),
"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,
},
}
def create_boxset_section(user_id: str, collection_name: str = "", collection_id: str = "") -> dict[str, Any]:
label = collection_name or "New Collection"
return {
"UserId": user_id,
"Name": label,
"CustomName": label,
"Id": gen_id(),
"SectionType": "boxset",
"ImageType": "Thumb",
"ItemTypes": [],
"SortBy": "Random",
"SortOrder": "Descending",
"Monitor": [],
"ExcludedFolders": [],
"CardSizeOffset": 0,
"IncludeNextUpInResume": True,
"ParentItem": {
"Name": label,
"Id": str(collection_id or ""),
},
"ParentId": str(collection_id or ""),
}
def normalize_sections_for_user(sections: Any, expected_emby_guid: str) -> list[dict[str, Any]]:
if not isinstance(sections, list):
return []
if not expected_emby_guid:
return sections
normalized: list[dict[str, Any]] = []
for section in sections:
if not isinstance(section, dict):
continue
next_section = dict(section)
next_section["UserId"] = expected_emby_guid
normalized.append(next_section)
return normalized
def _parse_json_blob(blob: Any) -> dict | None:
if blob is None:
return None
try:
text = blob if isinstance(blob, str) else bytes(blob).decode("utf-8")
return json.loads(text)
except Exception:
return None
def blob_to_emby_guid(blob: bytes | bytearray | memoryview | None) -> str:
if not blob:
return ""
raw = bytes(blob)
if len(raw) != 16:
return raw.hex().lower()
reordered = bytes(
[
raw[3], raw[2], raw[1], raw[0],
raw[5], raw[4],
raw[7], raw[6],
raw[8], raw[9], raw[10], raw[11], raw[12], raw[13], raw[14], raw[15],
]
)
return reordered.hex().lower()
def _has_table(conn: sqlite3.Connection, table_name: str) -> bool:
row = conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", (table_name,)).fetchone()
return bool(row)
def _users_table_columns(conn: sqlite3.Connection) -> list[str]:
rows = conn.execute("PRAGMA table_info(Users)").fetchall()
return [str(row[1]) for row in rows]
def _find_column(columns: list[str], *patterns: str) -> str | None:
lower_map = {col.lower(): col for col in columns}
for pattern in patterns:
for lower, original in lower_map.items():
if lower == pattern.lower():
return original
return None
def _load_users_table_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
columns = _users_table_columns(conn)
name_col = _find_column(columns, "Username", "Name")
guid_col = _find_column(columns, "Guid")
id_col = _find_column(columns, "Id") or "Id"
if not name_col:
raise RuntimeError(f"Cannot find a name column in Users table. Columns found: {', '.join(columns)}")
select_cols = ", ".join([col for col in [id_col, name_col, guid_col] if col])
rows = conn.execute(f"SELECT {select_cols} FROM Users").fetchall()
users: list[dict[str, Any]] = []
for row in rows:
raw_id = row[id_col]
raw_guid = row[guid_col] if guid_col else None
emby_guid = ""
guid = ""
if raw_guid:
if isinstance(raw_guid, (bytes, bytearray, memoryview)):
buf = bytes(raw_guid)
guid = buf.hex().upper()
emby_guid = blob_to_emby_guid(buf)
elif isinstance(raw_guid, str):
clean = normalize_guid(raw_guid)
emby_guid = clean
guid = clean.upper()
users.append(
{
"id": raw_id,
"name": row[name_col] or f"User {raw_id}",
"guid": guid,
"embyGuid": emby_guid,
"sourceTable": "Users",
}
)
return users
def _load_local_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
rows = conn.execute("SELECT Id, guid, data FROM LocalUsersv2").fetchall()
users: list[dict[str, Any]] = []
for row in rows:
parsed = _parse_json_blob(row["data"])
guid_blob = row["guid"]
guid = bytes(guid_blob).hex().upper() if guid_blob else ""
emby_guid_from_blob = blob_to_emby_guid(guid_blob)
emby_guid_from_json = normalize_guid((parsed or {}).get("IdString"))
emby_guid = emby_guid_from_json or emby_guid_from_blob
users.append(
{
"id": row["Id"],
"name": (parsed or {}).get("Name") or f"User {row['Id']}",
"guid": guid,
"embyGuid": emby_guid,
"sourceTable": "LocalUsersv2",
"profile": parsed,
}
)
return users
def load_canonical_users(conn: sqlite3.Connection) -> list[dict[str, Any]]:
if _has_table(conn, "LocalUsersv2"):
return _load_local_users(conn)
if _has_table(conn, "Users"):
return _load_users_table_users(conn)
raise RuntimeError("No supported user table found. Expected LocalUsersv2 or Users.")
def _home_screen_setting_rows(conn: sqlite3.Connection) -> list[sqlite3.Row]:
return conn.execute(
"""
SELECT us.UserId, us.Value
FROM UserSettings us
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
WHERE usk.Name = 'homescreensettings'
"""
).fetchall()
def read_db(db_path: str) -> dict[str, Any]:
path = Path(db_path)
if not path.exists():
raise FileNotFoundError(f"Database file not found: {path}")
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
try:
users = load_canonical_users(conn)
settings_rows = _home_screen_setting_rows(conn)
settings_map = {str(row["UserId"]): row["Value"] for row in settings_rows}
user_ids = {str(user["id"]) for user in users}
matched_users = 0
mismatched_users = 0
normalized_users = 0
missing_section_user_ids = 0
hydrated_users: list[dict[str, Any]] = []
for user in users:
raw_value = settings_map.get(str(user["id"]))
sections: list[dict[str, Any]] = []
try:
if raw_value:
sections = (json.loads(raw_value) or {}).get("Sections") or []
except Exception:
sections = []
actual_user_ids = sorted({normalize_guid(section.get("UserId")) for section in sections if normalize_guid(section.get("UserId"))})
mismatched_section_user_ids = (
[value for value in actual_user_ids if value != user["embyGuid"]]
if user.get("embyGuid")
else list(actual_user_ids)
)
missing_ids_for_user = sum(1 for section in sections if not normalize_guid(section.get("UserId")))
normalized_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
sections_were_normalized = json.dumps(sections, sort_keys=True) != json.dumps(normalized_sections, sort_keys=True)
if mismatched_section_user_ids:
mismatched_users += 1
else:
matched_users += 1
if sections_were_normalized:
normalized_users += 1
missing_section_user_ids += missing_ids_for_user
profile = user.get("profile") or {}
hydrated_users.append(
{
"id": user["id"],
"name": user["name"],
"dbName": user["name"],
"guid": user.get("guid", ""),
"embyGuid": user.get("embyGuid", ""),
"embyName": None,
"sections": normalized_sections,
"details": {
"sourceTable": user["sourceTable"],
"lastLoginDate": profile.get("LastLoginDate"),
"lastActivityDate": profile.get("LastActivityDate"),
"usesIdForConfigurationPath": profile.get("UsesIdForConfigurationPath"),
"importedCollectionsCount": len(profile.get("ImportedCollections") or []) if isinstance(profile.get("ImportedCollections"), list) else 0,
},
"match": {
"sourceTable": user["sourceTable"],
"settingsUserId": user["id"],
"expectedSectionUserId": user.get("embyGuid", ""),
"actualSectionUserIds": actual_user_ids,
"mismatchedSectionUserIds": mismatched_section_user_ids,
"missingSectionUserIds": missing_ids_for_user,
"ok": not mismatched_section_user_ids,
},
}
)
orphaned_settings_user_ids = sorted({str(row["UserId"]) for row in settings_rows if str(row["UserId"]) not in user_ids})
return {
"users": hydrated_users,
"validation": {
"userSource": users[0]["sourceTable"] if users else None,
"userCount": len(hydrated_users),
"settingsCount": len(settings_rows),
"matchedUsers": matched_users,
"mismatchedUsers": mismatched_users,
"normalizedUsers": normalized_users,
"missingSectionUserIds": missing_section_user_ids,
"orphanedSettingsUserIds": orphaned_settings_user_ids,
},
}
finally:
conn.close()
def write_db(db_path: str, changes: list[dict[str, Any]]) -> dict[str, Any]:
path = Path(db_path)
if not path.exists():
raise FileNotFoundError(f"Database file not found: {path}")
conn = sqlite3.connect(str(path))
conn.row_factory = sqlite3.Row
try:
user_lookup = {str(user["id"]): user for user in load_canonical_users(conn)}
key_row = conn.execute(
"SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'"
).fetchone()
if not key_row:
raise RuntimeError("'homescreensettings' key not found in UserSettingsKeys table")
key_id = key_row["UserSettingsKeyId"]
count = 0
normalized_sections = 0
conn.execute("BEGIN")
try:
for change in changes:
user_id = str(change.get("userId"))
sections = change.get("sections")
user = user_lookup.get(user_id)
if not user:
raise RuntimeError(f"UserId {user_id} does not exist in {path}")
next_sections = normalize_sections_for_user(sections, user.get("embyGuid", ""))
if json.dumps(next_sections, sort_keys=True) != json.dumps(sections, sort_keys=True):
normalized_sections += len(next_sections)
value = json.dumps({"Sections": next_sections}, separators=(",", ":"))
exists = conn.execute(
"SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?",
(change.get("userId"), key_id),
).fetchone()
if exists:
conn.execute(
"UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?",
(value, change.get("userId"), key_id),
)
else:
conn.execute(
"INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)",
(change.get("userId"), key_id, value),
)
count += 1
conn.commit()
except Exception:
conn.rollback()
raise
return {"ok": True, "count": count, "normalizedSections": normalized_sections}
finally:
conn.close()
def generate_sql(users: list[dict[str, Any]], original_users: list[dict[str, Any]]) -> str:
statements: list[str] = []
original_lookup = {str(user.get("id")): user for user in original_users}
for user in users:
sections = user.get("sections")
if not isinstance(sections, list):
continue
original = original_lookup.get(str(user.get("id")))
if not original:
continue
orig_json = json.dumps({"Sections": original.get("sections") or []}, separators=(",", ":"))
new_json = json.dumps({"Sections": sections}, separators=(",", ":"))
if orig_json == new_json:
continue
escaped_value = new_json.replace("'", "''")
statements.extend(
[
f"-- User: {user.get('name', 'Unknown')} (DB ID: {user.get('id')})",
"UPDATE UserSettings "
f"SET Value = '{escaped_value}' "
f"WHERE UserId = {user.get('id')} "
"AND UserSettingsKeyId = "
"(SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');",
"",
]
)
if not statements:
return "-- No changes detected"
header = [
"-- ===========================================",
"-- Emby Home Screen Settings Update",
f"-- Generated: {datetime.now().astimezone().isoformat(timespec='seconds')}",
"-- ===========================================",
"-- IMPORTANT: Stop Emby before running this!",
"-- sqlite3 /path/to/users.db < this_file.sql",
"-- Then restart Emby.",
"-- ===========================================",
"",
"BEGIN TRANSACTION;",
"",
]
return "\n".join(header + statements + ["COMMIT;"])
def _read_json_cache(path: Path, default: Any) -> Any:
if not path.exists():
return default
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default
def _write_json_cache(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
def _iso_now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _upload_path(upload_id: str) -> Path:
safe_id = "".join(ch for ch in str(upload_id or "") if ch.isalnum() or ch in ("-", "_")).strip()
if not safe_id:
raise ValueError("Invalid upload id.")
return HOMESCREEN_UPLOAD_DIR / f"{safe_id}.db"
def get_active_upload() -> dict[str, Any] | None:
payload = _read_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, {})
if not isinstance(payload, dict):
return None
upload_id = str(payload.get("upload_id") or "").strip()
if not upload_id:
return None
path = _upload_path(upload_id)
if not path.exists():
return None
return {
"upload_id": upload_id,
"filename": str(payload.get("filename") or path.name),
"size_bytes": int(payload.get("size_bytes") or path.stat().st_size),
"uploaded_at": payload.get("uploaded_at"),
"sha256": str(payload.get("sha256") or ""),
"path": str(path),
}
def save_uploaded_db(filename: str, content: bytes) -> dict[str, Any]:
if not content:
raise ValueError("Uploaded file is empty.")
HOMESCREEN_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
upload_id = uuid.uuid4().hex
path = _upload_path(upload_id)
path.write_bytes(content)
meta = {
"upload_id": upload_id,
"filename": Path(filename or "users.db").name or "users.db",
"size_bytes": len(content),
"uploaded_at": _iso_now(),
"sha256": hashlib.sha256(content).hexdigest(),
}
_write_json_cache(HOMESCREEN_UPLOAD_STATE_PATH, meta)
return {**meta, "path": str(path)}
def resolve_db_source(db_path: str | None = None, upload_id: str | None = None) -> tuple[str, dict[str, Any] | None]:
if upload_id:
path = _upload_path(upload_id)
if not path.exists():
raise FileNotFoundError(f"Uploaded database not found for id {upload_id}.")
active = get_active_upload()
if active and active.get("upload_id") == upload_id:
return str(path), active
stat = path.stat()
return str(path), {
"upload_id": upload_id,
"filename": path.name,
"size_bytes": stat.st_size,
"uploaded_at": None,
"sha256": "",
"path": str(path),
}
active = get_active_upload()
if active:
return active["path"], active
if db_path:
return db_path, None
raise FileNotFoundError("No homescreen database source configured.")
def read_cached_emby_users() -> dict[str, Any]:
payload = _read_json_cache(EMBY_USERS_CACHE_PATH, {"users": [], "lastSyncedAt": None})
users = payload.get("users") if isinstance(payload, dict) else []
last_synced = payload.get("lastSyncedAt") if isinstance(payload, dict) else None
normalized = [
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
for user in users
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
]
normalized.sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
return {"users": normalized, "lastSyncedAt": last_synced}
def write_cached_emby_users(users: list[dict[str, Any]]) -> dict[str, Any]:
fetched_at = datetime.now().astimezone().isoformat(timespec="seconds")
payload = {
"users": [
{"embyGuid": normalize_guid(user.get("embyGuid")), "name": str(user.get("name") or "").strip()}
for user in users
if normalize_guid(user.get("embyGuid")) and str(user.get("name") or "").strip()
],
"lastSyncedAt": fetched_at,
}
payload["users"].sort(key=lambda user: (user["name"].lower(), user["embyGuid"]))
_write_json_cache(EMBY_USERS_CACHE_PATH, payload)
return payload
def apply_cached_emby_names(users: list[dict[str, Any]]) -> dict[str, Any]:
cached = read_cached_emby_users()
lookup = {user["embyGuid"]: user for user in cached["users"]}
enriched = []
matched = 0
for user in users:
emby_guid = normalize_guid(user.get("embyGuid"))
cached_user = lookup.get(emby_guid)
if cached_user:
matched += 1
enriched.append(
{
**user,
"dbName": user.get("dbName") or user.get("name"),
"embyName": cached_user["name"] if cached_user else user.get("embyName"),
"name": cached_user["name"] if cached_user else user.get("name"),
}
)
return {
"users": enriched,
"cache": {
"matchedCount": matched,
"totalCachedUsers": len(cached["users"]),
"lastSyncedAt": cached.get("lastSyncedAt"),
},
}
def read_cached_user_context(emby_guid: str) -> dict[str, Any] | None:
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
return payload.get(normalize_guid(emby_guid)) if isinstance(payload, dict) else None
def write_cached_user_context(emby_guid: str, context: dict[str, Any]) -> dict[str, Any]:
payload = _read_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, {})
normalized_guid = normalize_guid(emby_guid)
payload[normalized_guid] = context
_write_json_cache(EMBY_USER_CONTEXT_CACHE_PATH, payload)
return context
+108 -1
View File
@@ -14,12 +14,15 @@ Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via
from __future__ import annotations
import logging
import os
import queue
import re
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from typing import Callable, Iterator
import requests
@@ -27,6 +30,10 @@ try: # Optional: only needed for the "find missing year/cover" online lookups.
import musicbrainzngs
musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com")
# musicbrainzngs logs "uncaught attribute"/"uncaught tag" at INFO whenever the
# MusicBrainz XML carries fields it doesn't model (e.g. release-group type-id).
# Harmless noise — keep only real warnings.
logging.getLogger("musicbrainzngs").setLevel(logging.WARNING)
_HAS_MUSICBRAINZ = True
except Exception: # pragma: no cover - optional dependency
_HAS_MUSICBRAINZ = False
@@ -46,6 +53,9 @@ YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$")
LogCallback = Callable[[dict], None]
DEFAULT_RECENT_WINDOW_SECONDS = 2 * 60 * 60
@dataclass
class ProcessOptions:
folder_cleanup: bool = False
@@ -54,9 +64,15 @@ class ProcessOptions:
lyrics: bool = False
covers: bool = True
dry_run: bool = True
recent_only: bool = False
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
@classmethod
def from_dict(cls, data: dict) -> "ProcessOptions":
try:
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
except (TypeError, ValueError):
window = DEFAULT_RECENT_WINDOW_SECONDS
return cls(
folder_cleanup=bool(data.get("folder_cleanup", False)),
rename=bool(data.get("rename", False)),
@@ -64,6 +80,8 @@ class ProcessOptions:
lyrics=bool(data.get("lyrics", False)),
covers=bool(data.get("covers", True)),
dry_run=bool(data.get("dry_run", True)),
recent_only=bool(data.get("recent_only", False)),
recent_window_seconds=window,
)
@@ -246,6 +264,16 @@ def analyze_album(album_folder: Path) -> dict:
}
def _was_created_recently(folder: Path, window_seconds: int) -> bool:
"""True if the folder was created/modified within the window (recent-only mode)."""
try:
stat = folder.stat()
except OSError:
return False
age = time.time() - max(stat.st_ctime, stat.st_mtime)
return 0 <= age <= window_seconds
def _iter_album_folders(root: Path):
for first_level in root.iterdir():
if not first_level.is_dir():
@@ -513,6 +541,16 @@ def process_library(
else:
folders = list(_iter_album_folders(root))
if options.recent_only and not album_paths:
before = len(folders)
folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
rec.emit(
"info",
"recent",
f"Recent-only: {len(folders)} of {before} albums modified in the last "
f"{options.recent_window_seconds // 3600}h",
)
rec.emit(
"info",
"start",
@@ -531,3 +569,72 @@ def process_library(
"actions": rec.actions,
"counts": rec.counts,
}
# ── streaming variants (disk-efficient; yield as work happens) ────────────────
def scan_library_stream(root: Path | None = None) -> Iterator[dict]:
"""Yield one album analysis at a time so the UI can render incrementally.
Memory stays flat (no full list is accumulated) and the slow NAS walk
streams results to the caller as each album folder is inspected.
"""
root = root or MUSIC_ROOT
if not root.exists():
yield {"type": "error", "message": f"Music root not found: {root}"}
return
yield {"type": "start", "root": str(root)}
album_count = missing_cover = needs_rename = extra_files = 0
for album_folder in _iter_album_folders(root):
try:
album = analyze_album(album_folder)
except OSError:
continue
album_count += 1
if not album["has_cover"]:
missing_cover += 1
if album["needs_folder_rename"]:
needs_rename += 1
extra_files += album["extra_file_count"]
yield {"type": "album", "album": album, "scanned": album_count}
yield {
"type": "summary",
"root": str(root),
"album_count": album_count,
"missing_cover_count": missing_cover,
"needs_rename_count": needs_rename,
"extra_file_count": extra_files,
}
def process_library_stream(
options: ProcessOptions,
*,
root: Path | None = None,
album_paths: list[str] | None = None,
) -> Iterator[dict]:
"""Run maintenance and yield each action the moment it happens.
``process_library`` already reports through a ``log`` callback; we bridge that
to a queue drained by this generator so the HTTP response streams live.
"""
events: queue.Queue = queue.Queue()
sentinel = object()
def worker():
try:
process_library(options, root=root, log=events.put, album_paths=album_paths)
except Exception as exc: # pragma: no cover - defensive
events.put({"level": "warn", "action": "error", "message": str(exc)})
finally:
events.put(sentinel)
threading.Thread(target=worker, name="music_process_stream", daemon=True).start()
while True:
item = events.get()
if item is sentinel:
break
yield item
+628
View File
@@ -0,0 +1,628 @@
"""Music tag maintenance — the metadata sibling to :mod:`music_covers`.
Where ``music_covers`` renames folders/files and fetches sidecar art, this module
rewrites the *tags inside* audio files. Three jobs, all honouring ``dry_run``:
* **Genres** look the album up on MusicBrainz and write a single canonical
genre to every track, so the library's genre facet stays consistent.
* **Junk** strip comment / encoder / URL / embedded-lyrics frames that rippers
and stores leave behind.
* **Track numbers** normalize the ``tracknumber`` / ``discnumber`` tag value
(drop the ``/total`` suffix, zero-pad to two digits).
Format-aware: MP3 (ID3), FLAC/Ogg (Vorbis comments) and M4A (MP4 atoms) each get
their own handlers, dispatched on the loaded tag object. Like ``music_covers``,
everything is synchronous (filesystem + blocking HTTP) call from FastAPI via
``asyncio.to_thread`` or the streaming generator below.
"""
from __future__ import annotations
import logging
import queue
import re
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
from mutagen import File as MutagenFile, MutagenError
from mutagen.id3 import ID3
from mutagen.mp4 import MP4Tags
from . import db
from .music_covers import (
AUDIO_EXTENSIONS,
MUSIC_ROOT,
DEFAULT_RECENT_WINDOW_SECONDS,
LogCallback,
_Recorder,
_iter_album_folders,
_was_created_recently,
clean_track_number,
get_album_metadata_from_files,
)
logger = logging.getLogger("homelabtoolkit.music_metadata")
try: # Optional: only needed for the online genre lookups.
import musicbrainzngs
_HAS_MUSICBRAINZ = True
except Exception: # pragma: no cover - optional dependency
_HAS_MUSICBRAINZ = False
# ── options ───────────────────────────────────────────────────────────────────
@dataclass
class MetadataOptions:
genres: bool = False
strip_junk: bool = False
normalize_tracks: bool = False
dry_run: bool = True
recent_only: bool = False
recent_window_seconds: int = DEFAULT_RECENT_WINDOW_SECONDS
@classmethod
def from_dict(cls, data: dict) -> "MetadataOptions":
try:
window = int(data.get("recent_window_seconds") or DEFAULT_RECENT_WINDOW_SECONDS)
except (TypeError, ValueError):
window = DEFAULT_RECENT_WINDOW_SECONDS
return cls(
genres=bool(data.get("genres", False)),
strip_junk=bool(data.get("strip_junk", False)),
normalize_tracks=bool(data.get("normalize_tracks", False)),
dry_run=bool(data.get("dry_run", True)),
recent_only=bool(data.get("recent_only", False)),
recent_window_seconds=window,
)
@property
def any_mode(self) -> bool:
return self.genres or self.strip_junk or self.normalize_tracks
# ── genre canonicalization ────────────────────────────────────────────────────
# MusicBrainz genres are lowercase; map the messy/spaced forms to a clean label.
_GENRE_CANON = {
"hip hop": "Hip-Hop",
"hip-hop": "Hip-Hop",
"rnb": "R&B",
"r and b": "R&B",
"rhythm and blues": "R&B",
"drum and bass": "Drum & Bass",
"dnb": "Drum & Bass",
"edm": "EDM",
"idm": "IDM",
"uk garage": "UK Garage",
}
_ACRONYMS = {"edm", "idm", "uk", "us", "dj"}
def canonical_genre(name: str | None) -> str | None:
if not name:
return None
key = name.strip().lower()
if not key:
return None
if key in _GENRE_CANON:
return _GENRE_CANON[key]
words = [w.upper() if w in _ACRONYMS else w.capitalize() for w in re.split(r"\s+", key)]
return " ".join(words)
def find_album_genre(artist: str, album: str) -> str | None:
"""Return a single canonical genre for an album via MusicBrainz, or None.
Prefers the release-group's curated genres (highest vote count); falls back
to its folksonomy tags, then to the artist's genres.
"""
if not _HAS_MUSICBRAINZ or not (artist and album):
return None
try:
result = musicbrainzngs.search_release_groups(artist=artist, releasegroup=album, limit=3)
groups = result.get("release-group-list", [])
if not groups:
return None
rgid = groups[0]["id"]
genre = _genre_from_release_group(rgid)
if genre:
return genre
artist_credit = groups[0].get("artist-credit") or []
for credit in artist_credit:
mbid = (credit.get("artist") or {}).get("id") if isinstance(credit, dict) else None
if mbid:
genre = _genre_from_artist(mbid)
if genre:
return genre
except Exception as exc: # network / parse / lookup failure — never fatal
logger.debug("Genre lookup failed for %s - %s: %s", artist, album, exc)
return None
return None
def find_artist_genre(artist: str) -> str | None:
"""Return a single canonical genre for an *artist* via MusicBrainz, or None.
Resolved once per artist so every album by that artist gets the same genre
(consistency over per-album accuracy e.g. all A Perfect Circle albums land
on one genre rather than a mix of Alternative Rock / Alternative Metal).
"""
if not _HAS_MUSICBRAINZ or not artist:
return None
try:
result = musicbrainzngs.search_artists(artist=artist, limit=3)
matches = result.get("artist-list", [])
if not matches:
return None
return _genre_from_artist(matches[0]["id"])
except Exception as exc: # network / parse / lookup failure — never fatal
logger.debug("Artist genre lookup failed for %s: %s", artist, exc)
return None
# ── manual genre overrides (user-editable, persisted) ─────────────────────────
def _artist_key(artist: str) -> str:
return (artist or "").strip().lower()
def list_genre_overrides() -> list[dict]:
with db.connect() as conn:
rows = conn.execute(
"SELECT artist, genre, updated_at FROM genre_overrides ORDER BY artist COLLATE NOCASE"
).fetchall()
return [dict(r) for r in rows]
def set_genre_override(artist: str, genre: str) -> dict:
artist = (artist or "").strip()
genre = (genre or "").strip()
if not artist or not genre:
raise ValueError("Both artist and genre are required.")
with db.connect() as conn:
conn.execute(
"INSERT INTO genre_overrides(artist_key, artist, genre, updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(artist_key) DO UPDATE SET artist=excluded.artist, genre=excluded.genre, "
"updated_at=excluded.updated_at",
(_artist_key(artist), artist, genre, db.now_iso()),
)
return {"artist": artist, "genre": genre}
def delete_genre_override(artist: str) -> None:
with db.connect() as conn:
conn.execute("DELETE FROM genre_overrides WHERE artist_key=?", (_artist_key(artist),))
def _overrides_map() -> dict[str, str]:
return {_artist_key(o["artist"]): o["genre"] for o in list_genre_overrides()}
def _best_genre(entries: list[dict] | None) -> str | None:
if not entries:
return None
best = max(entries, key=lambda g: int(g.get("count") or 0))
return canonical_genre(best.get("name"))
def _genre_from_release_group(rgid: str) -> str | None:
try:
detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["genres"])
except Exception:
try:
detail = musicbrainzngs.get_release_group_by_id(rgid, includes=["tags"])
except Exception:
return None
rg = detail.get("release-group") or {}
return _best_genre(rg.get("genre-list")) or _best_genre(rg.get("tag-list"))
def _genre_from_artist(mbid: str) -> str | None:
try:
detail = musicbrainzngs.get_artist_by_id(mbid, includes=["genres"])
except Exception:
try:
detail = musicbrainzngs.get_artist_by_id(mbid, includes=["tags"])
except Exception:
return None
artist = detail.get("artist") or {}
return _best_genre(artist.get("genre-list")) or _best_genre(artist.get("tag-list"))
# ── junk frame classification ─────────────────────────────────────────────────
# ID3 (MP3): match by 4-char frame id prefix. TXXX/PRIV are handled specially.
_ID3_JUNK_PREFIXES = (
"COMM", # comments
"USLT", "SYLT", # embedded lyrics
"WXXX", "WCOM", "WCOP", "WOAF", "WOAR", "WOAS", "WORS", "WPAY", "WPUB", # URLs
"TENC", "TSSE", # encoded-by / encoder settings
)
# TXXX descriptions worth dropping (store/encoder junk). ReplayGain is preserved.
_ID3_TXXX_JUNK = ("itun", "cddb", "purchase", "comment", "encoder", "encoded by", "www", "url")
# Vorbis (FLAC/Ogg): lowercase comment keys.
_VORBIS_JUNK_KEYS = {
"comment", "comments", "description",
"lyrics", "unsyncedlyrics", "unsynced lyrics",
"encoder", "encodedby", "encoded_by", "encoder_options", "encoding", "tool",
}
_VORBIS_JUNK_SUBSTRINGS = ("url", "www", "purchase", "itun", "cddb")
# MP4 (M4A): atom keys.
_MP4_JUNK_KEYS = {
"\xa9cmt", # comment
"\xa9lyr", # lyrics
"\xa9too", "tool", # encoder
"purd", # purchase date
}
# Embedded artwork is NEVER stripped. These are belt-and-braces guards so no
# current or future junk rule can ever match a picture frame/atom/comment.
_ID3_PROTECTED = ("APIC", "PIC") # ID3v2.3/2.4 and ID3v2.2 attached pictures
_MP4_PROTECTED = {"covr"} # MP4 cover atom
_VORBIS_PROTECTED = {"metadata_block_picture", "coverart", "cover art"} # FLAC/Ogg art
def _clean_label(text: str) -> str:
"""A short, printable tag name for logs.
ID3 ``PRIV``/``COMM``/``TXXX`` dict keys embed the frame's raw payload (which
can be arbitrary binary Traktor blobs, embedded art, etc.). Strip anything
non-printable and truncate so the activity log stays readable.
"""
text = "".join(ch for ch in str(text) if ch.isprintable()).strip()
return (text[:45] + "") if len(text) > 46 else text
def _id3_frame_label(key: str, frame) -> str:
"""Human-readable id for an ID3 frame, never including its binary data."""
fid = getattr(frame, "FrameID", None) or key.split(":", 1)[0]
if fid == "PRIV":
owner = getattr(frame, "owner", "") or ""
return _clean_label(f"PRIV:{owner}" if owner else "PRIV")
if fid in ("TXXX", "COMM", "USLT", "SYLT", "WXXX"):
desc = getattr(frame, "desc", "") or ""
return _clean_label(f"{fid}:{desc}" if desc else fid)
return _clean_label(fid)
def _strip_id3_junk(tags: ID3) -> list[str]:
removed: list[str] = []
for key in list(tags.keys()):
if key.startswith(_ID3_PROTECTED):
continue # never touch embedded artwork (APIC/PIC)
drop = key.startswith(_ID3_JUNK_PREFIXES)
if not drop and key.startswith("TXXX:"):
desc = key.split(":", 1)[1].lower()
drop = any(token in desc for token in _ID3_TXXX_JUNK)
if not drop and key.startswith("PRIV"):
drop = True # Windows Media / player breadcrumbs
if drop:
removed.append(_id3_frame_label(key, tags[key]))
del tags[key]
return removed
def _strip_vorbis_junk(tags) -> list[str]:
removed: list[str] = []
for key in list(tags.keys()):
low = key.lower()
if low in _VORBIS_PROTECTED:
continue # never touch embedded artwork
if low in _VORBIS_JUNK_KEYS or any(token in low for token in _VORBIS_JUNK_SUBSTRINGS):
del tags[key]
removed.append(_clean_label(key))
return removed
def _strip_mp4_junk(tags: MP4Tags) -> list[str]:
removed: list[str] = []
for key in list(tags.keys()):
if key in _MP4_PROTECTED:
continue # never touch the cover atom
drop = key in _MP4_JUNK_KEYS
if not drop and key.startswith("----"):
low = key.lower()
drop = any(token in low for token in ("itun", "purchase", "url", "www", "comment"))
if drop:
del tags[key]
removed.append(_clean_label(key))
return removed
# ── track-number normalization ────────────────────────────────────────────────
def _normalize_id3_tracks(tags: ID3) -> list[str]:
changes: list[str] = []
for frame_id, label in (("TRCK", "track"), ("TPOS", "disc")):
frame = tags.get(frame_id)
if frame is None:
continue
current = str(frame.text[0]) if frame.text else ""
normalized = clean_track_number(current)
if normalized and normalized != current:
frame.text = [normalized]
prefix = "" if label == "track" else f"{label} "
changes.append(f"{prefix}{current}{normalized}")
return changes
def _normalize_vorbis_tracks(tags) -> list[str]:
changes: list[str] = []
for key, label in (("tracknumber", "track"), ("discnumber", "disc")):
values = tags.get(key)
if not values:
continue
current = str(values[0])
normalized = clean_track_number(current)
if normalized and normalized != current:
tags[key] = [normalized]
prefix = "" if label == "track" else f"{label} "
changes.append(f"{prefix}{current}{normalized}")
return changes
# ── per-file processing ───────────────────────────────────────────────────────
def _set_genre(audio, tags, genre: str) -> bool:
"""Write ``genre`` across formats. Returns True if it changed."""
if isinstance(tags, ID3):
from mutagen.id3 import TCON
existing = tags.get("TCON")
if existing is not None and existing.text == [genre]:
return False
tags.setall("TCON", [TCON(encoding=3, text=[genre])])
return True
if isinstance(tags, MP4Tags):
if tags.get("\xa9gen") == [genre]:
return False
tags["\xa9gen"] = [genre]
return True
# Vorbis comment (FLAC/Ogg)
if list(tags.get("genre", [])) == [genre]:
return False
tags["genre"] = [genre]
return True
def _process_file(
path: Path,
opts: MetadataOptions,
genre: str | None,
rec: _Recorder,
*,
group: str,
subgroup: str,
) -> None:
try:
audio = MutagenFile(path)
except (MutagenError, OSError) as exc:
rec.emit("warn", "tag", f"Could not read {path.name}: {exc}")
return
if audio is None:
return
if audio.tags is None:
try:
audio.add_tags()
except (MutagenError, Exception):
rec.emit("skip", "tag", f"No tags and none could be added: {path.name}")
return
tags = audio.tags
removed: list[str] = []
track_changes: list[str] = []
genre_set = False
if opts.strip_junk:
if isinstance(tags, ID3):
removed = _strip_id3_junk(tags)
elif isinstance(tags, MP4Tags):
removed = _strip_mp4_junk(tags)
else:
removed = _strip_vorbis_junk(tags)
if opts.normalize_tracks:
if isinstance(tags, ID3):
track_changes = _normalize_id3_tracks(tags)
elif isinstance(tags, MP4Tags):
track_changes = [] # MP4 stores track as an integer tuple; nothing to pad
else:
track_changes = _normalize_vorbis_tracks(tags)
if opts.genres and genre and _set_genre(audio, tags, genre):
genre_set = True
if not (removed or track_changes or genre_set):
return
# Human-readable summary (also used by the collapsible raw log).
parts: list[str] = []
if removed:
parts.append(f"strip {len(removed)} junk tag(s): {', '.join(removed[:6])}")
if track_changes:
parts.append("; ".join(track_changes))
if genre_set:
parts.append(f"genre → {genre}")
rec.emit(
"dry" if opts.dry_run else "ok",
"tag",
f"{path.name}: " + " | ".join(parts),
path=str(path),
file=path.name,
group=group,
subgroup=subgroup,
junk=removed,
track="; ".join(track_changes) or None,
genre=genre if genre_set else None,
)
if opts.dry_run:
return
try:
audio.save()
except (MutagenError, OSError) as exc:
rec.emit("warn", "tag", f"Could not save {path.name}: {exc}")
def _process_album(
album_folder: Path,
opts: MetadataOptions,
rec: _Recorder,
genre_cache: dict[str, str | None],
overrides: dict[str, str],
) -> None:
audio_files = [
f for f in album_folder.iterdir()
if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS
]
if not audio_files:
return
group = album_folder.name
subgroup = album_folder.parent.name
genre = None
if opts.genres:
artist, album, _year = get_album_metadata_from_files(album_folder)
if artist and album:
group = album
subgroup = artist
if artist:
genre = _resolve_artist_genre(artist, album, genre_cache, overrides, rec)
for file in audio_files:
_process_file(file, opts, genre, rec, group=group, subgroup=subgroup)
def _resolve_artist_genre(
artist: str,
album: str | None,
cache: dict[str, str | None],
overrides: dict[str, str],
rec: _Recorder,
) -> str | None:
"""One genre per artist, resolved once and cached for the whole run.
Priority: a user override (always wins, no lookup) the artist's MusicBrainz
genre one album lookup as fallback. The result is cached under the artist so
every later album by the same artist reuses it and the library stays consistent.
"""
key = artist.strip().lower()
if key in cache:
return cache[key]
override = overrides.get(key)
if override:
cache[key] = override
rec.emit("info", "genre", f"{artist}{override} (override)")
return override
genre = find_artist_genre(artist)
if not genre and album:
genre = find_album_genre(artist, album)
cache[key] = genre
if genre:
rec.emit("info", "genre", f"{artist}{genre}")
else:
rec.emit("skip", "genre", f"No MusicBrainz genre: {artist}")
return genre
def process_library(
options: MetadataOptions,
*,
root: Path | None = None,
log: LogCallback | None = None,
album_paths: list[str] | None = None,
) -> dict:
"""Run the selected tag-maintenance modes. Honours ``options.dry_run``."""
root = root or MUSIC_ROOT
rec = _Recorder(sink=log)
if not options.any_mode:
rec.emit("warn", "start", "No metadata modes selected.")
return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}
if not root.exists():
rec.emit("warn", "root", f"Music root does not exist: {root}")
return {"root": str(root), "dry_run": options.dry_run, "actions": rec.actions, "counts": rec.counts}
if album_paths:
wanted = {str(Path(p)) for p in album_paths}
folders = [Path(p) for p in album_paths] if all(Path(p).exists() for p in album_paths) else [
f for f in _iter_album_folders(root) if str(f) in wanted
]
else:
folders = list(_iter_album_folders(root))
if options.recent_only and not album_paths:
before = len(folders)
folders = [f for f in folders if _was_created_recently(f, options.recent_window_seconds)]
rec.emit(
"info",
"recent",
f"Recent-only: {len(folders)} of {before} albums modified in the last "
f"{options.recent_window_seconds // 3600}h",
)
if options.genres and not _HAS_MUSICBRAINZ:
rec.emit("warn", "genre", "MusicBrainz library not available — genre lookups skipped.")
rec.emit(
"info",
"start",
f"{'Dry run' if options.dry_run else 'Applying'} tag maintenance across {len(folders)} album(s)",
)
genre_cache: dict[str, str | None] = {} # one genre per artist, for the whole run
overrides = _overrides_map() if options.genres else {}
for album_folder in folders:
try:
_process_album(album_folder, options, rec, genre_cache, overrides)
except OSError as exc:
rec.emit("warn", "album", f"Error processing {album_folder.name}: {exc}")
rec.emit("info", "done", "Finished")
return {
"root": str(root),
"dry_run": options.dry_run,
"actions": rec.actions,
"counts": rec.counts,
}
def process_library_stream(
options: MetadataOptions,
*,
root: Path | None = None,
album_paths: list[str] | None = None,
) -> Iterator[dict]:
"""Run tag maintenance and yield each action the moment it happens."""
events: queue.Queue = queue.Queue()
sentinel = object()
def worker():
try:
process_library(options, root=root, log=events.put, album_paths=album_paths)
except Exception as exc: # pragma: no cover - defensive
events.put({"level": "warn", "action": "error", "message": str(exc)})
finally:
events.put(sentinel)
threading.Thread(target=worker, name="music_metadata_stream", daemon=True).start()
while True:
item = events.get()
if item is sentinel:
break
yield item
+157
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import os
import secrets
from datetime import datetime
import httpx
@@ -115,22 +116,49 @@ def _map_album(raw: dict) -> dict:
def _map_song(raw: dict) -> dict:
play_count = raw.get("playCount")
try:
play_count = int(play_count) if play_count is not None else 0
except (TypeError, ValueError):
play_count = 0
return {
"id": raw.get("id"),
"title": raw.get("title") or "",
"track": raw.get("track"),
"disc": raw.get("discNumber"),
"artist": raw.get("artist") or "",
"artist_id": raw.get("artistId"),
"album": raw.get("album") or "",
"album_id": raw.get("albumId") or raw.get("parent"),
"year": raw.get("year"),
"genre": raw.get("genre"),
"duration": raw.get("duration") or 0,
"bitrate": raw.get("bitRate"),
"suffix": raw.get("suffix"),
"size": raw.get("size"),
"path": raw.get("path"),
"cover_art": raw.get("coverArt"),
"cover_url": _cover_url(raw.get("coverArt")),
"play_count": play_count,
"created": raw.get("created"),
"starred": raw.get("starred"),
}
def _parse_dt(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def _dt_timestamp(value: str | None) -> float:
parsed = _parse_dt(value)
return parsed.timestamp() if parsed else 0.0
async def ping(client: httpx.AsyncClient) -> dict:
if not is_configured():
return {"connected": False, "configured": False, "url": NAVIDROME_URL}
@@ -205,6 +233,64 @@ async def get_album(client: httpx.AsyncClient, album_id: str) -> dict:
return album
async def search_songs(client: httpx.AsyncClient, query: str, *, count: int = 100, offset: int = 0) -> list[dict]:
payload = await _call(
client,
"search3",
{"query": query, "artistCount": 0, "albumCount": 0, "songCount": count, "songOffset": offset},
)
raw_songs = ((payload.get("searchResult3") or {}).get("song")) or []
return [_map_song(song) for song in raw_songs]
async def get_now_playing(client: httpx.AsyncClient) -> list[dict]:
payload = await _call(client, "getNowPlaying")
raw = ((payload.get("nowPlaying") or {}).get("entry")) or []
return [_map_song(song) for song in raw]
async def get_starred(client: httpx.AsyncClient) -> dict:
payload = await _call(client, "getStarred2")
raw = payload.get("starred2") or {}
return {
"songs": [_map_song(song) for song in (raw.get("song") or [])],
"albums": [_map_album(album) for album in (raw.get("album") or [])],
"artists": [
{
"id": artist.get("id"),
"name": artist.get("name") or "",
"cover_art": artist.get("coverArt"),
"cover_url": _cover_url(artist.get("coverArt")),
"starred": artist.get("starred"),
}
for artist in (raw.get("artist") or [])
],
}
async def get_playlists(client: httpx.AsyncClient) -> list[dict]:
payload = await _call(client, "getPlaylists")
raw = ((payload.get("playlists") or {}).get("playlist")) or []
playlists = [
{
"id": p.get("id"),
"name": p.get("name") or "",
"song_count": p.get("songCount") or 0,
"owner": p.get("owner") or "",
"public": bool(p.get("public")),
"duration": p.get("duration") or 0,
"changed": p.get("changed"),
}
for p in raw
]
playlists.sort(key=lambda entry: entry["name"].lower())
return playlists
async def delete_playlist(client: httpx.AsyncClient, playlist_id: str) -> None:
await _call(client, "deletePlaylist", {"id": playlist_id})
async def get_cover_art(
client: httpx.AsyncClient, cover_id: str, size: int | None = None
) -> tuple[bytes, str]:
@@ -306,3 +392,74 @@ async def get_stats(client: httpx.AsyncClient) -> dict:
"genre_count": len(genres),
"top_genres": genres[:8],
}
async def get_reporting_snapshot(
client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400
) -> dict:
stats = await get_stats(client)
# Keep the API calls explicit so they remain easy to debug.
top_albums = await get_albums(client, list_type="frequent", size=12)
recent_albums = await get_albums(client, list_type="recent", size=12)
newest_albums = await get_albums(client, list_type="newest", size=12)
starred = await get_starred(client)
now_playing = await get_now_playing(client)
all_songs: list[dict] = []
offset = 0
scanned_pages = 0
truncated = False
for _ in range(max_pages):
scanned_pages += 1
songs = await search_songs(client, "", count=page_size, offset=offset)
if not songs:
break
all_songs.extend(songs)
if len(songs) < page_size:
break
offset += page_size
else:
truncated = True
songs_with_plays = [song for song in all_songs if (song.get("play_count") or 0) > 0]
total_play_count = sum(song.get("play_count") or 0 for song in songs_with_plays)
top_tracks = sorted(
songs_with_plays,
key=lambda song: ((song.get("play_count") or 0), song.get("title") or "", song.get("artist") or ""),
reverse=True,
)[:25]
recently_added_tracks = sorted(
[song for song in all_songs if song.get("created")],
key=lambda song: _dt_timestamp(song.get("created")),
reverse=True,
)[:25]
favorite_tracks = sorted(
starred["songs"],
key=lambda song: ((song.get("play_count") or 0), _dt_timestamp(song.get("starred"))),
reverse=True,
)[:25]
return {
"summary": {
**stats,
"library_tracks_scanned": len(all_songs),
"tracks_with_plays": len(songs_with_plays),
"total_play_count": total_play_count,
"favorite_song_count": len(starred["songs"]),
"favorite_album_count": len(starred["albums"]),
"favorite_artist_count": len(starred["artists"]),
"now_playing_count": len(now_playing),
"scan_pages": scanned_pages,
"truncated": truncated,
},
"top_tracks": top_tracks,
"favorite_tracks": favorite_tracks,
"recently_added_tracks": recently_added_tracks,
"top_albums": top_albums,
"recent_albums": recent_albums,
"newest_albums": newest_albums,
"top_genres": stats["top_genres"],
"now_playing": now_playing,
"favorite_albums": starred["albums"][:20],
"favorite_artists": starred["artists"][:20],
}
+440
View File
@@ -0,0 +1,440 @@
from __future__ import annotations
import asyncio
import ipaddress
import json
import logging
import os
import posixpath
import time
from datetime import datetime
from pathlib import Path
from stat import S_ISDIR
from typing import Any
try:
import paramiko
except ImportError: # pragma: no cover - exercised indirectly via status checks
paramiko = None
from services import settings as settings_service
DEFAULT_REMOTE_APP_DIR = "/share/Docker/homelabtoolkit"
DEPLOY_TIMEOUT_SECONDS = int(os.environ.get("DEPLOY_TIMEOUT_SECONDS", "1800"))
ROOT_DIR = Path(__file__).resolve().parent.parent
REMOTE_CONTAINER_NAME = os.environ.get("DEPLOY_CONTAINER_NAME", "homelabtoolkit").strip() or "homelabtoolkit"
TOP_LEVEL_FILES = ("app.py", "rotate_preroll.py", "Dockerfile", "docker-compose.yml", "requirements.txt")
FRONTEND_ROOT_FILES = ("package.json", "package-lock.json", "vite.config.ts", "tsconfig.json", "index.html")
LOGO_EXTENSIONS = {".png", ".jpg", ".jpeg"}
runtime: dict[str, Any] = {
"running": False,
"last_started_at": None,
"last_finished_at": None,
"last_status": "idle",
"last_message": None,
"last_output_tail": [],
}
_lock = asyncio.Lock()
_task: asyncio.Task | None = None
logging.getLogger("paramiko").setLevel(logging.WARNING)
logger = logging.getLogger("homelabtoolkit.update")
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def normalize_client_host(value: str | None) -> str:
text = (value or "").strip()
if not text:
return ""
if "," in text:
text = text.split(",", 1)[0].strip()
if text.startswith("::ffff:"):
text = text[len("::ffff:") :]
return text
def is_local_client(host: str | None) -> bool:
text = normalize_client_host(host)
if not text:
return False
if text.lower() == "localhost":
return True
try:
ip = ipaddress.ip_address(text)
except ValueError:
return False
return ip.is_loopback or ip.is_private
def is_paramiko_available() -> bool:
return paramiko is not None
def is_configured(values: dict[str, Any]) -> bool:
return bool(str(values.get("deploy_nas_host") or "").strip() and str(values.get("deploy_nas_user") or "").strip())
def remote_app_dir(values: dict[str, Any]) -> str:
return str(values.get("deploy_remote_app_dir") or DEFAULT_REMOTE_APP_DIR).strip() or DEFAULT_REMOTE_APP_DIR
def deploy_password(values: dict[str, Any]) -> str:
return str(values.get("deploy_nas_password") or "")
def using_saved_password(values: dict[str, Any]) -> bool:
return bool(deploy_password(values))
def deploy_music_host_path(values: dict[str, Any]) -> str:
return str(values.get("deploy_music_host_path") or "/share/Music").strip() or "/share/Music"
def _remote(values: dict[str, Any]) -> str:
return f'{str(values.get("deploy_nas_user") or "").strip()}@{str(values.get("deploy_nas_host") or "").strip()}'
def _tail(lines: list[str], max_lines: int = 40) -> list[str]:
return lines[-max_lines:]
def _push_runtime_output(log_lines: list[str]) -> None:
runtime["last_output_tail"] = list(_tail(log_lines))
def _log(log_lines: list[str], message: str) -> None:
text = message.rstrip()
if not text:
return
log_lines.append(text)
_push_runtime_output(log_lines)
logger.info(text)
def _ensure_remote_dir(sftp, remote_dir: str) -> None:
normalized = posixpath.normpath(remote_dir)
parts = [part for part in normalized.split("/") if part]
current = "/" if normalized.startswith("/") else ""
for part in parts:
current = posixpath.join(current, part) if current not in ("", "/") else f"{current}{part}" if current == "/" else part
try:
attrs = sftp.stat(current)
if not S_ISDIR(attrs.st_mode):
raise RuntimeError(f"Remote path exists but is not a directory: {current}")
except OSError:
sftp.mkdir(current)
def _upload_file(sftp, local_path: Path, remote_path: str, log_lines: list[str]) -> None:
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
sftp.put(str(local_path), remote_path)
_log(log_lines, f"Uploaded {local_path.relative_to(ROOT_DIR).as_posix()} -> {remote_path}")
def _upload_text(sftp, content: str, remote_path: str, label: str, log_lines: list[str]) -> None:
_ensure_remote_dir(sftp, posixpath.dirname(remote_path))
with sftp.file(remote_path, "w") as handle:
handle.write(content)
_log(log_lines, f"Rendered {label} -> {remote_path}")
def _upload_tree(sftp, local_dir: Path, remote_dir: str, log_lines: list[str]) -> None:
if not local_dir.exists():
return
_ensure_remote_dir(sftp, remote_dir)
for path in sorted(local_dir.rglob("*")):
if path.is_dir():
_ensure_remote_dir(sftp, posixpath.join(remote_dir, path.relative_to(local_dir).as_posix()))
continue
if "__pycache__" in path.parts:
continue
remote_path = posixpath.join(remote_dir, path.relative_to(local_dir).as_posix())
_upload_file(sftp, path, remote_path, log_lines)
def _stream_output(channel, log_lines: list[str]) -> None:
stdout_buffer = ""
stderr_buffer = ""
while True:
had_output = False
if channel.recv_ready():
stdout_buffer += channel.recv(4096).decode("utf-8", errors="replace")
had_output = True
while "\n" in stdout_buffer:
line, stdout_buffer = stdout_buffer.split("\n", 1)
_log(log_lines, line)
if channel.recv_stderr_ready():
stderr_buffer += channel.recv_stderr(4096).decode("utf-8", errors="replace")
had_output = True
while "\n" in stderr_buffer:
line, stderr_buffer = stderr_buffer.split("\n", 1)
_log(log_lines, line)
if channel.exit_status_ready() and not channel.recv_ready() and not channel.recv_stderr_ready():
break
if not had_output:
time.sleep(0.1)
if stdout_buffer.strip():
_log(log_lines, stdout_buffer)
if stderr_buffer.strip():
_log(log_lines, stderr_buffer)
def _run_remote_command(client, command: str, log_lines: list[str], allow_failure: bool = False) -> tuple[int, list[str]]:
stdin, stdout, stderr = client.exec_command(command, timeout=DEPLOY_TIMEOUT_SECONDS)
if stdin:
stdin.close()
channel = stdout.channel
command_lines: list[str] = []
_stream_output(channel, command_lines)
for line in command_lines:
_log(log_lines, line)
exit_code = stdout.channel.recv_exit_status()
if exit_code != 0 and not allow_failure:
raise RuntimeError(f"Remote command failed with exit code {exit_code}")
return exit_code, command_lines
def _connect(values: dict[str, Any]):
if paramiko is None:
raise RuntimeError("Paramiko is not installed.")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs: dict[str, Any] = {
"hostname": str(values.get("deploy_nas_host") or "").strip(),
"username": str(values.get("deploy_nas_user") or "").strip(),
"timeout": 20,
"banner_timeout": 20,
"auth_timeout": 20,
}
password = deploy_password(values)
if password:
connect_kwargs["password"] = password
connect_kwargs["look_for_keys"] = False
connect_kwargs["allow_agent"] = False
client.connect(**connect_kwargs)
return client
def render_remote_compose(values: dict[str, Any]) -> str:
source = (ROOT_DIR / "docker-compose.yml").read_text(encoding="utf-8")
remote_dir = remote_app_dir(values).rstrip("/")
music_host = deploy_music_host_path(values)
rendered = source
rendered = rendered.replace("/share/Docker/homelabtoolkit/output:/app/output", f"{remote_dir}/output:/app/output")
rendered = rendered.replace("/share/Docker/homelabtoolkit/cache:/app/cache", f"{remote_dir}/cache:/app/cache")
rendered = rendered.replace("/share/Music:/music", f"{music_host}:/music")
return rendered
def render_remote_settings(values: dict[str, Any]) -> str:
payload = {key: values.get(key) for key in settings_service.FIELD_SPECS if key in values}
return json.dumps(payload, indent=2)
def _check_container_health(client, base_dir: str, log_lines: list[str]) -> tuple[bool, str]:
inspect_cmd = (
f"docker inspect -f '{{{{.State.Status}}}}|{{{{.State.Running}}}}|{{{{.State.ExitCode}}}}|{{{{.State.Error}}}}' "
f"{REMOTE_CONTAINER_NAME} 2>/dev/null || true"
)
_, lines = _run_remote_command(client, inspect_cmd, log_lines, allow_failure=True)
raw = (lines[-1] if lines else "").strip()
if not raw:
_log(log_lines, f"Container {REMOTE_CONTAINER_NAME} was not found after deploy.")
return False, "container missing"
parts = raw.split("|", 3)
state = parts[0] if len(parts) > 0 else "unknown"
running = parts[1].lower() == "true" if len(parts) > 1 else False
exit_code = parts[2] if len(parts) > 2 else ""
error = parts[3].strip() if len(parts) > 3 else ""
_log(log_lines, f"Container status: state={state} running={running} exit_code={exit_code or 'n/a'}")
if running and state == "running":
return True, state
_log(log_lines, "Container did not reach running state. Fetching recent logs…")
_run_remote_command(client, f"docker logs --tail 80 {REMOTE_CONTAINER_NAME} 2>&1 || true", log_lines, allow_failure=True)
if error:
_log(log_lines, f"Container error: {error}")
return False, state or "unknown"
def status_payload(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
allowed = is_local_client(client_host)
configured = is_configured(values)
transport_ready = is_paramiko_available()
available = allowed and configured and transport_ready
reasons: list[str] = []
if not allowed:
reasons.append("Updates can only be triggered from a local/private network client.")
if not configured:
reasons.append("Set a NAS host and NAS user first.")
if not transport_ready:
reasons.append("Python SSH support is not installed on this host yet.")
return {
"available": available,
"allowed": allowed,
"configured": configured,
"transport": "paramiko" if transport_ready else None,
"transport_ready": transport_ready,
"password_configured": using_saved_password(values),
"client_host": normalize_client_host(client_host),
"nas_host": str(values.get("deploy_nas_host") or "").strip(),
"nas_user": str(values.get("deploy_nas_user") or "").strip(),
"remote_app_dir": remote_app_dir(values),
"runtime": dict(runtime),
"reason": " ".join(reasons).strip() or None,
}
def _run_sync(values: dict[str, Any]) -> dict[str, Any]:
log_lines: list[str] = []
client = None
sftp = None
try:
_log(log_lines, f"Deploying HomelabToolkit to {_remote(values)}:{remote_app_dir(values)}")
_log(
log_lines,
"Auth mode: saved password" if using_saved_password(values) else "Auth mode: SSH keys / agent"
)
_log(log_lines, "Connecting to remote host…")
client = _connect(values)
_log(log_lines, "SSH connection established.")
sftp = client.open_sftp()
_log(log_lines, "SFTP channel opened.")
base_dir = remote_app_dir(values).rstrip("/")
for directory in (
base_dir,
f"{base_dir}/output",
f"{base_dir}/cache",
f"{base_dir}/static",
f"{base_dir}/static/studios",
f"{base_dir}/services",
f"{base_dir}/frontend",
):
_ensure_remote_dir(sftp, directory)
_log(log_lines, "Remote directory structure ensured.")
for name in TOP_LEVEL_FILES:
source = ROOT_DIR / name
if source.exists():
if name == "docker-compose.yml":
_upload_text(sftp, render_remote_compose(values), f"{base_dir}/{name}", name, log_lines)
else:
_upload_file(sftp, source, f"{base_dir}/{name}", log_lines)
_upload_text(sftp, render_remote_settings(values), f"{base_dir}/cache/settings.json", "settings.json", log_lines)
_upload_tree(sftp, ROOT_DIR / "static", f"{base_dir}/static", log_lines)
_upload_tree(sftp, ROOT_DIR / "services", f"{base_dir}/services", log_lines)
_log(log_lines, "Cleaning remote frontend build directories…")
_run_remote_command(client, f"rm -rf {base_dir}/frontend/node_modules {base_dir}/frontend/dist", log_lines)
for name in FRONTEND_ROOT_FILES:
source = ROOT_DIR / "frontend" / name
if source.exists():
_upload_file(sftp, source, f"{base_dir}/frontend/{name}", log_lines)
_upload_tree(sftp, ROOT_DIR / "frontend" / "src", f"{base_dir}/frontend/src", log_lines)
for path in sorted(ROOT_DIR.iterdir()):
if path.is_file() and path.suffix.lower() in LOGO_EXTENSIONS:
_upload_file(sftp, path, f"{base_dir}/{path.name}", log_lines)
_log(log_lines, "Starting remote docker compose build and update…")
_run_remote_command(
client,
(
f"cd {base_dir} && "
"if command -v docker-compose >/dev/null 2>&1; then "
"docker-compose build && docker-compose up -d; "
"else "
"docker compose build && docker compose up -d; "
"fi"
),
log_lines,
)
healthy, state = _check_container_health(client, base_dir, log_lines)
if not healthy:
return {
"ok": False,
"message": f"Deployment finished but container state is {state}.",
"output_tail": _tail(log_lines),
}
_log(log_lines, "Deployment complete.")
_log(log_lines, f"To view logs: ssh {_remote(values)} 'cd {base_dir} && docker compose logs -f'")
return {
"ok": True,
"message": "Deployment completed.",
"output_tail": _tail(log_lines),
}
except Exception as exc:
return {
"ok": False,
"message": f"Deployment failed: {exc}",
"output_tail": _tail(log_lines),
}
finally:
if sftp is not None:
sftp.close()
if client is not None:
client.close()
async def run_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
async with _lock:
status = status_payload(values, client_host)
if not status["allowed"]:
return {"ok": False, "message": status["reason"] or "Update not allowed."}
if not status["configured"]:
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
if not status["transport_ready"]:
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
runtime["running"] = True
runtime["last_started_at"] = _now()
runtime["last_finished_at"] = None
runtime["last_status"] = "running"
runtime["last_message"] = None
runtime["last_output_tail"] = []
try:
result = await asyncio.wait_for(asyncio.to_thread(_run_sync, values), timeout=DEPLOY_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
result = {
"ok": False,
"message": f"Deployment timed out after {DEPLOY_TIMEOUT_SECONDS} seconds.",
"output_tail": [],
}
runtime["running"] = False
runtime["last_finished_at"] = _now()
runtime["last_status"] = "ok" if result.get("ok") else "error"
runtime["last_message"] = result.get("message")
runtime["last_output_tail"] = list(result.get("output_tail") or [])
return result
async def start_update(values: dict[str, Any], client_host: str | None) -> dict[str, Any]:
global _task
status = status_payload(values, client_host)
if not status["allowed"]:
return {"ok": False, "message": status["reason"] or "Update not allowed."}
if not status["configured"]:
return {"ok": False, "message": status["reason"] or "Update target is not configured."}
if not status["transport_ready"]:
return {"ok": False, "message": status["reason"] or "Python SSH support is not available."}
if runtime["running"]:
return {"ok": False, "message": "Deployment is already running."}
_task = asyncio.create_task(run_update(values, client_host))
return {"ok": True, "message": "Deployment started."}
+192 -20
View File
@@ -1,27 +1,111 @@
"""Runtime settings store.
Configuration can come from two places: environment variables (the deploy-time
defaults) and a JSON file written by the in-app Settings page. The file, when
present, wins. ``load`` returns the effective settings; ``save`` persists the
editable subset and returns the new effective settings.
Settings can come from three places:
1. environment variables (deploy-time defaults)
2. a legacy JSON file (local/dev compatibility and migration source)
3. PostgreSQL when ``DATABASE_URL`` is configured
When PostgreSQL is available, settings are persisted there and any legacy JSON
settings are migrated on startup. The JSON file remains as a fallback for
non-database local runs.
"""
from __future__ import annotations
import json
import os
from contextlib import contextmanager
from pathlib import Path
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
try:
import psycopg
from psycopg.rows import dict_row
except ImportError: # pragma: no cover - exercised via runtime status instead
psycopg = None
dict_row = None
FIELDS = (
"emby_url",
"emby_api_key",
"navidrome_url",
"navidrome_user",
"navidrome_password",
"music_root",
)
from services import emby_tasks as emby_tasks_service
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json"))
DATABASE_URL = os.environ.get("DATABASE_URL", "").strip()
FIELD_SPECS = {
"emby_url": "string",
"emby_api_key": "string",
"navidrome_url": "string",
"navidrome_user": "string",
"navidrome_password": "string",
"audiobookshelf_url": "string",
"audiobookshelf_token": "string",
"music_root": "string",
"homescreen_db_path": "string",
"tmdb_api_key": "string",
"deploy_nas_host": "string",
"deploy_nas_user": "string",
"deploy_nas_password": "string",
"deploy_remote_app_dir": "string",
"deploy_music_host_path": "string",
"preroll_enabled": "bool",
"preroll_active_dir": "string",
"preroll_inactive_dir": "string",
"preroll_state_file": "string",
"preroll_weekday": "int",
"preroll_time": "string",
"emby_tasks": "object",
}
FIELD_DEFAULTS = {
"preroll_enabled": False,
"preroll_weekday": 0,
}
SCHEMA = """
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
def _env(name: str, legacy_name: str | None = None, default: str = "") -> str:
if os.environ.get(name) is not None:
return os.environ[name]
if legacy_name and os.environ.get(legacy_name) is not None:
return os.environ[legacy_name]
return default
def _coerce_value(field: str, value):
kind = FIELD_SPECS[field]
if kind == "bool":
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on"}
if kind == "int":
try:
return int(value)
except (TypeError, ValueError):
return FIELD_DEFAULTS.get(field, 0)
if kind == "object":
if field == "emby_tasks":
return emby_tasks_service.normalize_settings(value)
return value if isinstance(value, dict) else {}
if value is None:
return ""
return str(value)
def using_postgres() -> bool:
return bool(DATABASE_URL and psycopg is not None)
@contextmanager
def postgres_connect():
if not using_postgres():
raise RuntimeError("PostgreSQL settings store is not configured.")
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
yield conn
def env_defaults() -> dict:
@@ -31,10 +115,30 @@ def env_defaults() -> dict:
"navidrome_url": os.environ.get("NAVIDROME_URL", "http://10.0.0.2:4533"),
"navidrome_user": os.environ.get("NAVIDROME_USER", ""),
"navidrome_password": os.environ.get("NAVIDROME_PASSWORD", ""),
"audiobookshelf_url": os.environ.get("AUDIOBOOKSHELF_URL", ""),
"audiobookshelf_token": os.environ.get("AUDIOBOOKSHELF_TOKEN", ""),
"music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"),
"homescreen_db_path": os.environ.get("HOMESCREEN_DB_PATH", ""),
"tmdb_api_key": os.environ.get("TMDB_API_KEY", ""),
"deploy_nas_host": os.environ.get("DEPLOY_NAS_HOST", ""),
"deploy_nas_user": os.environ.get("DEPLOY_NAS_USER", ""),
"deploy_nas_password": os.environ.get("DEPLOY_NAS_PASSWORD", ""),
"deploy_remote_app_dir": os.environ.get("DEPLOY_REMOTE_APP_DIR", "/share/Docker/homelabtoolkit"),
"deploy_music_host_path": os.environ.get("DEPLOY_MUSIC_HOST_PATH", "/share/Music"),
"preroll_enabled": _coerce_value("preroll_enabled", _env("PREROLL_ENABLED", default="false")),
"preroll_active_dir": _env("PREROLL_ACTIVE_DIR", "ACTIVE_DIR", "/media/Prerolls"),
"preroll_inactive_dir": _env("PREROLL_INACTIVE_DIR", "INACTIVE_DIR", "/media/Prerolls - Not Active"),
"preroll_state_file": _env("PREROLL_STATE_FILE", "STATE_FILE", "cache/preroll-state.json"),
"preroll_weekday": _coerce_value("preroll_weekday", _env("PREROLL_WEEKDAY", "ROTATE_WEEKDAY", "0")),
"preroll_time": _env("PREROLL_TIME", "SCHEDULE_TIME", "02:00"),
"emby_tasks": emby_tasks_service.default_settings(),
}
def _normalize_payload(data: dict) -> dict:
return {k: _coerce_value(k, v) for k, v in data.items() if k in FIELD_SPECS and v is not None}
def _read_file() -> dict:
if not SETTINGS_FILE.exists():
return {}
@@ -42,22 +146,90 @@ def _read_file() -> dict:
data = json.loads(SETTINGS_FILE.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return {k: str(v) for k, v in data.items() if k in FIELDS and v is not None}
return _normalize_payload(data if isinstance(data, dict) else {})
def _write_file(values: dict) -> None:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(values, indent=2), encoding="utf-8")
def _ensure_postgres_schema() -> None:
with postgres_connect() as conn:
with conn.cursor() as cur:
cur.execute(SCHEMA)
conn.commit()
def _read_postgres() -> dict:
with postgres_connect() as conn:
with conn.cursor() as cur:
cur.execute("SELECT key, value_json FROM app_settings")
rows = cur.fetchall()
data: dict[str, object] = {}
for row in rows:
data[row["key"]] = row["value_json"]
return _normalize_payload(data)
def _write_postgres(values: dict) -> None:
payload = _normalize_payload(values)
with postgres_connect() as conn:
with conn.cursor() as cur:
for key, value in payload.items():
cur.execute(
"""
INSERT INTO app_settings (key, value_json, updated_at)
VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (key)
DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
""",
(key, json.dumps(value)),
)
conn.commit()
def _migrate_file_to_postgres() -> None:
file_values = _read_file()
if not file_values:
return
current_db = _read_postgres()
merged = dict(current_db)
for key, value in file_values.items():
if key not in merged or merged[key] in ("", {}, 0, False):
merged[key] = value
_write_postgres(merged)
def init_store() -> None:
if using_postgres():
_ensure_postgres_schema()
_migrate_file_to_postgres()
def load() -> dict:
"""Effective settings: env defaults overlaid with the saved file."""
"""Effective settings: env defaults overlaid with persisted overrides."""
values = env_defaults()
if using_postgres():
values.update(_read_postgres())
else:
values.update(_read_file())
return values
def save(updates: dict) -> dict:
"""Persist the editable subset of ``updates`` and return effective settings."""
current = _read_file()
for key in FIELDS:
current = _read_postgres() if using_postgres() else _read_file()
for key in FIELD_SPECS:
if key in updates and updates[key] is not None:
current[key] = str(updates[key]).strip()
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(current, indent=2), encoding="utf-8")
value = updates[key]
if FIELD_SPECS[key] == "string":
current[key] = str(value).strip()
else:
current[key] = _coerce_value(key, value)
if using_postgres():
_ensure_postgres_schema()
_write_postgres(current)
else:
_write_file(current)
return load()
+8
View File
@@ -0,0 +1,8 @@
import importlib
import sys
def test_app_module_imports_cleanly():
sys.modules.pop("app", None)
module = importlib.import_module("app")
assert module.app is not None
+104
View File
@@ -0,0 +1,104 @@
import json
import sqlite3
from services import homescreen_editor
def _make_db(path):
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE Users (Id INTEGER PRIMARY KEY, Name TEXT, Guid TEXT)")
conn.execute("CREATE TABLE UserSettingsKeys (UserSettingsKeyId INTEGER PRIMARY KEY, Name TEXT)")
conn.execute("CREATE TABLE UserSettings (UserId INTEGER, UserSettingsKeyId INTEGER, Value TEXT)")
conn.execute("INSERT INTO UserSettingsKeys (UserSettingsKeyId, Name) VALUES (1, 'homescreensettings')")
conn.execute("INSERT INTO Users (Id, Name, Guid) VALUES (1, 'Alice', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')")
conn.execute("INSERT INTO Users (Id, Name, Guid) VALUES (2, 'Bob', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')")
conn.execute(
"INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)",
(
1,
1,
json.dumps(
{
"Sections": [
{
"Id": "one",
"Name": "Watchlist",
"CustomName": "Watchlist",
"UserId": "WRONG",
"SectionType": "items",
}
]
}
),
),
)
conn.commit()
conn.close()
def test_read_db_normalizes_section_user_ids(tmp_path):
db_path = tmp_path / "users.db"
_make_db(db_path)
result = homescreen_editor.read_db(str(db_path))
assert result["validation"]["userCount"] == 2
alice = next(user for user in result["users"] if user["name"] == "Alice")
assert alice["embyGuid"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
assert alice["sections"][0]["UserId"] == "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
def test_write_db_persists_normalized_sections(tmp_path):
db_path = tmp_path / "users.db"
_make_db(db_path)
payload = homescreen_editor.write_db(
str(db_path),
[
{
"userId": 2,
"sections": [
{
"Id": "two",
"Name": "Recent",
"CustomName": "Recent",
"UserId": "SHOULD_BE_NORMALIZED",
"SectionType": "items",
}
],
}
],
)
assert payload["ok"] is True
conn = sqlite3.connect(db_path)
value = conn.execute("SELECT Value FROM UserSettings WHERE UserId = 2").fetchone()[0]
conn.close()
parsed = json.loads(value)
assert parsed["Sections"][0]["UserId"] == "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def test_generate_sql_includes_only_changed_users():
original = [{"id": 1, "name": "Alice", "sections": [{"Id": "one"}]}]
updated = [{"id": 1, "name": "Alice", "sections": [{"Id": "two"}]}]
sql = homescreen_editor.generate_sql(updated, original)
assert "Alice" in sql
assert "UPDATE UserSettings" in sql
assert "COMMIT;" in sql
def test_uploaded_db_becomes_active_source(tmp_path, monkeypatch):
upload_dir = tmp_path / "uploads"
state_path = tmp_path / "upload-state.json"
monkeypatch.setattr(homescreen_editor, "HOMESCREEN_UPLOAD_DIR", upload_dir)
monkeypatch.setattr(homescreen_editor, "HOMESCREEN_UPLOAD_STATE_PATH", state_path)
meta = homescreen_editor.save_uploaded_db("users.db", b"sqlite-bytes")
active = homescreen_editor.get_active_upload()
resolved_path, resolved_meta = homescreen_editor.resolve_db_source()
assert meta["upload_id"] == active["upload_id"]
assert resolved_meta["upload_id"] == meta["upload_id"]
assert resolved_path.endswith(f"{meta['upload_id']}.db")
+72
View File
@@ -0,0 +1,72 @@
from services import self_update
def test_is_local_client_accepts_private_and_loopback_addresses():
assert self_update.is_local_client("127.0.0.1")
assert self_update.is_local_client("10.0.0.124")
assert self_update.is_local_client("192.168.1.20")
assert self_update.is_local_client("::1")
assert self_update.is_local_client("::ffff:10.0.0.124")
def test_is_local_client_rejects_public_and_empty_hosts():
assert not self_update.is_local_client("")
assert not self_update.is_local_client(None)
assert not self_update.is_local_client("8.8.8.8")
assert not self_update.is_local_client("example.com")
def test_status_payload_reports_missing_configuration(tmp_path, monkeypatch):
monkeypatch.setattr(self_update, "is_paramiko_available", lambda: True)
status = self_update.status_payload(
{
"deploy_nas_host": "",
"deploy_nas_user": "",
"deploy_nas_password": "",
"deploy_remote_app_dir": "",
},
"10.0.0.124",
)
assert status["allowed"] is True
assert status["configured"] is False
assert status["available"] is False
assert status["transport"] == "paramiko"
assert status["transport_ready"] is True
assert "Set a NAS host and NAS user first." in (status["reason"] or "")
def test_status_payload_available_when_local_and_configured(monkeypatch):
monkeypatch.setattr(self_update, "is_paramiko_available", lambda: True)
status = self_update.status_payload(
{
"deploy_nas_host": "MATT-NAS",
"deploy_nas_user": "ssh",
"deploy_nas_password": "secret",
"deploy_remote_app_dir": "/share/Docker/homelabtoolkit",
},
"10.0.0.124",
)
assert status["allowed"] is True
assert status["configured"] is True
assert status["transport"] == "paramiko"
assert status["transport_ready"] is True
assert status["password_configured"] is True
assert status["available"] is True
def test_render_remote_compose_uses_deploy_settings():
rendered = self_update.render_remote_compose(
{
"deploy_remote_app_dir": "/share/Docker/custom-toolkit",
"deploy_music_host_path": "/share/Movies/Music",
}
)
assert "/share/Docker/custom-toolkit/output:/app/output" in rendered
assert "/share/Docker/custom-toolkit/cache:/app/cache" in rendered
assert "/share/Movies/Music:/music" in rendered
assert "/share/Music:/music" not in rendered
+57
View File
@@ -0,0 +1,57 @@
import asyncio
from pathlib import Path
from services import emby_tasks
from services import music_covers as music_service
def run(coro):
return asyncio.run(coro)
def test_describe_tasks_marks_navidrome_tasks_unavailable_without_music_root(tmp_path, monkeypatch):
monkeypatch.setattr(music_service, "MUSIC_ROOT", tmp_path / "missing-music-root")
settings = emby_tasks.default_settings()
tasks = {task["id"]: task for task in emby_tasks.describe_tasks(settings)}
assert tasks["navidrome_file_cleanup"]["supports_run"] is False
assert tasks["navidrome_cover_backfill"]["requires"] == "music_root"
def test_navidrome_file_cleanup_preview_and_apply(tmp_path, monkeypatch):
music_root = tmp_path / "music"
album = music_root / "Boards of Canada" / "Music Has the Right to Children"
album.mkdir(parents=True)
(album / "cover.jpg").write_bytes(b"cover")
(album / "booklet.pdf").write_bytes(b"pdf")
(album / "notes.nfo").write_text("extra", encoding="utf-8")
monkeypatch.setattr(music_service, "MUSIC_ROOT", music_root)
monkeypatch.setattr(emby_tasks, "STATE_FILE", tmp_path / "tasks-state.json")
settings = emby_tasks.default_settings()
preview = run(emby_tasks.run_task("navidrome_file_cleanup", client=None, settings=settings, dry_run=True))
assert preview["ok"] is True
assert preview["matched_count"] == 2
assert "would be removed" in preview["message"]
assert (album / "booklet.pdf").exists()
assert (album / "notes.nfo").exists()
applied = run(emby_tasks.run_task("navidrome_file_cleanup", client=None, settings=settings, dry_run=False))
assert applied["ok"] is True
assert applied["removed_count"] == 2
assert not (album / "booklet.pdf").exists()
assert not (album / "notes.nfo").exists()
def test_record_run_persists_task_status(tmp_path, monkeypatch):
monkeypatch.setattr(emby_tasks, "STATE_FILE", tmp_path / "tasks-state.json")
entry = emby_tasks.record_run("navidrome_file_cleanup", {"ok": True, "message": "done"}, automated=True)
state = emby_tasks.load_state()
assert entry["last_status"] == "ok"
assert state["tasks"]["navidrome_file_cleanup"]["last_result"]["message"] == "done"
assert "last_automation_week" in state["tasks"]["navidrome_file_cleanup"]