Homelabtoolkit v2
This commit is contained in:
+26
-2
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 @@
|
||||
{"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"}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -2,8 +2,9 @@ fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
Pillow
|
||||
onnxruntime
|
||||
python-multipart
|
||||
requests
|
||||
mutagen
|
||||
musicbrainzngs
|
||||
paramiko
|
||||
psycopg[binary]
|
||||
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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])
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user