From 040fbacc70ba5eb2376dc4298d87627259cc9517 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Mon, 8 Jun 2026 00:01:55 +1200 Subject: [PATCH] Homelabtoolkit v1 --- .dockerignore | 12 + .gitignore | 9 + DESIGN.md | 39 + Dockerfile | 12 + README.md | 137 +- app.py | 547 ++++- deploy.ps1 | 28 +- docker-compose.yml | 17 +- embycovers.code-workspace | 9 + frontend/index.html | 13 + frontend/package-lock.json | 1774 +++++++++++++++++ frontend/package.json | 23 + frontend/src/App.tsx | 80 + frontend/src/api.ts | 79 + frontend/src/components/Sidebar.tsx | 143 ++ frontend/src/components/icons.tsx | 141 ++ frontend/src/components/ui.tsx | 119 ++ frontend/src/lib/toast.tsx | 37 + frontend/src/main.tsx | 16 + frontend/src/pages/Dashboard.tsx | 411 ++++ frontend/src/pages/Settings.tsx | 117 ++ frontend/src/pages/emby/Airing.tsx | 177 ++ frontend/src/pages/emby/BulkAssign.tsx | 198 ++ frontend/src/pages/emby/Collections.tsx | 229 +++ frontend/src/pages/emby/Favorites.tsx | 217 ++ frontend/src/pages/emby/Generator.tsx | 343 ++++ .../navidrome/CollectionCompleteness.tsx | 284 +++ frontend/src/pages/navidrome/CoverManager.tsx | 246 +++ frontend/src/pages/navidrome/Library.tsx | 224 +++ frontend/src/styles.css | 1197 +++++++++++ frontend/tsconfig.json | 20 + frontend/tsconfig.tsbuildinfo | 1 + frontend/vite.config.ts | 18 + music-covers.py | 777 ++++++++ requirements.txt | 4 +- services/__init__.py | 8 + services/db.py | 157 ++ services/emby_collections.py | 149 ++ services/emby_users.py | 39 + services/emby_watch_history.py | 50 + services/favorites.py | 311 +++ services/music_covers.py | 533 +++++ services/music_library.py | 625 ++++++ services/musicbrainz.py | 187 ++ services/navidrome.py | 308 +++ services/recommendations.py | 159 ++ services/settings.py | 63 + services/text_normalize.py | 145 ++ static/app-theme.css | 799 ++++++++ templates/airing.html | 21 +- templates/bulk_assign.html | 24 +- templates/collections.html | 33 +- templates/favorites.html | 659 ++++++ templates/index.html | 35 +- tests/test_favorites.py | 396 ++++ tests/test_music_library.py | 229 +++ 56 files changed, 12477 insertions(+), 151 deletions(-) create mode 100644 .dockerignore create mode 100644 DESIGN.md create mode 100644 embycovers.code-workspace create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api.ts create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/lib/toast.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Settings.tsx create mode 100644 frontend/src/pages/emby/Airing.tsx create mode 100644 frontend/src/pages/emby/BulkAssign.tsx create mode 100644 frontend/src/pages/emby/Collections.tsx create mode 100644 frontend/src/pages/emby/Favorites.tsx create mode 100644 frontend/src/pages/emby/Generator.tsx create mode 100644 frontend/src/pages/navidrome/CollectionCompleteness.tsx create mode 100644 frontend/src/pages/navidrome/CoverManager.tsx create mode 100644 frontend/src/pages/navidrome/Library.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.tsbuildinfo create mode 100644 frontend/vite.config.ts create mode 100644 music-covers.py create mode 100644 services/__init__.py create mode 100644 services/db.py create mode 100644 services/emby_collections.py create mode 100644 services/emby_users.py create mode 100644 services/emby_watch_history.py create mode 100644 services/favorites.py create mode 100644 services/music_covers.py create mode 100644 services/music_library.py create mode 100644 services/musicbrainz.py create mode 100644 services/navidrome.py create mode 100644 services/recommendations.py create mode 100644 services/settings.py create mode 100644 services/text_normalize.py create mode 100644 static/app-theme.css create mode 100644 templates/favorites.html create mode 100644 tests/test_favorites.py create mode 100644 tests/test_music_library.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a86594a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +.git/ +.pytest_cache/ +cache/ +output/ +logs/ +.app.out.log +.app.err.log +frontend/node_modules/ +frontend/dist/ +templates/ diff --git a/.gitignore b/.gitignore index 5ddedfd..da9b8f3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ __pycache__/ # Runtime image cache cache/ + +# App logs +logs/ +.app.out.log +.app.err.log + +# Frontend +frontend/node_modules/ +frontend/dist/ diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..e7b28ae --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,39 @@ +# DESIGN.md — EmbyToolkit + +Register: **product** (a self-hosted tool UI that serves the task, not a marketing surface). + +Theme scene: a self-hoster glances at their Emby cover-art toolkit late evening in a +dim home office on a wide monitor, wanting it to feel like the polished *arr-suite +dashboards (Sonarr / Radarr / Tracearr) they keep open all day. → dark, near-black. + +Design language is modeled on the **Tracearr** dashboard: + +## Color +OKLCH-authored, cool-tinted neutrals, single vivid cyan accent (Restrained strategy, +accent ≤10% of surface). Never `#000`/`#fff`. + +- Canvas: near-black, faint cyan top-glow, otherwise flat. +- Accent: vivid cyan `#36d6e0`. Used for the brand chip, active nav, focus rings, links. +- Status: green (success / connected / "watched"), amber ("sampled"), red ("abandoned"). + +Tokens live in `static/app-theme.css` (`:root:root` so they override each page's legacy +inline `:root`). Component names the pages already use (`--bg`, `--surface`, `--accent`, +`--text*`, `--border`, `--r`) are preserved so legacy `var()` calls recolor for free. + +## Typography +Inter. Page titles 28px / 700 / -0.02em. Body 14px, muted, capped ~70ch. Numeric +fields use tabular-nums. + +## Sidebar (signature) +Bright cyan rounded logo chip, brand name, an "Emby" connection status chip, flat nav +with a small inset cyan indicator pill on the active item (never a side-stripe border), +and a footer with social icons + version line. + +## Components +Segmented date/filter pills, ghost dropdown buttons, rounded surface toolbars/panels, +clean tables (uppercase 11px headers, hover rows), status badges, trust pills, thin +progress bars, stat cards (icon chip + big number + label). + +## Bans (house rules) +No side-stripe accent borders, no gradient text, no decorative glassmorphism, no fake +dead controls (no theme toggle on a dark-only app), no em dashes in UI copy. diff --git a/Dockerfile b/Dockerfile index 0f0f8f6..19868f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,12 @@ +# ── Stage 1: build the React SPA ───────────────────────────────────────────── +FROM node:20-slim AS frontend +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: Python runtime ────────────────────────────────────────────────── FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -17,6 +26,9 @@ RUN python -m pip install --upgrade pip \ COPY . . +# Bring in the built SPA from the frontend stage. +COPY --from=frontend /frontend/dist ./frontend/dist + RUN mkdir -p /app/cache /app/output EXPOSE 8500 diff --git a/README.md b/README.md index 5cd64fc..d214770 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,84 @@ -# Emby Thumbnail Generator +# HomelabToolkit -A self-hosted web UI that generates landscape thumbnails from your Emby library posters. Uses AI-powered subject extraction (rembg/U2-Net, runs entirely locally) to isolate characters from poster art, then composites them into widescreen thumbnails with customisable layouts. +A self-hosted control room for your media stack. HomelabToolkit pairs an Emby +artwork toolkit with a Navidrome music-library manager behind one beautiful React +UI, served by a FastAPI backend. -## What it does +## Categories -1. Connects to your Emby server via API -2. Search/browse your movie and TV library -3. Pulls the poster for a selected item -4. Extracts the subject (person/character) using rembg (offline, no external API) -5. Generates a landscape thumbnail with the subject positioned to one side and the title on the other -6. Optionally pushes the generated thumbnail back to Emby as a custom Thumb image +### Emby +- **Thumbnail Generator** — composite landscape thumbnails from posters, logos and + backdrops (subject-aware), then push them back to Emby as custom Thumb images. +- **Collection Art** — design Thumb/Primary cover artwork for your collections. +- **Airing & New Seasons** — track currently-airing series and stamp "New Season" + artwork on eligible premieres. +- **Bulk Assign** — generate and apply thumbnails across the whole library, plus + 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). -## Templates +### Navidrome +- **Music Library** — browse artists and albums over the Subsonic API. +- **Cover Manager** — the former `music-covers.py` script, now a web tool: scan the + library, normalize album folders, rename tracks, remove stray files, and fetch + missing covers/lyrics. **Runs in dry-run mode by default** — nothing on disk + changes until you explicitly switch to apply mode. -- **Subject Left, Text Right** — character on the left, title text on the right -- **Subject Right, Text Left** — character on the right, title text on the left -- **Subject Center, Text Overlay** — character centered with title overlaid +## Architecture -## Background Modes +- **Backend:** Python, FastAPI, Pillow, rembg (U2-Net). API-only; serves the built + SPA and owns every `/api/*` route. +- **Frontend:** React + TypeScript + Vite (`frontend/`), built to `frontend/dist`. +- **Music tooling:** mutagen + musicbrainzngs + Cover Art Archive + LRCLIB. -- **Auto Gradient** — samples dominant colours from the poster and creates a dark gradient -- **Blurred Poster** — darkened, heavily blurred version of the original poster -- **Solid Colour** — pick your own background colour +## Configuration (environment variables) -## Setup +| Variable | Purpose | +| --- | --- | +| `EMBY_URL` | Emby server URL | +| `EMBY_API_KEY` | Emby API key | +| `NAVIDROME_URL` | Navidrome server URL (Subsonic API) | +| `NAVIDROME_USER` | Navidrome username | +| `NAVIDROME_PASSWORD` | Navidrome password | +| `MUSIC_ROOT` | Path to the music library for the Cover Manager | +| `TMDB_BEARER_TOKEN` / `TMDB_API_KEY` | Optional artwork providers | +| `GOOGLE_CUSTOM_SEARCH_API_KEY` / `..._ENGINE_ID` | Optional artwork search | -### Docker (recommended) - -1. Edit `docker-compose.yml` with your Emby details: - ```yaml - environment: - - EMBY_URL=http://192.168.1.x:8096 - - EMBY_API_KEY=your-api-key - ``` +## Run with Docker (recommended) +1. Edit `docker-compose.yml` with your Emby/Navidrome details and mount your music + share to match `MUSIC_ROOT`. 2. Build and run: ```bash docker compose up -d --build ``` + The image builds the React SPA in a Node stage, then serves it from FastAPI. +3. Open `http://localhost:8500`. -3. Open `http://localhost:8500` - -### Manual - -1. Install dependencies: - ```bash - pip install -r requirements.txt - ``` - -2. Set environment variables: - ```bash - export EMBY_URL=http://192.168.1.x:8096 - export EMBY_API_KEY=your-api-key - ``` - -3. Run: - ```bash - python app.py - ``` - -4. Open `http://localhost:8500` - -## Getting your Emby API Key - -1. Open Emby Dashboard → Advanced → API Keys -2. Click "New API Key" -3. Give it a name (e.g. "Thumb Generator") -4. Copy the key - -## Optional Artwork Providers - -The artwork editor always supports Emby images and Wikimedia Commons. You can enable additional search providers with environment variables: +## Local development +Backend: ```bash -export TMDB_BEARER_TOKEN=your-tmdb-read-access-token -# or: export TMDB_API_KEY=your-tmdb-v3-api-key - -export GOOGLE_CUSTOM_SEARCH_API_KEY=your-google-api-key -export GOOGLE_CUSTOM_SEARCH_ENGINE_ID=your-google-search-engine-id +pip install -r requirements.txt +python app.py # http://localhost:8500 ``` -TMDB is the preferred external artwork source for posters and backdrops. Google Custom Search is optional and only uses Google's official Custom Search JSON API. +Frontend (hot reload, proxies /api to :8500): +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` -## Notes +Production build of the SPA (served by FastAPI): +```bash +cd frontend && npm run build +``` -- First generation will be slower as rembg downloads the U2-Net model (~170MB) -- The model runs entirely offline after first download — no data leaves your network -- Generated thumbnails are cached in the `cache/` directory -- Works with both movies and TV series -- The "Apply to Emby" button sets the generated image as the item's Thumb image type +## Deploy to a NAS -## Tech Stack - -- **Backend:** Python, FastAPI, Pillow, rembg (U2-Net) -- **Frontend:** Vanilla HTML/CSS/JS -- **Deployment:** Docker +`deploy.ps1` syncs the project (including the `frontend/` source) over SSH and runs +`docker compose build && up -d` remotely. The frontend is built inside the image: +```powershell +.\deploy.ps1 -NasHost MATT-NAS -NasUser ssh +``` diff --git a/app.py b/app.py index 1980f9d..bf8cdff 100644 --- a/app.py +++ b/app.py @@ -15,15 +15,25 @@ logging.basicConfig( format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) -logger = logging.getLogger("embytoolkit") +logger = logging.getLogger("homelabtoolkit") import httpx from fastapi import FastAPI, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, Response, StreamingResponse +from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError +from services import db as db_service +from services import favorites as favorites_service +from services import music_covers as music_service +from services import music_library as library_service +from services import navidrome as navidrome_service +from services import settings as settings_service +from services.favorites import FavoritesError +from services.music_covers import ProcessOptions +from services.navidrome import NavidromeError +from services.recommendations import DEFAULT_TARGET_SIZE + Image.MAX_IMAGE_PIXELS = None # Emby backdrops can exceed PIL's default bomb threshold; source is trusted EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096") @@ -290,8 +300,21 @@ async def get_cached_emby_source_image( # --- Emby API helpers --- +def apply_settings(values: dict) -> None: + """Push effective settings into the live module globals the app reads.""" + global EMBY_URL, EMBY_API_KEY + EMBY_URL = values["emby_url"] + EMBY_API_KEY = values["emby_api_key"] + navidrome_service.NAVIDROME_URL = values["navidrome_url"] + navidrome_service.NAVIDROME_USER = values["navidrome_user"] + navidrome_service.NAVIDROME_PASSWORD = values["navidrome_password"] + music_service.MUSIC_ROOT = Path(values["music_root"]) + + @asynccontextmanager async def lifespan(app: FastAPI): + apply_settings(settings_service.load()) + db_service.init_db() get_http_client() try: yield @@ -302,9 +325,14 @@ async def lifespan(app: FastAPI): http_client = None -app = FastAPI(title="EmbyToolkit", lifespan=lifespan) +app = FastAPI(title="HomelabToolkit", lifespan=lifespan) app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") + +# The React SPA is built to frontend/dist. When present we serve its assets and +# fall back to index.html for client-side routes (see the catch-all near the end). +FRONTEND_DIST = Path("frontend/dist") +if (FRONTEND_DIST / "assets").exists(): + app.mount("/assets", StaticFiles(directory=str(FRONTEND_DIST / "assets")), name="assets") def get_http_client() -> httpx.AsyncClient: global http_client @@ -1983,25 +2011,6 @@ async def render_collection_art_preview(options: dict) -> tuple[str, bytes]: # --- API Routes --- -@app.get("/", response_class=HTMLResponse) -async def index(request: Request): - return templates.TemplateResponse(request, "index.html") - - -@app.get("/collections", response_class=HTMLResponse) -async def collections_page(request: Request): - return templates.TemplateResponse(request, "collections.html") - - -@app.get("/airing", response_class=HTMLResponse) -async def airing_page(request: Request): - return templates.TemplateResponse(request, "airing.html") - - -@app.get("/bulk-assign", response_class=HTMLResponse) -async def bulk_assign_page(request: Request): - return templates.TemplateResponse(request, "bulk_assign.html") - @app.get("/api/bulk-assign/series") async def get_bulk_assign_series( @@ -2209,7 +2218,7 @@ async def bulk_apply_new_season_banner(request: Request): skipped_missing_assets: list[str] = [] failed: list[dict] = [] - logger.info("=== Bulk Apply Selected: %d %s item(s) ===", len(item_ids), emby_type) + logger.info("=== Bulk Apply Selected: %d item(s) ===", len(item_ids)) for i, item_id in enumerate(item_ids): item = items_by_id.get(item_id) if item is None: @@ -2892,11 +2901,495 @@ async def bulk_apply_category(request: Request): @app.get("/api/config") async def get_config(): return { - "emby_url": EMBY_URL, - "connected": bool(EMBY_API_KEY), + "app_name": "HomelabToolkit", + "emby": { + "url": EMBY_URL, + "connected": bool(EMBY_API_KEY), + }, + "navidrome": { + "url": navidrome_service.NAVIDROME_URL, + "configured": navidrome_service.is_configured(), + }, + "music": { + "root": str(music_service.MUSIC_ROOT), + "available": music_service.MUSIC_ROOT.exists(), + }, } +# ── Dashboard overview ─────────────────────────────────────────────────────── + + +@app.get("/api/dashboard") +async def get_dashboard(): + """Aggregate library counts for the homepage. Resilient: any failing source + degrades to null/0 rather than failing the whole response.""" + client = get_http_client() + + async def emby_count(item_types: str) -> int: + data = await emby_get("/Items", { + "IncludeItemTypes": item_types, + "Recursive": "true", + "Limit": "1", + "ImageTypeLimit": "0", + }) + return int(data.get("TotalRecordCount", 0)) + + async def latest_added() -> str | None: + data = await emby_get("/Items", { + "IncludeItemTypes": "Movie,Episode", + "Recursive": "true", + "SortBy": "DateCreated", + "SortOrder": "Descending", + "Limit": "1", + "Fields": "DateCreated", + }) + items = data.get("Items") or [] + return items[0].get("DateCreated") if items else None + + async def user_count() -> int: + data = await emby_get("/Users") + return len(data) if isinstance(data, list) else 0 + + async def favorites_count() -> int: + return len(await favorites_service.list_favorites_users(emby_client_adapter)) + + async def navidrome_stats() -> dict: + status = await navidrome_service.ping(client) + if not status.get("connected"): + return {"connected": False, "configured": status.get("configured", False)} + stats = await navidrome_service.get_stats(client) + return {"connected": True, "configured": True, **stats} + + ( + movies, series, episodes, collections, users, favorites, last_added, navidrome, + ) = await asyncio.gather( + emby_count("Movie"), + emby_count("Series"), + emby_count("Episode"), + emby_count("BoxSet"), + user_count(), + favorites_count(), + latest_added(), + navidrome_stats(), + return_exceptions=True, + ) + + def safe(value, default=0): + return default if isinstance(value, Exception) else value + + return { + "emby": { + "connected": bool(EMBY_API_KEY), + "url": EMBY_URL, + "movies": safe(movies), + "series": safe(series), + "episodes": safe(episodes), + "collections": safe(collections), + "users": safe(users), + "favorites_collections": safe(favorites), + "last_added": safe(last_added, None), + }, + "navidrome": navidrome if not isinstance(navidrome, Exception) else {"connected": False}, + "music": { + "available": music_service.MUSIC_ROOT.exists(), + "root": str(music_service.MUSIC_ROOT), + }, + } + + +@app.post("/api/emby/refresh-libraries") +async def emby_refresh_libraries(): + """Trigger a scan of all Emby libraries.""" + response = await emby_request( + "POST", "/Library/Refresh", headers={"X-Emby-Token": EMBY_API_KEY} + ) + if response.status_code not in (200, 204): + raise HTTPException( + status_code=502, + detail=f"Emby library refresh failed ({response.status_code}).", + ) + return {"status": "started"} + + +@app.post("/api/navidrome/scan") +async def navidrome_scan(full: bool = Query(False)): + """Trigger a Navidrome library scan.""" + try: + result = await navidrome_service.start_scan(get_http_client(), full=full) + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + return {"status": "started", **result} + + +@app.get("/api/emby/user-activity") +async def emby_user_activity(): + """Per-user last login + last activity, enriched with the most recent + session's IP/device. ``/Users`` carries login timestamps; ``/Sessions`` + carries the remote endpoint (IP).""" + users, sessions = await asyncio.gather( + emby_get("/Users"), + emby_get("/Sessions"), + return_exceptions=True, + ) + if isinstance(users, Exception): + raise HTTPException(status_code=502, detail="Could not load Emby users.") + if isinstance(sessions, Exception) or not isinstance(sessions, list): + sessions = [] + + def clean_ip(value: str | None) -> str | None: + if not value: + return None + value = value.strip() + if value.startswith("::ffff:"): + value = value[len("::ffff:"):] + return value or None + + def platform_of(client: str | None, device: str | None) -> str: + text = f"{client or ''} {device or ''}".lower() + if "android" in text: + return "android" + if any(k in text for k in ("ios", "iphone", "ipad", "apple tv", "tvos")): + return "ios" + if any(k in text for k in ("web", "browser", "chrome", "firefox", "safari", "edge")): + return "web" + return "other" + + latest_session: dict[str, dict] = {} + devices: dict[str, str] = {} # device id -> platform + for session in sessions: + client = session.get("Client") + device = session.get("DeviceName") + device_id = session.get("DeviceId") or f"{device}::{client}" + if device_id: + devices[device_id] = platform_of(client, device) + user_id = session.get("UserId") + if not user_id: + continue + last = session.get("LastActivityDate") or "" + existing = latest_session.get(user_id) + if existing is None or last > existing["last"]: + latest_session[user_id] = { + "last": last, + "ip": clean_ip(session.get("RemoteEndPoint")), + "device": device, + "client": client, + } + + rows = [] + for user in users if isinstance(users, list) else []: + user_id = user.get("Id") + session = latest_session.get(user_id, {}) + rows.append({ + "id": user_id, + "name": user.get("Name", ""), + "last_login": user.get("LastLoginDate"), + "last_activity": user.get("LastActivityDate"), + "ip": session.get("ip"), + "device": session.get("device"), + "client": session.get("client"), + }) + + rows.sort(key=lambda r: (r["last_activity"] or r["last_login"] or ""), reverse=True) + + platform_counts = {"android": 0, "ios": 0, "web": 0, "other": 0} + for platform in devices.values(): + platform_counts[platform] += 1 + device_count = len(devices) + + def pct(value: int) -> int: + return round(value / device_count * 100) if device_count else 0 + + summary = { + "user_count": len(rows), + "device_count": device_count, + "platforms": platform_counts, + "platform_pct": {key: pct(value) for key, value in platform_counts.items()}, + } + return {"users": rows, "summary": summary} + + +# ── Settings ───────────────────────────────────────────────────────────────── + + +@app.get("/api/settings") +async def get_settings(): + return settings_service.load() + + +@app.post("/api/settings") +async def update_settings(request: Request): + try: + body = await request.json() + except Exception: + body = {} + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail="Settings payload must be an object.") + values = settings_service.save(body) + apply_settings(values) + navidrome_status = await navidrome_service.ping(get_http_client()) + return { + "settings": values, + "navidrome": navidrome_status, + "music_available": music_service.MUSIC_ROOT.exists(), + } + + +# ── Navidrome (Subsonic API) ───────────────────────────────────────────────── + + +def _handle_navidrome_error(exc: NavidromeError) -> HTTPException: + return HTTPException(status_code=exc.status, detail=exc.message) + + +@app.get("/api/navidrome/status") +async def navidrome_status(): + return await navidrome_service.ping(get_http_client()) + + +@app.get("/api/navidrome/artists") +async def navidrome_artists(): + try: + return {"items": await navidrome_service.get_artists(get_http_client())} + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + + +@app.get("/api/navidrome/albums") +async def navidrome_albums( + q: str = Query(""), + type: str = Query("alphabeticalByName"), + size: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), +): + client = get_http_client() + try: + if q.strip(): + items = await navidrome_service.search_albums(client, q.strip(), count=size) + else: + items = await navidrome_service.get_albums(client, list_type=type, size=size, offset=offset) + return {"items": items, "offset": offset, "size": size, "has_more": len(items) >= size} + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + + +_navidrome_formats_cache: dict = {"data": None, "expires": 0.0} +NAVIDROME_FORMATS_TTL = 1800 + + +@app.get("/api/navidrome/formats") +async def navidrome_formats(refresh: bool = Query(False)): + now = time.time() + cached = _navidrome_formats_cache + if not refresh and cached["data"] is not None and cached["expires"] > now: + return cached["data"] + try: + data = await navidrome_service.get_format_breakdown(get_http_client()) + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + _navidrome_formats_cache.update(data=data, expires=now + NAVIDROME_FORMATS_TTL) + return data + + +@app.get("/api/navidrome/album/{album_id}") +async def navidrome_album(album_id: str): + try: + return await navidrome_service.get_album(get_http_client(), album_id) + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + + +@app.get("/api/navidrome/cover/{cover_id}") +async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)): + try: + image_bytes, content_type = await navidrome_service.get_cover_art( + get_http_client(), cover_id, size or None + ) + except NavidromeError as exc: + raise _handle_navidrome_error(exc) from exc + return Response(content=image_bytes, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"}) + + +# ── Music library maintenance (music-covers) ───────────────────────────────── + + +@app.get("/api/music/scan") +async def music_scan(): + return await asyncio.to_thread(music_service.scan_library) + + +# ── Music Collection Completeness ──────────────────────────────────────────── + + +@app.post("/api/music-collection/scan") +async def music_collection_scan(): + return library_service.start_scan_job() + + +@app.post("/api/music-collection/refresh-metadata") +async def music_collection_refresh_metadata(): + return library_service.start_metadata_job() + + +@app.get("/api/music-collection/status") +async def music_collection_status(): + return await asyncio.to_thread(library_service.get_status) + + +@app.get("/api/music-collection/overview") +async def music_collection_overview(): + return await asyncio.to_thread(library_service.get_overview) + + +@app.get("/api/music-collection/artists") +async def music_collection_artists(q: str = Query("")): + return {"artists": await asyncio.to_thread(library_service.get_artists_completeness, q)} + + +@app.get("/api/music-collection/artist/{artist_id}/albums") +async def music_collection_artist_albums(artist_id: int): + return await asyncio.to_thread(library_service.get_artist_albums, artist_id) + + +@app.post("/api/music-collection/album/{completeness_id}/decision") +async def music_collection_decision(completeness_id: int, request: Request): + try: + body = await request.json() + except Exception: + body = {} + action = (body or {}).get("action", "") + try: + return await asyncio.to_thread(library_service.set_album_decision, completeness_id, action) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@app.post("/api/music/process") +async def music_process(request: Request): + try: + body = await request.json() + except Exception: + body = {} + options = ProcessOptions.from_dict(body if isinstance(body, dict) else {}) + album_paths = body.get("album_paths") if isinstance(body, dict) else None + result = await asyncio.to_thread( + music_service.process_library, options, album_paths=album_paths + ) + return result + + +# ── User Favourites ────────────────────────────────────────────────────────── + +class EmbyClientAdapter: + """Adapts the module-level Emby helpers to the services' client protocol. + + Write operations carry the ``X-Emby-Token`` header (as the image write paths + do) and surface Emby errors as HTTPExceptions via ``ensure_emby_success``. + """ + + async def get(self, path: str, params: dict | None = None): + return await emby_get(path, params) + + async def get_all(self, path: str, params: dict | None = None): + return await emby_get_all(path, params) + + async def post(self, path: str, params: dict | None = None, **kwargs): + resp = await emby_request("POST", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs) + return ensure_emby_success(resp, context=f"Emby POST {path}") + + async def delete(self, path: str, params: dict | None = None, **kwargs): + resp = await emby_request("DELETE", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs) + return ensure_emby_success(resp, context=f"Emby DELETE {path}") + + +emby_client_adapter = EmbyClientAdapter() + + +async def _favorites_body(request: Request) -> dict: + try: + body = await request.json() + except Exception: + return {} + return body if isinstance(body, dict) else {} + + +@app.get("/api/favorites/users") +async def favorites_users(): + return {"users": await favorites_service.list_favorites_users(emby_client_adapter)} + + +@app.get("/api/favorites/collections") +async def favorites_collections_overview(): + return await favorites_service.list_collections_overview(emby_client_adapter) + + +@app.get("/api/favorites/collection/{collection_id}") +async def favorites_collection_items(collection_id: str, user_id: str = Query(...)): + try: + return await favorites_service.get_collection_items_view(emby_client_adapter, collection_id, user_id) + except FavoritesError as exc: + raise HTTPException(status_code=exc.status, detail=exc.message) from exc + + +@app.post("/api/favorites/collection/{collection_id}/cleanup") +async def favorites_cleanup(collection_id: str, request: Request): + body = await _favorites_body(request) + user_id = body.get("userId", "") + dry_run = bool(body.get("dryRun", True)) # dry-run is the default + try: + return await favorites_service.cleanup_watched( + emby_client_adapter, collection_id, user_id, dry_run=dry_run + ) + except FavoritesError as exc: + raise HTTPException(status_code=exc.status, detail=exc.message) from exc + + +@app.post("/api/favorites/collection/{collection_id}/regenerate") +async def favorites_regenerate(collection_id: str, request: Request): + body = await _favorites_body(request) + user_id = body.get("userId", "") + dry_run = bool(body.get("dryRun", True)) # dry-run is the default + try: + target_size = int(body.get("targetSize", DEFAULT_TARGET_SIZE)) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="targetSize must be an integer.") from exc + try: + return await favorites_service.regenerate( + emby_client_adapter, collection_id, user_id, dry_run=dry_run, target_size=target_size + ) + except FavoritesError as exc: + raise HTTPException(status_code=exc.status, detail=exc.message) from exc + + +# ── React SPA (must stay last so /api routes win) ──────────────────────────── + + +@app.get("/", response_class=HTMLResponse) +@app.get("/{full_path:path}", response_class=HTMLResponse) +async def serve_spa(full_path: str = ""): + """Serve the built React app, falling back to index.html for client routes.""" + if full_path.startswith("api/"): + raise HTTPException(status_code=404, detail="Not found") + + index_file = FRONTEND_DIST / "index.html" + if not index_file.exists(): + return HTMLResponse( + "

HomelabToolkit

Frontend not built. Run " + "npm install && npm run build in frontend/.

", + status_code=200, + ) + + # Serve real files (favicon, etc.) directly when they exist. + if full_path: + candidate = FRONTEND_DIST / full_path + if candidate.is_file(): + return FileResponse(candidate) + + return FileResponse(index_file) + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8500) diff --git a/deploy.ps1 b/deploy.ps1 index ede7cb7..352e144 100644 --- a/deploy.ps1 +++ b/deploy.ps1 @@ -21,7 +21,7 @@ param( [Parameter(Mandatory = $true)] [string]$NasUser, - [string]$RemoteAppDir = "/share/Docker/embycovers" + [string]$RemoteAppDir = "/share/Docker/homelabtoolkit" ) $ErrorActionPreference = "Stop" @@ -38,11 +38,11 @@ Require-Command scp $LocalDir = Split-Path -Parent $MyInvocation.MyCommand.Path $Remote = "$NasUser@$NasHost" -Write-Host "Deploying embycovers to ${Remote}:$RemoteAppDir" +Write-Host "Deploying HomelabToolkit to ${Remote}:$RemoteAppDir" ssh $Remote @" set -e -mkdir -p '$RemoteAppDir' '$RemoteAppDir/output' '$RemoteAppDir/cache' '$RemoteAppDir/static/studios' '$RemoteAppDir/templates' +mkdir -p '$RemoteAppDir' '$RemoteAppDir/output' '$RemoteAppDir/cache' '$RemoteAppDir/static/studios' '$RemoteAppDir/services' '$RemoteAppDir/frontend' "@ # Copy top-level project files. @@ -53,9 +53,25 @@ scp ` "$LocalDir/requirements.txt" ` "${Remote}:$RemoteAppDir/" -# Copy templates and static assets recursively. -scp -r "$LocalDir/templates/." "${Remote}:$RemoteAppDir/templates/" -scp -r "$LocalDir/static/." "${Remote}:$RemoteAppDir/static/" +# Copy static assets (studio logos, served at /static). +scp -r "$LocalDir/static/." "${Remote}:$RemoteAppDir/static/" + +# Copy the service layer (Emby, Navidrome, music-covers). Exclude local __pycache__. +scp -r "$LocalDir/services/." "${Remote}:$RemoteAppDir/services/" + +# Copy the React frontend SOURCE (built inside the Docker frontend stage). +# node_modules / dist are excluded — the image rebuilds them. +ssh $Remote "rm -rf '$RemoteAppDir/frontend/node_modules' '$RemoteAppDir/frontend/dist'" +scp "$LocalDir/frontend/package.json" "${Remote}:$RemoteAppDir/frontend/" +if (Test-Path "$LocalDir/frontend/package-lock.json") { + scp "$LocalDir/frontend/package-lock.json" "${Remote}:$RemoteAppDir/frontend/" +} +scp ` + "$LocalDir/frontend/vite.config.ts" ` + "$LocalDir/frontend/tsconfig.json" ` + "$LocalDir/frontend/index.html" ` + "${Remote}:$RemoteAppDir/frontend/" +scp -r "$LocalDir/frontend/src" "${Remote}:$RemoteAppDir/frontend/" # Copy any loose logo images that live at the repo root. $LogoFiles = Get-ChildItem -Path $LocalDir -File | diff --git a/docker-compose.yml b/docker-compose.yml index 9fc1d71..d488378 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,21 +1,30 @@ services: - embycovers: + homelabtoolkit: build: . - container_name: embytoolkit + container_name: homelabtoolkit ports: - "8500:8500" environment: - TZ=Pacific/Auckland + # Emby - EMBY_URL=http://10.0.0.2:8096 - EMBY_API_KEY=b9af54b630f6448289ab96422add567a + # Navidrome (Subsonic API) + - NAVIDROME_URL=http://10.0.0.2:4533 + - NAVIDROME_USER= + - NAVIDROME_PASSWORD= + # Music library root (for the Cover Manager). Mount the share below to match. + - MUSIC_ROOT=/music # Optional external artwork providers: # - TMDB_BEARER_TOKEN= # - TMDB_API_KEY= # - GOOGLE_CUSTOM_SEARCH_API_KEY= # - GOOGLE_CUSTOM_SEARCH_ENGINE_ID= volumes: - - /share/Docker/embytoolkit/output:/app/output - - /share/Docker/embytoolkit/cache:/app/cache + - /share/Docker/homelabtoolkit/output:/app/output + - /share/Docker/homelabtoolkit/cache:/app/cache + # Mount your music library so the Cover Manager can scan/maintain it: + - /share/Music:/music restart: unless-stopped networks: - npm_network diff --git a/embycovers.code-workspace b/embycovers.code-workspace new file mode 100644 index 0000000..d79d9d7 --- /dev/null +++ b/embycovers.code-workspace @@ -0,0 +1,9 @@ +{ + "folders": [ + { + "name": "embycovers", + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a4ba55f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + HomelabToolkit + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4d5484f --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "homelab-toolkit-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "homelab-toolkit-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz", + "integrity": "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.368", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", + "integrity": "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7ffc6ac --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "homelab-toolkit-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9b5acfa --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import Sidebar from "./components/Sidebar"; +import { AppConfig, apiGet } from "./api"; +import Dashboard from "./pages/Dashboard"; +import Generator from "./pages/emby/Generator"; +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 Library from "./pages/navidrome/Library"; +import CoverManager from "./pages/navidrome/CoverManager"; +import CollectionCompleteness from "./pages/navidrome/CollectionCompleteness"; +import Settings from "./pages/Settings"; + +const CRUMBS: Record = { + "/": ["", "Dashboard"], + "/emby/generator": ["Emby", "Thumbnail Generator"], + "/emby/collections": ["Emby", "Collection Art"], + "/emby/airing": ["Emby", "Airing & New Seasons"], + "/emby/bulk-assign": ["Emby", "Bulk Assign"], + "/emby/favorites": ["Emby", "User Favorites"], + "/navidrome/library": ["Navidrome", "Music Library"], + "/navidrome/covers": ["Navidrome", "Cover Manager"], + "/collection-completeness": ["Navidrome", "Collection Completeness"], + "/settings": ["System", "Settings"], +}; + +export default function App() { + const [config, setConfig] = useState(null); + const [navidromeConnected, setNavidromeConnected] = useState(false); + const location = useLocation(); + + function refreshConfig() { + apiGet("/api/config") + .then(setConfig) + .catch(() => setConfig(null)); + apiGet<{ connected: boolean }>("/api/navidrome/status") + .then((s) => setNavidromeConnected(!!s.connected)) + .catch(() => setNavidromeConnected(false)); + } + + useEffect(refreshConfig, []); + + const [section, page] = CRUMBS[location.pathname] || ["", ""]; + + return ( +
+ +
+
+
+ {section && ( + <> + {section}  / {" "} + + )} + {page} +
+
+
+
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..8cd2fa3 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,79 @@ +// Thin fetch wrapper around the FastAPI backend. All endpoints are same-origin +// (the SPA is served by FastAPI in production; proxied in dev via vite.config). + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +async function parseError(res: Response): Promise { + try { + const data = await res.json(); + if (typeof data?.detail === "string") return data.detail; + if (Array.isArray(data?.detail)) return data.detail.map((d: any) => d.msg).join(", "); + return res.statusText; + } catch { + return res.statusText || `Request failed (${res.status})`; + } +} + +export async function apiGet(path: string): Promise { + const res = await fetch(path); + if (!res.ok) throw new ApiError(await parseError(res), res.status); + return res.json(); +} + +export async function apiPost(path: string, body?: unknown): Promise { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) throw new ApiError(await parseError(res), res.status); + return res.json(); +} + +// Returns the rendered image as an object URL plus the X-Cache-Key header. +export async function apiPostImage( + path: string, + body: unknown +): Promise<{ url: string; cacheKey: string | null }> { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new ApiError(await parseError(res), res.status); + const blob = await res.blob(); + return { url: URL.createObjectURL(blob), cacheKey: res.headers.get("X-Cache-Key") }; +} + +export async function uploadBackground(file: File): Promise<{ upload_id: string; width: number; height: number }> { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/upload-background", { 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 }; + music: { root: string; available: boolean }; +} + +export interface SearchItem { + id: string; + name: string; + year: number | string; + type: string; + has_logo: boolean; + auto_studio: string | null; + backdrop_count: number; + poster_url: string; +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..91d6919 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { NavLink, useLocation } from "react-router-dom"; +import { AppConfig } from "../api"; +import { + IconCalendar, + IconChevron, + IconDisc, + IconEmby, + IconGrid, + IconHeart, + IconHome, + IconImage, + IconLayers, + IconMusic, + IconSettings, + IconWand, +} from "./icons"; + +interface NavItem { + to: string; + label: string; + icon: JSX.Element; +} + +const GROUPS: { id: string; label: string; icon: JSX.Element; links: NavItem[] }[] = [ + { + id: "emby", + label: "Emby", + icon: , + links: [ + { to: "/emby/generator", label: "Thumbnail Generator", icon: }, + { to: "/emby/collections", label: "Collection Art", icon: }, + { to: "/emby/airing", label: "Airing & New Seasons", icon: }, + { to: "/emby/bulk-assign", label: "Bulk Assign", icon: }, + { to: "/emby/favorites", label: "User Favorites", icon: }, + ], + }, + { + id: "navidrome", + label: "Navidrome", + icon: , + links: [ + { to: "/navidrome/library", label: "Music Library", icon: }, + { to: "/navidrome/covers", label: "Cover Manager", icon: }, + { to: "/collection-completeness", label: "Collection Completeness", icon: }, + ], + }, + { + id: "system", + label: "System", + icon: , + links: [{ to: "/settings", label: "Settings", icon: }], + }, +]; + +function Chip({ label, ok, configured }: { label: string; ok: boolean; configured: boolean }) { + const cls = !configured ? "idle" : ok ? "" : "off"; + const text = !configured ? "Not configured" : ok ? "Connected" : "Offline"; + return ( +
+ + {label} + + {text} + +
+ ); +} + +interface Props { + config: AppConfig | null; + navidromeConnected: boolean; +} + +export default function Sidebar({ config, navidromeConnected }: Props) { + const location = useLocation(); + const activeGroup = GROUPS.find((g) => g.links.some((l) => location.pathname.startsWith(l.to)))?.id; + + // Categories start collapsed; the group holding the current route opens itself. + const [open, setOpen] = useState>(() => (activeGroup ? { [activeGroup]: true } : {})); + + useEffect(() => { + if (activeGroup) setOpen((o) => (o[activeGroup] ? o : { ...o, [activeGroup]: true })); + }, [activeGroup]); + + const toggle = (id: string) => setOpen((o) => ({ ...o, [id]: !o[id] })); + + return ( + + ); +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..212f7fe --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,141 @@ +// Minimal feather-style icon set (stroke=currentColor) used across the app. +// A default 18px size keeps every icon consistent even where no CSS rule scopes +// it (e.g. category headers); callers can still override via CSS or the size prop. +type P = { className?: string }; +const base = { + fill: "none", + stroke: "currentColor", + strokeWidth: 2, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, + viewBox: "0 0 24 24", + width: 18, + height: 18, +}; + +export const IconImage = (p: P) => ( + + + + + +); +export const IconLayers = (p: P) => ( + + + + +); +export const IconCalendar = (p: P) => ( + + + + +); +export const IconGrid = (p: P) => ( + + + + + + +); +export const IconHeart = (p: P) => ( + + + +); +export const IconMusic = (p: P) => ( + + + + + +); +export const IconDisc = (p: P) => ( + + + + +); +export const IconWand = (p: P) => ( + + + + +); +export const IconSearch = (p: P) => ( + + + + +); +export const IconHome = (p: P) => ( + + + +); +export const IconServer = (p: P) => ( + + + + + +); +export const IconRefresh = (p: P) => ( + + + + +); +export const IconCheck = (p: P) => ( + + + +); +export const IconUpload = (p: P) => ( + + + + +); +export const IconTrash = (p: P) => ( + + + +); +export const IconPlay = (p: P) => ( + + + +); +export const IconUser = (p: P) => ( + + + + +); +export const IconChevron = (p: P) => ( + + + +); +export const IconFolder = (p: P) => ( + + + +); +export const IconSettings = (p: P) => ( + + + + +); +// Stylized Emby media mark (rounded square + play). Inherits currentColor so it +// tints with nav state; swap in the official asset if you have it. +export const IconEmby = (p: P) => ( + + + + +); diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..5827bd1 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,119 @@ +import { ReactNode } from "react"; + +export function PageHead({ title, icon, children }: { title: string; icon?: ReactNode; children?: ReactNode }) { + return ( +
+
+ {icon && {icon}} +

{title}

+
+ {children &&

{children}

} +
+ ); +} + +export function Avatar({ name }: { name: string }) { + const initials = + name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]) + .join("") + .toUpperCase() || "?"; + // Deterministic hue from the name so each user keeps a stable color. + let hash = 0; + for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) % 360; + return ( + + {initials} + + ); +} + +export function formatNZ(iso: string | null | undefined): string { + if (!iso) return "Never"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "Never"; + return d.toLocaleString("en-NZ", { + timeZone: "Pacific/Auckland", + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +export function StatCard({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) { + return ( +
+
{icon}
+
+
{value}
+
{label}
+
+
+ ); +} + +export function Empty({ icon, children }: { icon?: ReactNode; children: ReactNode }) { + return ( +
+ {icon} +
{children}
+
+ ); +} + +export function Loading({ label }: { label?: string }) { + return ( +
+ + {label && {label}} +
+ ); +} + +export function timeAgo(iso: string | null | undefined): string { + if (!iso) return "—"; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return "—"; + const secs = Math.max(0, (Date.now() - then) / 1000); + const units: [number, string][] = [ + [60, "second"], + [60, "minute"], + [24, "hour"], + [30, "day"], + [12, "month"], + [Number.POSITIVE_INFINITY, "year"], + ]; + let value = secs; + for (const [size, name] of units) { + if (value < size) { + const v = Math.floor(value); + return `${v} ${name}${v === 1 ? "" : "s"} ago`; + } + value /= size; + } + return "—"; +} + +export function fmtNumber(n: number | null | undefined): string { + if (n == null) return "—"; + return n.toLocaleString(); +} + +export function fmtDuration(seconds: number): string { + if (!seconds) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + if (m >= 60) { + const h = Math.floor(m / 60); + return `${h}h ${m % 60}m`; + } + return `${m}:${String(s).padStart(2, "0")}`; +} diff --git a/frontend/src/lib/toast.tsx b/frontend/src/lib/toast.tsx new file mode 100644 index 0000000..80ce76d --- /dev/null +++ b/frontend/src/lib/toast.tsx @@ -0,0 +1,37 @@ +import { createContext, useCallback, useContext, useState, ReactNode } from "react"; + +type ToastKind = "ok" | "err" | "info"; +interface Toast { + id: number; + message: string; + kind: ToastKind; +} + +const ToastContext = createContext<(message: string, kind?: ToastKind) => void>(() => {}); + +export function useToast() { + return useContext(ToastContext); +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + + const push = useCallback((message: string, kind: ToastKind = "info") => { + const id = Date.now() + Math.random(); + setToasts((t) => [...t, { id, message, kind }]); + setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 4200); + }, []); + + return ( + + {children} +
+ {toasts.map((t) => ( +
+ {t.message} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..db1ac0e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import { ToastProvider } from "./lib/toast"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + +); diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9f77da3 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,411 @@ +import { useEffect, useState, ReactNode } from "react"; +import { Link } from "react-router-dom"; +import { AppConfig, apiGet, apiPost } from "../api"; +import { useToast } from "../lib/toast"; +import { PageHead, StatCard, Loading, Empty, Avatar, timeAgo, fmtNumber, formatNZ } from "../components/ui"; +import { + IconCalendar, + IconChevron, + IconDisc, + IconEmby, + IconGrid, + IconHeart, + IconHome, + IconImage, + IconLayers, + IconMusic, + IconPlay, + IconRefresh, + IconUser, + IconWand, +} from "../components/icons"; + +interface Props { + config: AppConfig | null; + navidromeConnected: boolean; +} + +interface DashboardData { + emby: { + connected: boolean; + url: string; + movies: number; + series: number; + episodes: number; + collections: number; + users: number; + favorites_collections: number; + last_added: string | null; + }; + navidrome: { + connected: boolean; + configured?: boolean; + artist_count?: number; + album_count?: number; + song_count?: number; + genre_count?: number; + top_genres?: { name: string; song_count: number; album_count: number }[]; + }; + music: { available: boolean; root: string }; +} + +interface UserActivity { + id: string; + name: string; + last_login: string | null; + last_activity: string | null; + ip: string | null; + device: string | null; + client: string | null; +} + +interface ActivitySummary { + user_count: number; + device_count: number; + platforms: { android: number; ios: number; web: number; other: number }; + platform_pct: { android: number; ios: number; web: number; other: number }; +} + +interface FormatData { + total: number; + formats: { format: string; count: number }[]; +} + +const FORMAT_COLORS: Record = { + flac: "var(--accent)", + mp3: "var(--amber)", + m4a: "var(--green)", + aac: "var(--green)", + alac: "#7fd1ff", + ogg: "var(--purple)", + opus: "var(--purple)", + wav: "#8fa3b8", + wma: "#c98cf3", + other: "var(--text-3)", +}; +const formatColor = (fmt: string) => FORMAT_COLORS[fmt.toLowerCase()] || "#6b7c90"; + +const tools = [ + { to: "/emby/generator", label: "Thumbnail Generator", icon: , cat: "Emby" }, + { to: "/emby/collections", label: "Collection Art", icon: , cat: "Emby" }, + { to: "/emby/airing", label: "Airing & New Seasons", icon: , cat: "Emby" }, + { to: "/emby/bulk-assign", label: "Bulk Assign", icon: , cat: "Emby" }, + { to: "/emby/favorites", label: "User Favorites", icon: , cat: "Emby" }, + { to: "/navidrome/library", label: "Music Library", icon: , cat: "Navidrome" }, + { to: "/navidrome/covers", label: "Cover Manager", icon: , cat: "Navidrome" }, +]; + +function MiniStat({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) { + return ( +
+
{icon}
+
+
{value}
+
{label}
+
+
+ ); +} + +function StatusBadge({ configured, connected }: { configured: boolean; connected: boolean }) { + if (!configured) return not configured; + return {connected ? "connected" : "offline"}; +} + +export default function Dashboard({ config, navidromeConnected }: Props) { + const toast = useToast(); + const [data, setData] = useState(null); + const [activity, setActivity] = useState(null); + const [activitySummary, setActivitySummary] = useState(null); + const [formats, setFormats] = useState(null); + const [formatsLoading, setFormatsLoading] = useState(false); + const [loading, setLoading] = useState(true); + const [embyScanning, setEmbyScanning] = useState(false); + const [navScanning, setNavScanning] = useState(false); + + async function refreshEmby() { + setEmbyScanning(true); + try { + await apiPost("/api/emby/refresh-libraries"); + toast("Emby library scan started", "ok"); + } catch (err: any) { + toast(err.message, "err"); + } finally { + setEmbyScanning(false); + } + } + + async function scanNavidrome() { + setNavScanning(true); + try { + await apiPost("/api/navidrome/scan"); + toast("Navidrome scan started", "ok"); + } catch (err: any) { + toast(err.message, "err"); + } finally { + setNavScanning(false); + } + } + + function load() { + setLoading(true); + apiGet("/api/dashboard") + .then(setData) + .catch(() => setData(null)) + .finally(() => setLoading(false)); + apiGet<{ users: UserActivity[]; summary: ActivitySummary }>("/api/emby/user-activity") + .then((d) => { + setActivity(d.users); + setActivitySummary(d.summary); + }) + .catch(() => { + setActivity([]); + setActivitySummary(null); + }); + // Format breakdown pages the whole song list, so it may take a moment on the + // first load; the backend caches it for subsequent calls. + setFormatsLoading(true); + apiGet("/api/navidrome/formats") + .then(setFormats) + .catch(() => setFormats(null)) + .finally(() => setFormatsLoading(false)); + } + useEffect(load, []); + + const e = data?.emby; + const n = data?.navidrome; + const genres = n?.top_genres ?? []; + const maxGenre = genres.reduce((m, g) => Math.max(m, g.song_count), 0) || 1; + + return ( + <> +
+ }> + A live overview of your media stack — Emby library health on the left, your Navidrome music collection on the + right. + + +
+ + {loading && !data ? ( + + ) : ( + <> +
+ {/* ── Emby column ── */} +
+
+
+ +
+

Emby

+ + +
+
+
+ } value={fmtNumber(e?.movies)} label="Movies" /> + } value={fmtNumber(e?.series)} label="Series" /> + } value={fmtNumber(e?.episodes)} label="Episodes" /> + } value={fmtNumber(e?.collections)} label="Collections" /> + } value={fmtNumber(e?.users)} label="Users" /> + } value={fmtNumber(e?.favorites_collections)} label="Favorites" /> +
+
+
+ +
+
+
+ {timeAgo(e?.last_added)} +
+
Last item added · {e?.url}
+
+
+
+
+ + {/* ── Navidrome column ── */} +
+
+
+ +
+

Navidrome

+ + +
+
+ {!n?.connected ? ( + }> + {n?.configured + ? "Navidrome is configured but offline." + : "Navidrome is not configured. Add your server details in Settings."} + + ) : ( + <> +
+ } value={fmtNumber(n?.artist_count)} label="Artists" /> + } value={fmtNumber(n?.album_count)} label="Albums" /> + } value={fmtNumber(n?.song_count)} label="Tracks" /> + } value={fmtNumber(n?.genre_count)} label="Genres" /> +
+
+
+ Audio formats +
+ {formatsLoading && !formats ? ( +

+ Analyzing track formats… +

+ ) : !formats || formats.total === 0 ? ( +

No format data.

+ ) : ( + <> +
+ {formats.formats.map((f) => ( + + ))} +
+
+ {formats.formats.map((f) => ( +
+ + {f.format} + + {fmtNumber(f.count)} · {Math.round((f.count / formats.total) * 100)}% + +
+ ))} +
+ + )} +
+ +
+
+ Top genres +
+ {genres.length === 0 ? ( +

No genre data.

+ ) : ( + genres.map((g) => ( +
+ {g.name} + + + + {fmtNumber(g.song_count)} +
+ )) + )} +
+ + )} +
+
+
+ + {/* ── User activity (full width) ── */} +
+
+ +

User Activity

+ {activitySummary && ( +
+ {activitySummary.user_count} users + {activitySummary.device_count} devices + {activitySummary.platforms.android > 0 && ( + Android {activitySummary.platform_pct.android}% + )} + {activitySummary.platforms.ios > 0 && ( + iOS {activitySummary.platform_pct.ios}% + )} + {activitySummary.platforms.web > 0 && ( + Web {activitySummary.platform_pct.web}% + )} + {activitySummary.platforms.other > 0 && ( + Other {activitySummary.platform_pct.other}% + )} +
+ )} +
+ {!activity ? ( +
+ +
+ ) : activity.length === 0 ? ( + }>No Emby users found. + ) : ( +
+ + + + + + + + + + + + {activity.map((u) => { + const when = u.last_activity || u.last_login; + return ( + + + + + + + + ); + })} + +
UserLast login (NZ)WhenIP addressDevice
+
+ + {u.name} +
+
{formatNZ(u.last_login)}{when ? timeAgo(when) : "Never"}{u.ip || } + {u.device || "—"} + {u.client ? ` · ${u.client}` : ""} +
+
+ )} +
+ + {/* ── Quick actions ── */} +
Tools
+
+ {tools.map((t) => ( + +
+ {t.icon} +
+
+
{t.label}
+
+ {t.cat} +
+
+ + + ))} +
+ + )} + + ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..d54c799 --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,117 @@ +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 { useToast } from "../lib/toast"; + +interface SettingsValues { + emby_url: string; + emby_api_key: string; + navidrome_url: string; + navidrome_user: string; + navidrome_password: string; + music_root: string; +} + +const LABELS: Record = { + emby_url: { label: "Emby URL", placeholder: "http://10.0.0.2:8096" }, + emby_api_key: { label: "Emby API key", secret: true }, + navidrome_url: { label: "Navidrome URL", placeholder: "http://10.0.0.2:4533" }, + navidrome_user: { label: "Navidrome username" }, + navidrome_password: { label: "Navidrome password", secret: true }, + music_root: { label: "Music library path", placeholder: "/music" }, +}; + +export default function Settings({ onSaved }: { onSaved?: () => void }) { + const toast = useToast(); + const [values, setValues] = useState(null); + const [saving, setSaving] = useState(false); + const [reveal, setReveal] = useState(false); + + useEffect(() => { + apiGet("/api/settings") + .then(setValues) + .catch((e) => toast(e.message, "err")); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + function set(k: K, v: string) { + setValues((s) => (s ? { ...s, [k]: v } : s)); + } + + async function save() { + if (!values) return; + setSaving(true); + try { + const res = 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"); + } + onSaved?.(); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setSaving(false); + } + } + + if (!values) return ; + + // Render inline (not as a nested component) so React keeps input identity + // stable across renders — otherwise each keystroke remounts and drops focus. + const groups: { title: string; icon: React.ReactNode; keys: (keyof SettingsValues)[] }[] = [ + { title: "Emby", icon: , keys: ["emby_url", "emby_api_key"] }, + { title: "Navidrome", icon: , keys: ["navidrome_url", "navidrome_user", "navidrome_password"] }, + { title: "Music library", icon: , keys: ["music_root"] }, + ]; + + return ( + <> + }> + Configure connections without touching environment variables. Saved settings are written to the app's config + file and applied immediately — they override the deploy-time defaults. + + +
+ {groups.map((g) => ( +
+
+
+ {g.icon} +
+

{g.title}

+
+
+ {g.keys.map((k) => ( +
+ + set(k, e.target.value)} + /> +
+ ))} +
+
+ ))} + +
+ + +
+

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

+
+ + ); +} diff --git a/frontend/src/pages/emby/Airing.tsx b/frontend/src/pages/emby/Airing.tsx new file mode 100644 index 0000000..34ed98e --- /dev/null +++ b/frontend/src/pages/emby/Airing.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api"; +import { PageHead, Empty, Loading } from "../../components/ui"; +import { IconCalendar, IconCheck, IconRefresh } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface AiringItem { + id: string; + name: string; + year?: number; + status: string; + air_days: string[]; + poster_url: string; + has_logo: boolean; + selected_week_air_at: string | null; + selected_week_episode_label: string | null; + next_air_at: string | null; + next_episode_label: string | null; + season_number: number | null; + eligible_new_season: boolean; +} +interface Snapshot { + items: AiringItem[]; + week_start: string; + week_end: string; + week_offset: number; +} + +const WEEKS = [ + { off: -1, label: "Last week" }, + { off: 0, label: "This week" }, + { off: 1, label: "Next week" }, +]; + +function fmtDate(iso: string | null) { + if (!iso) return null; + return new Date(iso).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); +} + +export default function Airing() { + const toast = useToast(); + const [snap, setSnap] = useState(null); + const [loading, setLoading] = useState(true); + const [weekOffset, setWeekOffset] = useState(0); + const [eligibleOnly, setEligibleOnly] = useState(false); + const [busy, setBusy] = useState>({}); + const [selected, setSelected] = useState>(new Set()); + + const load = useCallback( + (refresh = false) => { + setLoading(true); + apiGet(`/api/airing?week_offset=${weekOffset}&eligible_only=${eligibleOnly}&limit=48&refresh=${refresh}`) + .then(setSnap) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + }, + [weekOffset, eligibleOnly, toast] + ); + + useEffect(() => { + load(); + }, [load]); + + async function applyOne(item: AiringItem) { + setBusy((b) => ({ ...b, [item.id]: true })); + try { + await apiPost("/api/airing/apply-new-season", { + item_id: item.id, + generate_primary: true, + week_offset: weekOffset, + }); + toast(`New Season artwork applied to ${item.name}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy((b) => ({ ...b, [item.id]: false })); + } + } + + async function applySelected() { + const ids = [...selected]; + if (!ids.length) return; + try { + const res = await apiPost("/api/airing/apply-new-season/bulk", { item_ids: ids }); + toast(`Applied to ${res.applied_count} series`, "ok"); + setSelected(new Set()); + } catch (e: any) { + toast(e.message, "err"); + } + } + + function toggle(id: string) { + setSelected((s) => { + const n = new Set(s); + n.has(id) ? n.delete(id) : n.add(id); + return n; + }); + } + + return ( + <> + + Series currently airing in your library. Eligible new-season premieres can be stamped with "New Season" + artwork in one click. + + +
+
+
+ {WEEKS.map((w) => ( + + ))} +
+ +
+
+ {selected.size > 0 && ( + + )} + +
+
+ + {loading ? ( + + ) : !snap?.items.length ? ( +
+ }>No airing series found for this week. +
+ ) : ( +
+ {snap.items.map((item) => ( +
+
+ +
+
{item.name}
+
+ {item.status} + {item.eligible_new_season && eligible} + {!item.has_logo && no logo} +
+
+ {item.selected_week_episode_label || item.next_episode_label || "—"} + {fmtDate(item.selected_week_air_at || item.next_air_at) && ( +
{fmtDate(item.selected_week_air_at || item.next_air_at)}
+ )} +
+
+
+
+ + +
+
+ ))} +
+ )} + + ); +} diff --git a/frontend/src/pages/emby/BulkAssign.tsx b/frontend/src/pages/emby/BulkAssign.tsx new file mode 100644 index 0000000..4910f93 --- /dev/null +++ b/frontend/src/pages/emby/BulkAssign.tsx @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api"; +import { PageHead, Empty, Loading } from "../../components/ui"; +import { IconCheck, IconGrid, IconRefresh, IconSearch } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface Item { + id: string; + name: string; + year?: number; + type: string; + poster_url: string | null; + has_primary: boolean; + has_logo: boolean; + has_backdrop: boolean; + can_bulk_assign: boolean; +} + +const STUDIOS = ["netflix", "appletv", "paramountplus", "hbo", "disney", "hulu"]; + +export default function BulkAssign() { + const toast = useToast(); + const [kind, setKind] = useState<"series" | "movies">("series"); + const [query, setQuery] = useState(""); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [selected, setSelected] = useState>(new Set()); + const [working, setWorking] = useState(false); + + const load = useCallback(() => { + setLoading(true); + apiGet<{ items: Item[]; total: number }>(`/api/bulk-assign/${kind}?q=${encodeURIComponent(query.trim())}&limit=60`) + .then((d) => { + setItems(d.items); + setTotal(d.total); + }) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + }, [kind, query, toast]); + + useEffect(() => { + load(); + }, [kind]); // eslint-disable-line react-hooks/exhaustive-deps + + function toggle(id: string) { + setSelected((s) => { + const n = new Set(s); + n.has(id) ? n.delete(id) : n.add(id); + return n; + }); + } + function selectAllEligible() { + setSelected(new Set(items.filter((i) => i.can_bulk_assign).map((i) => i.id))); + } + + async function applySelected() { + const ids = [...selected]; + if (!ids.length) return; + setWorking(true); + try { + const res = await apiPost("/api/bulk-assign/apply", { item_ids: ids }); + toast(`Applied ${res.applied_count}, skipped ${res.skipped_missing_assets_count}, failed ${res.failed_count}`, "ok"); + setSelected(new Set()); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setWorking(false); + } + } + + async function applyAll() { + if (!window.confirm(`Apply thumbnails to ALL eligible ${kind} in your library? This may take a while.`)) return; + setWorking(true); + try { + const res = await apiPost("/api/bulk-assign/apply-all", { item_type: kind === "series" ? "series" : "movie" }); + toast(`Applied ${res.applied_count} of ${res.eligible_count} eligible`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setWorking(false); + } + } + + async function resetStudio(studio: string) { + if (!window.confirm(`Reset Emby Thumb & Primary images for all ${studio} titles? Emby will re-download originals.`)) return; + setWorking(true); + try { + const res = await apiPost("/api/bulk-reset/studio", { studio_key: studio }); + toast(`Reset ${res.reset} titles (${res.skipped} skipped)`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setWorking(false); + } + } + + return ( + <> + + Generate and push landscape thumbnails across many titles at once. Eligible titles need an Emby primary, logo + and backdrop. + + +
+
+
+ + +
+
{ + e.preventDefault(); + load(); + }} + > + + setQuery(e.target.value)} /> + +
+
+ + {selected.size > 0 && ( + + )} + +
+
+ +
+ {total} {kind} · {items.filter((i) => i.can_bulk_assign).length} eligible on this page +
+ + {loading ? ( + + ) : !items.length ? ( +
+ }>No titles found. +
+ ) : ( +
+ {items.map((it) => ( +
it.can_bulk_assign && toggle(it.id)} + style={{ opacity: it.can_bulk_assign ? 1 : 0.55, cursor: it.can_bulk_assign ? "pointer" : "default" }} + > + {it.poster_url ? ( + + ) : ( +
+ )} +
+
{it.name}
+
+ + logo + + + bd + + {selected.has(it.id) && } +
+
+
+ ))} +
+ )} + +
+
+

Reset studio artwork

+ Delete generated Thumb/Primary so Emby re-downloads originals +
+
+ {STUDIOS.map((s) => ( + + ))} +
+
+ + ); +} diff --git a/frontend/src/pages/emby/Collections.tsx b/frontend/src/pages/emby/Collections.tsx new file mode 100644 index 0000000..7abe1b5 --- /dev/null +++ b/frontend/src/pages/emby/Collections.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet, apiPost, apiPostImage } from "../../api"; +import { PageHead, Empty, Loading } from "../../components/ui"; +import { IconCheck, IconLayers, IconSearch, IconWand } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface Collection { + id: string; + name: string; + child_count: number; + poster_url: string | null; +} + +interface Opts { + target_type: string; + text: string; + text_color: string; + text_align: string; + text_position: string; + text_scale: number; + darkness: number; +} + +const DEFAULTS: Opts = { + target_type: "Thumb", + text: "", + text_color: "#FFFFFF", + text_align: "center", + text_position: "bottom", + text_scale: 1.0, + darkness: 0.18, +}; + +export default function Collections() { + const toast = useToast(); + const [query, setQuery] = useState(""); + const [list, setList] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [opts, setOpts] = useState(DEFAULTS); + const [preview, setPreview] = useState(null); + const [rendering, setRendering] = useState(false); + const [applying, setApplying] = useState(false); + const debounceRef = useRef(); + + const set = (k: K, v: Opts[K]) => setOpts((o) => ({ ...o, [k]: v })); + + const load = useCallback(() => { + setLoading(true); + apiGet<{ items: Collection[] }>(`/api/collections?q=${encodeURIComponent(query.trim())}&limit=60`) + .then((d) => setList(d.items)) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + }, [query, toast]); + + useEffect(() => { + load(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + function select(c: Collection) { + setSelected(c); + setPreview(null); + setOpts({ ...DEFAULTS, text: c.name }); + } + + const generate = useCallback(async () => { + if (!selected) return; + setRendering(true); + try { + const { url } = await apiPostImage("/api/collections/generate", { item_id: selected.id, ...opts }); + setPreview((p) => { + if (p) URL.revokeObjectURL(p); + return url; + }); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setRendering(false); + } + }, [selected, opts, toast]); + + useEffect(() => { + if (!selected) return; + window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(generate, 350); + return () => window.clearTimeout(debounceRef.current); + }, [selected, opts]); // eslint-disable-line react-hooks/exhaustive-deps + + async function apply() { + if (!selected) return; + setApplying(true); + try { + await apiPost("/api/collections/apply", { item_id: selected.id, ...opts }); + toast(`Applied ${opts.target_type} to ${selected.name}`, "ok"); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setApplying(false); + } + } + + return ( + <> + Generate cover artwork for your Emby collections with custom titling. + +
+
+
+
{ + e.preventDefault(); + load(); + }} + > + + setQuery(e.target.value)} /> + +
+
+ {loading ? ( + + ) : !list.length ? ( + }>No collections found. + ) : ( + list.map((c) => ( +
select(c)}> + {c.poster_url ? :
} +
+
{c.name}
+
{c.child_count} items
+
+
+ )) + )} +
+
+ +
+ {!selected ? ( + }>Select a collection to design its artwork. + ) : ( + <> +
+ {preview ? preview : } +
+
+ + +
+ + )} +
+ +
+
+

Controls

+
+
+ {!selected ? ( +

Pick a collection first.

+ ) : ( + <> +
+ +
+ + +
+
+
+ + set("text", e.target.value)} /> +
+
+ +
+ {["left", "center", "right"].map((a) => ( + + ))} +
+
+
+ +
+ {["top", "center", "bottom"].map((p) => ( + + ))} +
+
+
+ + set("text_scale", +e.target.value)} /> +
+
+ + set("darkness", +e.target.value)} /> +
+
+ +
+ set("text_color", e.target.value)} /> + set("text_color", e.target.value)} /> +
+
+ + )} +
+
+
+ + ); +} diff --git a/frontend/src/pages/emby/Favorites.tsx b/frontend/src/pages/emby/Favorites.tsx new file mode 100644 index 0000000..e0e0c39 --- /dev/null +++ b/frontend/src/pages/emby/Favorites.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api"; +import { PageHead, Empty, Loading } from "../../components/ui"; +import { IconHeart, IconRefresh, IconTrash, IconUser } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface User { + id: string; + name: string; +} +interface Collection { + collection_id: string; + collection_name: string; + owner_name: string | null; + is_favorites: boolean; + item_count: number | null; + owner_user_id: string | null; +} +interface ViewItem { + id: string; + title: string; + type: string; + year?: number; + watched: boolean; +} +interface View { + collection_name: string; + user_name: string; + items: ViewItem[]; + summary: { current_count: number; watched_count: number; unwatched_count: number }; +} + +export default function Favorites() { + const toast = useToast(); + const [collections, setCollections] = useState([]); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [collectionId, setCollectionId] = useState(""); + const [userId, setUserId] = useState(""); + const [view, setView] = useState(null); + const [viewLoading, setViewLoading] = useState(false); + const [busy, setBusy] = useState(false); + const [targetSize, setTargetSize] = useState(20); + + useEffect(() => { + apiGet<{ collections: Collection[]; users: User[] }>("/api/favorites/collections") + .then((d) => { + setCollections(d.collections); + setUsers(d.users); + }) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // When a favorites collection is picked, default the user to its owner. + useEffect(() => { + const c = collections.find((x) => x.collection_id === collectionId); + if (c?.owner_user_id) setUserId(c.owner_user_id); + }, [collectionId, collections]); + + function loadView() { + if (!collectionId || !userId) return; + setViewLoading(true); + setView(null); + apiGet(`/api/favorites/collection/${collectionId}?user_id=${userId}`) + .then(setView) + .catch((e) => toast(e.message, "err")) + .finally(() => setViewLoading(false)); + } + + useEffect(() => { + if (collectionId && userId) loadView(); + }, [collectionId, userId]); // eslint-disable-line react-hooks/exhaustive-deps + + async function cleanup(dryRun: boolean) { + if (!dryRun && !window.confirm("Remove all watched items from this collection?")) return; + setBusy(true); + try { + const res = await apiPost(`/api/favorites/collection/${collectionId}/cleanup`, { userId, dryRun }); + toast( + dryRun + ? `${res.summary.watched_count} watched items would be removed` + : `Removed ${res.summary.removed_count} watched items`, + dryRun ? "info" : "ok" + ); + if (!dryRun) loadView(); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy(false); + } + } + + async function regenerate(dryRun: boolean) { + setBusy(true); + try { + const res = await apiPost(`/api/favorites/collection/${collectionId}/regenerate`, { userId, dryRun, targetSize }); + toast( + dryRun + ? `${res.summary.recommended_count} recommendations available` + : `Added ${res.summary.added_count} recommendations`, + dryRun ? "info" : "ok" + ); + if (!dryRun) loadView(); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setBusy(false); + } + } + + if (loading) return ; + + return ( + <> + + Browse any Emby collection with per-user watched status, prune watched items, and top up with personalized + recommendations. Both actions default to a safe dry run. + + +
+
+
+ + +
+
+ + +
+
+
+ + {viewLoading ? ( + + ) : !view ? ( +
+ }>Pick a collection and user to inspect favorites. +
+ ) : ( + <> +
+
+ {view.summary.current_count} items + {view.summary.watched_count} watched + {view.summary.unwatched_count} unwatched +
+
+ + +
+ Target + setTargetSize(+e.target.value)} + /> + + +
+
+
+ +
+ + + + + + + + + + + {view.items.map((i) => ( + + + + + + + ))} + +
TitleTypeYearStatus
{i.title}{i.type}{i.year || "—"} + {i.watched ? watched : unwatched} +
+
+ + )} + + ); +} diff --git a/frontend/src/pages/emby/Generator.tsx b/frontend/src/pages/emby/Generator.tsx new file mode 100644 index 0000000..faafb85 --- /dev/null +++ b/frontend/src/pages/emby/Generator.tsx @@ -0,0 +1,343 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet, apiPost, apiPostImage, uploadBackground, SearchItem } from "../../api"; +import { PageHead, Empty, Loading } from "../../components/ui"; +import { IconCheck, IconImage, IconSearch, IconUpload, IconWand } from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface ImageInfo { + has_logo: boolean; + logo_count: number; + backdrop_count: number; + logos: { index: number; url: string }[]; + backdrops: { index: number }[]; +} + +interface Options { + title: string; + bg_mode: string; + backdrop_index: number; + text_color: string; + logo_align: string; + logo_scale: number; + darkness: number; + studio: string; + studio_position: string; + new_episodes_tag: boolean; + season_finale_tag: boolean; + generate_primary: boolean; + logo_index: number; + upload_bg_id: string | null; +} + +const DEFAULTS: Options = { + title: "", + bg_mode: "backdrop", + backdrop_index: 0, + text_color: "#FFFFFF", + logo_align: "bottom-center", + logo_scale: 1.3, + darkness: 0.0, + studio: "auto", + studio_position: "top-left", + new_episodes_tag: false, + season_finale_tag: false, + generate_primary: false, + logo_index: 0, + upload_bg_id: null, +}; + +const ALIGNS = ["top-left", "top-center", "top-right", "bottom-left", "bottom-center", "bottom-right"]; +const STUDIOS = ["auto", "none", "netflix", "appletv", "paramountplus", "hbo", "disney", "hulu"]; +const POSITIONS = ["top-left", "top-right", "bottom-left", "bottom-right"]; + +export default function Generator() { + const toast = useToast(); + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [selected, setSelected] = useState(null); + const [imageInfo, setImageInfo] = useState(null); + const [opts, setOpts] = useState(DEFAULTS); + const [preview, setPreview] = useState(null); + const [rendering, setRendering] = useState(false); + const [applying, setApplying] = useState(false); + const debounceRef = useRef(); + + const set = (k: K, v: Options[K]) => setOpts((o) => ({ ...o, [k]: v })); + + async function search(e?: React.FormEvent) { + e?.preventDefault(); + if (!query.trim()) return; + setSearching(true); + try { + const d = await apiGet<{ items: SearchItem[] }>( + `/api/search?q=${encodeURIComponent(query.trim())}&limit=40` + ); + setResults(d.items); + } catch (err: any) { + toast(err.message, "err"); + } finally { + setSearching(false); + } + } + + async function select(item: SearchItem) { + setSelected(item); + setPreview(null); + setImageInfo(null); + setOpts({ ...DEFAULTS, title: item.name, studio: item.auto_studio || "auto" }); + try { + const info = await apiGet(`/api/images/${item.id}`); + setImageInfo(info); + } catch (err: any) { + toast(err.message, "err"); + } + } + + const buildBody = useCallback( + () => ({ item_id: selected?.id, ...opts }), + [selected, opts] + ); + + const generate = useCallback(async () => { + if (!selected) return; + setRendering(true); + try { + const { url } = await apiPostImage("/api/generate", buildBody()); + setPreview((prev) => { + if (prev) URL.revokeObjectURL(prev); + return url; + }); + } catch (err: any) { + toast(err.message, "err"); + } finally { + setRendering(false); + } + }, [selected, buildBody, toast]); + + // Auto-regenerate the preview shortly after any option changes. + useEffect(() => { + if (!selected) return; + window.clearTimeout(debounceRef.current); + debounceRef.current = window.setTimeout(generate, 350); + return () => window.clearTimeout(debounceRef.current); + }, [selected, opts]); // eslint-disable-line react-hooks/exhaustive-deps + + async function apply() { + if (!selected) return; + setApplying(true); + try { + const res = await apiPost("/api/apply", buildBody()); + toast(`Applied to Emby (thumb ${res.thumb_code})`, "ok"); + } catch (err: any) { + toast(err.message, "err"); + } finally { + setApplying(false); + } + } + + async function onUpload(file: File) { + try { + const { upload_id } = await uploadBackground(file); + setOpts((o) => ({ ...o, bg_mode: "upload", upload_bg_id: upload_id })); + toast("Background uploaded", "ok"); + } catch (err: any) { + toast(err.message, "err"); + } + } + + return ( + <> + + Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby. + + +
+ {/* search column */} +
+
+
+ + setQuery(e.target.value)} /> + +
+
+ {searching ? ( + + ) : results.length === 0 ? ( + }>Search your Emby library to begin. + ) : ( + results.map((r) => ( +
select(r)}> + +
+
{r.name}
+
+ {r.year || "—"} + · + {r.type} + {r.has_logo && logo} +
+
+
+ )) + )} +
+
+ + {/* preview column */} +
+ {!selected ? ( + }>Select an item to preview a thumbnail. + ) : ( + <> +
+ {preview ? preview : } + {rendering && preview && ( +
+ +
+ )} +
+
+ + +
+ + )} +
+ + {/* controls column */} +
+
+

Controls

+
+
+ {!selected ? ( +

Pick an item first.

+ ) : ( + <> +
+ + set("title", e.target.value)} /> +
+ +
+ +
+ + +
+ +
+ + {opts.bg_mode === "backdrop" && imageInfo && imageInfo.backdrop_count > 1 && ( +
+ + set("backdrop_index", +e.target.value)} + /> +
+ )} + + {imageInfo && imageInfo.logo_count > 1 && ( +
+ + set("logo_index", +e.target.value)} /> +
+ )} + +
+ +
+ {ALIGNS.map((a) => ( + + ))} +
+
+ +
+ + set("logo_scale", +e.target.value)} /> +
+ +
+ + set("darkness", +e.target.value)} /> +
+ +
+ +
+ set("text_color", e.target.value)} /> + set("text_color", e.target.value)} /> +
+
+ +
+ + +
+ + {opts.studio !== "none" && ( +
+ +
+ {POSITIONS.map((p) => ( + + ))} +
+
+ )} + + {selected.type === "Series" && ( +
+ + +
+ )} + + )} +
+
+
+ + ); +} diff --git a/frontend/src/pages/navidrome/CollectionCompleteness.tsx b/frontend/src/pages/navidrome/CollectionCompleteness.tsx new file mode 100644 index 0000000..dac3b8e --- /dev/null +++ b/frontend/src/pages/navidrome/CollectionCompleteness.tsx @@ -0,0 +1,284 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { apiGet, apiPost } from "../../api"; +import { PageHead, StatCard, Loading, Empty, timeAgo } from "../../components/ui"; +import { + IconCheck, + IconChevron, + IconDisc, + IconImage, + IconLayers, + IconRefresh, + IconSearch, + IconWand, +} from "../../components/icons"; +import { useToast } from "../../lib/toast"; + +interface Overview { + last_scan: any | null; + last_metadata: any | null; + scan_running: boolean; + metadata_running: boolean; + owned: number; + missing: number; + uncertain: number; + ignored: number; + completeness: number; + library_artists: number; + library_albums: number; +} +interface ArtistRow { + id: number; + name: string; + owned: number; + missing: number; + uncertain: number; + ignored: number; + completeness: number; +} +interface Album { + id: number; + title: string; + year: number; + status: string; + confidence: number; + reason: string; + source: string; + manual_override: number; +} + +const STATUS_BADGE: Record = { + owned: "badge-ok", + probably_owned: "badge-ok", + missing: "badge-bad", + uncertain: "badge-warn", + ignored: "badge", +}; +const statusLabel = (s: string) => s.replace("_", " "); + +export default function CollectionCompleteness() { + const toast = useToast(); + const [overview, setOverview] = useState(null); + const [artists, setArtists] = useState([]); + const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState(null); + const [albums, setAlbums] = useState>({}); + const [loading, setLoading] = useState(true); + const pollRef = useRef(); + + const loadOverview = useCallback(() => { + return apiGet("/api/music-collection/overview") + .then(setOverview) + .catch(() => setOverview(null)); + }, []); + + const loadArtists = useCallback( + (q = "") => + apiGet<{ artists: ArtistRow[] }>(`/api/music-collection/artists?q=${encodeURIComponent(q)}`) + .then((d) => setArtists(d.artists)) + .catch(() => setArtists([])), + [] + ); + + useEffect(() => { + Promise.all([loadOverview(), loadArtists()]).finally(() => setLoading(false)); + }, [loadOverview, loadArtists]); + + // Poll while a job runs; refresh data when it finishes. + const running = !!overview && (overview.scan_running || overview.metadata_running); + useEffect(() => { + if (!running) { + window.clearInterval(pollRef.current); + return; + } + pollRef.current = window.setInterval(async () => { + const prev = running; + await loadOverview(); + const next = overview && (overview.scan_running || overview.metadata_running); + if (prev && !next) loadArtists(search); + }, 2500); + return () => window.clearInterval(pollRef.current); + }, [running]); // eslint-disable-line react-hooks/exhaustive-deps + + async function startScan() { + const res = await apiPost<{ started: boolean; reason?: string }>("/api/music-collection/scan"); + toast(res.started ? "Library scan started" : res.reason || "Already running", res.started ? "ok" : "info"); + loadOverview(); + } + async function refreshMetadata() { + const res = await apiPost<{ started: boolean; reason?: string }>("/api/music-collection/refresh-metadata"); + toast(res.started ? "Metadata refresh started" : res.reason || "Already running", res.started ? "ok" : "info"); + loadOverview(); + } + + function toggleArtist(id: number) { + if (expanded === id) { + setExpanded(null); + return; + } + setExpanded(id); + if (!albums[id]) { + apiGet<{ albums: Album[] }>(`/api/music-collection/artist/${id}/albums`) + .then((d) => setAlbums((a) => ({ ...a, [id]: d.albums }))) + .catch((e) => toast(e.message, "err")); + } + } + + async function decide(artistId: number, albumId: number, action: string) { + try { + await apiPost(`/api/music-collection/album/${albumId}/decision`, { action }); + const d = await apiGet<{ albums: Album[] }>(`/api/music-collection/artist/${artistId}/albums`); + setAlbums((a) => ({ ...a, [artistId]: d.albums })); + loadOverview(); + loadArtists(search); + } catch (e: any) { + toast(e.message, "err"); + } + } + + const o = overview; + const scanStatus = o?.scan_running + ? "Scanning…" + : o?.last_scan + ? `${o.last_scan.status} · ${timeAgo(o.last_scan.completed_at || o.last_scan.started_at)}` + : "Never run"; + + return ( + <> +
+ }> + 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. + +
+ + +
+
+ + {loading ? ( + + ) : ( + <> +
+ } value={`${o?.completeness ?? 0}%`} label="Overall completeness" /> + } value={o?.owned ?? 0} label="Owned albums" /> + } value={o?.missing ?? 0} label="Missing albums" /> + } value={o?.uncertain ?? 0} label="Uncertain matches" /> +
+ +
+ {o?.library_artists ?? 0} artists scanned + {o?.library_albums ?? 0} albums in library + Last scan: {scanStatus} + + Last metadata:{" "} + {o?.metadata_running + ? `running · ${o?.last_metadata?.progress || ""}` + : o?.last_metadata + ? timeAgo(o.last_metadata.completed_at || o.last_metadata.started_at) + : "never"} + +
+ +
{ + e.preventDefault(); + loadArtists(search); + }} + > + + setSearch(e.target.value)} /> + + + {artists.length === 0 ? ( +
+ }> + No completeness data yet. Run Scan library, then Refresh metadata to + compare against MusicBrainz. + +
+ ) : ( +
+ {artists.map((a) => ( +
+ + + {expanded === a.id && ( +
+ {!albums[a.id] ? ( + + ) : ( + + + + + + + + + + + + + {albums[a.id].map((al) => ( + + + + + + + + + ))} + +
AlbumYearStatusConfidenceSourceActions
+ {al.title} + {al.manual_override ? manual : null} + {al.year || "—"} + {statusLabel(al.status)} + {al.confidence ? al.confidence.toFixed(2) : "—"}{al.source} +
+ + + + +
+
+ )} +
+ )} +
+ ))} +
+ )} + + )} + + ); +} diff --git a/frontend/src/pages/navidrome/CoverManager.tsx b/frontend/src/pages/navidrome/CoverManager.tsx new file mode 100644 index 0000000..b176755 --- /dev/null +++ b/frontend/src/pages/navidrome/CoverManager.tsx @@ -0,0 +1,246 @@ +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 { useToast } from "../../lib/toast"; + +interface Album { + path: string; + folder_name: string; + artist: string; + album: string; + 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; + message: string; +} + +const MODES = [ + { key: "covers", label: "Fetch covers", desc: "Download missing cover.jpg from Cover Art Archive", icon: }, + { key: "folder_cleanup", label: "Folder cleanup", desc: "Normalize folders to 'YEAR - Album'", icon: }, + { key: "rename", label: "Rename tracks", desc: "Rename audio files to 'NN - Title'", icon: }, + { key: "file_cleanup", label: "File cleanup", desc: "Remove non-audio / non-art files", icon: }, + { key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars from LRCLIB", icon: }, +] as const; + +type ModeKey = (typeof MODES)[number]["key"]; + +export default function CoverManager() { + const toast = useToast(); + const [scan, setScan] = useState(null); + const [loading, setLoading] = useState(true); + const [running, setRunning] = useState(false); + const [dryRun, setDryRun] = useState(true); + const [modes, setModes] = useState>({ + covers: true, + folder_cleanup: false, + rename: false, + file_cleanup: false, + lyrics: false, + }); + const [actions, setActions] = useState(null); + + function refresh() { + setLoading(true); + apiGet("/api/music/scan") + .then(setScan) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + } + useEffect(refresh, []); // eslint-disable-line react-hooks/exhaustive-deps + + async function run() { + 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; + } + setRunning(true); + setActions(null); + try { + const res = await apiPost<{ actions: Action[]; dry_run: boolean }>("/api/music/process", { + ...modes, + dry_run: dryRun, + }); + setActions(res.actions); + toast(dryRun ? "Dry run complete" : "Changes applied", dryRun ? "info" : "ok"); + if (!dryRun) refresh(); + } catch (e: any) { + toast(e.message, "err"); + } finally { + setRunning(false); + } + } + + if (loading) return ; + + if (!scan?.exists) { + return ( + <> + Maintain your local music library. +
+ }> + Music root not found: {scan?.root} +
+ Set MUSIC_ROOT and mount the share into the container. +
+
+ + ); + } + + const logClass = (a: Action) => + ({ ok: "log-ok", dry: "log-dry", skip: "log-skip", warn: "log-warn", info: "log-info" }[a.level] || "log-info"); + + return ( + <> + + Clean album folders, rename tracks and fetch missing covers across {scan.root}. Runs in dry-run + mode by default — nothing changes until you turn that off. + + +
+ } value={scan.album_count ?? 0} label="Albums" /> + } value={scan.missing_cover_count ?? 0} label="Missing covers" /> + } value={scan.needs_rename_count ?? 0} label="Folders to rename" /> + } value={scan.extra_file_count ?? 0} label="Extra files" /> +
+ +
+
+
+
+

Maintenance modes

+
+
+ {MODES.map((m) => ( + + ))} +
+
+ +
+
+ + + +
+
+
+ +
+ {actions && ( +
+
+

{scan && actions ? "Result" : ""} Action log

+ {actions.length} entries +
+
+
+ {actions.length === 0 ? ( + Nothing to do. + ) : ( + actions.map((a, i) => ( +
+ {a.level === "dry" ? "plan" : a.level} + {a.message} +
+ )) + )} +
+
+
+ )} + +
+
+

Albums

+ {scan.albums.length} +
+
+ + + + + + + + + + + {scan.albums.map((a) => ( + + + + + + + ))} + +
AlbumYearTracksStatus
+
{a.album || a.folder_name}
+
{a.artist}
+
{a.year || "—"}{a.track_count} +
+ {a.has_cover ? ( + cover + ) : ( + no cover + )} + {a.needs_folder_rename && rename} + {a.extra_file_count > 0 && {a.extra_file_count} extra} +
+
+
+
+
+
+ + ); +} diff --git a/frontend/src/pages/navidrome/Library.tsx b/frontend/src/pages/navidrome/Library.tsx new file mode 100644 index 0000000..81d8c59 --- /dev/null +++ b/frontend/src/pages/navidrome/Library.tsx @@ -0,0 +1,224 @@ +import { useEffect, useState, useCallback } from "react"; +import { apiGet } from "../../api"; +import { PageHead, Empty, Loading, fmtDuration } from "../../components/ui"; +import { IconDisc, IconMusic, IconSearch, IconPlay } 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; + track?: number; + duration: number; + artist: string; +} +interface AlbumDetail extends Album { + songs: Song[]; +} + +const SORTS = [ + { key: "alphabeticalByName", label: "A–Z" }, + { key: "newest", label: "Newest" }, + { key: "recent", label: "Recently Played" }, + { key: "frequent", label: "Most Played" }, + { key: "random", label: "Random" }, +]; + +export default function Library() { + const toast = useToast(); + const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null); + const [albums, setAlbums] = useState([]); + const [loading, setLoading] = useState(true); + const [sort, setSort] = useState("alphabeticalByName"); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + useEffect(() => { + apiGet("/api/navidrome/status").then(setStatus).catch(() => setStatus({ connected: false, configured: false })); + }, []); + + const load = useCallback(() => { + setLoading(true); + const q = query.trim(); + const path = q + ? `/api/navidrome/albums?q=${encodeURIComponent(q)}&size=120` + : `/api/navidrome/albums?type=${sort}&size=120`; + apiGet<{ items: Album[] }>(path) + .then((d) => setAlbums(d.items)) + .catch((e) => toast(e.message, "err")) + .finally(() => setLoading(false)); + }, [sort, query, toast]); + + useEffect(() => { + if (status?.connected) load(); + }, [status?.connected, sort]); // eslint-disable-line react-hooks/exhaustive-deps + + function openAlbum(id: string) { + setDetailLoading(true); + setSelected(null); + apiGet(`/api/navidrome/album/${id}`) + .then(setSelected) + .catch((e) => toast(e.message, "err")) + .finally(() => setDetailLoading(false)); + } + + if (status && !status.configured) { + return ( + <> + Browse your Navidrome library. +
+ }> + Navidrome is not configured. Set NAVIDROME_URL, NAVIDROME_USER and{" "} + NAVIDROME_PASSWORD and restart the app. + +
+ + ); + } + if (status && status.configured && !status.connected) { + return ( + <> + Browse your Navidrome library. +
+ }>Could not connect to Navidrome. {status.error} +
+ + ); + } + + return ( + <> + Browse artists and albums served by your Navidrome instance. + +
+
+ {SORTS.map((s) => ( + + ))} +
+
{ + e.preventDefault(); + load(); + }} + > + + setQuery(e.target.value)} + /> + +
+ + {loading ? ( + + ) : albums.length === 0 ? ( +
+ }>No albums found. +
+ ) : ( +
+ {albums.map((a) => ( +
openAlbum(a.id)}> + {a.cover_url ? ( + {a.name} + ) : ( +
+ +
+ )} +
+
{a.name}
+
+ {a.artist} + {a.year ? ` · ${a.year}` : ""} +
+
+
+ ))} +
+ )} + + {(selected || detailLoading) && ( +
setSelected(null)} + > +
e.stopPropagation()}> + {detailLoading || !selected ? ( + + ) : ( + <> +
+ {selected.cover_url && ( + + )} +
+

{selected.name}

+
+ {selected.artist} + {selected.year ? ` · ${selected.year}` : ""} · {selected.song_count} tracks ·{" "} + {fmtDuration(selected.duration)} +
+
+ +
+ + + {selected.songs.map((s) => ( + + + + + + ))} + +
+ {s.track ?? } + {s.title} + {fmtDuration(s.duration)} +
+ + )} +
+
+ )} + + ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..7da1196 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,1197 @@ +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"); + +/* ============================================================================ + HomelabToolkit — Tracearr-modeled design system (ported from app-theme.css) + ========================================================================== */ +:root { + --bg: #0a0c0f; + --bg-2: #0c0f13; + --surface: #101419; + --surface2: #151a21; + --surface3: #1b222b; + --surface4: #232c37; + + --border: rgba(151, 167, 187, 0.12); + --border-strong: rgba(151, 167, 187, 0.22); + --border-active: #36d6e0; + + --text: #e8edf3; + --text-2: #9aa7b6; + --text-3: #5e6b7b; + + --accent: #36d6e0; + --accent-h: #5ee7ef; + --accent-2: #2bb6c4; + --accent-glow: rgba(54, 214, 224, 0.15); + --accent-soft: rgba(54, 214, 224, 0.12); + + --green: #46d99a; + --green-bg: rgba(70, 217, 154, 0.12); + --green-bd: rgba(70, 217, 154, 0.3); + --amber: #f3c969; + --amber-bg: rgba(243, 201, 105, 0.12); + --amber-bd: rgba(243, 201, 105, 0.3); + --red: #f0726f; + --red-bg: rgba(240, 114, 111, 0.12); + --red-bd: rgba(240, 114, 111, 0.3); + + --purple: #b18cff; + --purple-bg: rgba(177, 140, 255, 0.12); + --purple-bd: rgba(177, 140, 255, 0.3); + + --shadow-soft: 0 14px 38px rgba(2, 5, 10, 0.42); + --shadow-strong: 0 26px 60px rgba(2, 5, 10, 0.55); + --r: 10px; + --r-lg: 14px; + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} + +* { + box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} +*::-webkit-scrollbar { + width: 9px; + height: 9px; +} +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--text-3); + background-clip: padding-box; +} + +html { + color-scheme: dark; +} +body { + margin: 0; + font-family: "IBM Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); + font-size: 15px; + -webkit-font-smoothing: antialiased; +} +input, +button, +select, +textarea { + font-family: inherit; +} +a { + color: var(--accent-h); + text-decoration: none; +} +::selection { + background: var(--accent-glow); + color: var(--text); +} + +/* ── App layout ───────────────────────────────────────────────────────────── */ +.app { + display: grid; + grid-template-columns: 244px 1fr; + min-height: 100vh; +} +.main { + min-width: 0; + display: flex; + flex-direction: column; +} + +/* ── Sidebar ──────────────────────────────────────────────────────────────── */ +.nav { + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--surface); + border-right: 1px solid var(--border); +} +.nav-brand { + display: flex; + align-items: center; + gap: 11px; + min-height: 64px; + padding: 0 16px; + border-bottom: 1px solid var(--border); +} +.nav-logo { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 9px; + font-weight: 800; + font-size: 15px; + background: linear-gradient(155deg, #5ee7ef 0%, #36d6e0 45%, #1f9aa6 100%); + color: #04181b; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), 0 4px 14px rgba(54, 214, 224, 0.28); +} +.nav-name { + font-size: 17px; + font-weight: 700; + letter-spacing: -0.02em; +} +.nav-name small { + display: block; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-3); +} +.nav-scroll { + flex: 1; + overflow-y: auto; + padding: 12px 10px; +} +.nav-group + .nav-group { + margin-top: 6px; +} +/* Collapsible category header (button) */ +.nav-group-head { + display: flex; + align-items: center; + gap: 9px; + width: 100%; + padding: 9px 12px; + border: 0; + background: transparent; + font-family: inherit; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); + cursor: pointer; + border-radius: 9px; + transition: background 150ms var(--ease-out), color 150ms var(--ease-out); +} +.nav-group-head:hover { + background: var(--surface2); + color: var(--text-2); +} +.nav-group-head > svg { + width: 15px; + height: 15px; + opacity: 0.8; + flex-shrink: 0; +} +.nav-caret { + width: 15px !important; + height: 15px !important; + opacity: 0.7; + transition: transform 240ms var(--ease-out); +} +.nav-caret.open { + transform: rotate(90deg); +} +/* Modern height animation via grid-template-rows 0fr → 1fr */ +.nav-group-panel { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 260ms var(--ease-out); +} +.nav-group-panel.open { + grid-template-rows: 1fr; +} +.nav-group-inner { + overflow: hidden; + min-height: 0; + padding: 2px 0; + opacity: 0; + transform: translateY(-4px); + transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out); +} +.nav-group-panel.open .nav-group-inner { + opacity: 1; + transform: translateY(0); +} +.stat-icon svg { + width: 19px; + height: 19px; +} +.nav-item { + position: relative; + display: flex; + align-items: center; + gap: 11px; + min-height: 40px; + padding: 9px 12px 9px 22px; + border-radius: 9px; + font-size: 15px; + font-weight: 500; + color: var(--text-2); + cursor: pointer; + transition: background 160ms var(--ease-out), color 160ms var(--ease-out); +} +.nav-item-top { + padding-left: 12px; +} +.nav-item + .nav-item { + margin-top: 2px; +} +.nav-item svg { + width: 16px; + height: 16px; + opacity: 0.85; + flex-shrink: 0; +} +.nav-item:hover { + background: var(--surface2); + color: var(--text); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--accent-h); +} +.nav-item.active svg { + opacity: 1; +} +.nav-item.active::before { + content: ""; + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 16px; + border-radius: 99px; + background: var(--accent); + box-shadow: 0 0 10px var(--accent-glow); +} +.nav-foot { + padding: 12px; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 8px; +} +.nav-version { + font-size: 11px; + color: var(--text-3); + letter-spacing: 0.02em; + padding-left: 4px; + font-variant-numeric: tabular-nums; +} + +/* connection chip */ +.status-chip { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 11px; + border-radius: 9px; + background: var(--surface2); + border: 1px solid var(--border); + font-size: 12px; + font-weight: 600; + color: var(--text-2); +} +.status-chip .label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 0 3px var(--green-bg), 0 0 8px var(--green); + flex-shrink: 0; +} +.dot.off { + background: var(--red); + box-shadow: 0 0 0 3px var(--red-bg), 0 0 8px var(--red); +} +.dot.idle { + background: var(--text-3); + box-shadow: 0 0 0 3px rgba(94, 107, 123, 0.15); +} + +/* ── Topbar / header ──────────────────────────────────────────────────────── */ +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 16px; + padding: 0 36px; + min-height: 60px; + background: var(--bg); + border-bottom: 1px solid var(--border); +} +.topbar .crumbs { + font-size: 12.5px; + color: var(--text-3); + letter-spacing: 0.02em; +} +.topbar .crumbs b { + color: var(--text-2); + font-weight: 600; +} +.topbar-spacer { + flex: 1; +} + +.content { + padding: 26px 36px 40px; + width: 100%; +} + +.page-head { + margin-bottom: 22px; +} +.page-head-row { + display: flex; + align-items: center; + gap: 12px; +} +.page-head-icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 10px; + background: var(--accent-soft); + color: var(--accent-h); + flex-shrink: 0; +} +.page-head-icon svg { + width: 20px; + height: 20px; +} +.page-head h1 { + margin: 0; + font-size: 28px; + 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; +} + +/* ── Cards / panels ───────────────────────────────────────────────────────── */ +.panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); +} +.panel-head { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 18px; + border-bottom: 1px solid var(--border); +} +.panel-head h3 { + margin: 0; + font-size: 17px; + font-weight: 600; + letter-spacing: -0.01em; +} +.panel-head .sub { + font-size: 13px; + color: var(--text-3); +} +.panel-body { + padding: 18px; +} + +/* ── Section labels (dashboard groupings) ─────────────────────────────────── */ +.section-label { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 12px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-3); +} +.section-label svg { + width: 14px; + height: 14px; + opacity: 0.8; +} + +/* ── Dashboard split (Emby | Navidrome) ───────────────────────────────────── */ +.dash-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-bottom: 26px; +} +@media (max-width: 1080px) { + .dash-split { + grid-template-columns: 1fr; + } +} +.mini-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.mini-stat { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: var(--r); + background: var(--surface2); + border: 1px solid var(--border); +} +.mini-stat .stat-icon { + width: 34px; + height: 34px; +} +.mini-stat .stat-value { + font-size: 19px; +} +.mini-stat .stat-label { + font-size: 12px; +} + +/* genre breakdown bars */ +.genre-row { + display: grid; + grid-template-columns: 110px 1fr 52px; + align-items: center; + gap: 12px; + padding: 5px 0; +} +.genre-name { + font-size: 13px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.genre-bar { + height: 7px; + border-radius: 99px; + background: var(--surface3); + overflow: hidden; +} +.genre-bar-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--accent-2), var(--accent)); +} +.genre-count { + font-size: 12px; + color: var(--text-3); + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* format distribution (stacked bar + legend) */ +.stack-bar { + display: flex; + height: 14px; + border-radius: 99px; + overflow: hidden; + background: var(--surface3); +} +.stack-seg { + height: 100%; + transition: width 240ms var(--ease-out); +} +.legend { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 16px; + margin-top: 12px; +} +.legend-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 12.5px; +} +.legend-dot { + width: 9px; + height: 9px; + border-radius: 3px; + flex-shrink: 0; +} +.legend-name { + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--text); +} +.legend-count { + margin-left: auto; + color: var(--text-3); + font-variant-numeric: tabular-nums; +} + +/* ── Completeness rows ────────────────────────────────────────────────────── */ +.completeness-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 13px 16px; + border: 0; + background: transparent; + color: var(--text); + font-family: inherit; + font-size: 14px; + cursor: pointer; + transition: background 140ms var(--ease-out); +} +.completeness-row:hover { + background: rgba(54, 214, 224, 0.04); +} +.completeness-pct { + width: 52px; + text-align: right; + color: var(--text-2); + font-weight: 600; +} + +/* ── Tool quick-links ─────────────────────────────────────────────────────── */ +.tool-card { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; +} +.tool-card:hover { + border-color: var(--border-strong); +} + +/* ── Stat cards ───────────────────────────────────────────────────────────── */ +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 14px; +} +.stat-card { + display: flex; + align-items: center; + gap: 14px; + padding: 18px 20px; + border-radius: var(--r-lg); + background: var(--surface); + border: 1px solid var(--border); +} +.stat-icon { + width: 40px; + height: 40px; + border-radius: 11px; + display: grid; + place-items: center; + background: var(--accent-soft); + color: var(--accent-h); + flex-shrink: 0; +} +.stat-meta { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.stat-value { + font-size: 25px; + font-weight: 700; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} +.stat-label { + font-size: 13.5px; + color: var(--text-2); +} + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 9px 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text); + font-size: 14px; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out), transform 120ms var(--ease-out), + opacity 160ms var(--ease-out); +} +.btn svg { + width: 16px; + height: 16px; +} +.btn:hover:not(:disabled) { + background: var(--surface3); + border-color: var(--border-strong); +} +.btn:active:not(:disabled) { + transform: translateY(1px); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-primary { + background: var(--accent); + border-color: rgba(54, 214, 224, 0.4); + color: #04181b; +} +.btn-primary:hover:not(:disabled) { + background: var(--accent-h); + border-color: rgba(94, 231, 239, 0.5); +} +.btn-green { + background: var(--green-bg); + border-color: var(--green-bd); + color: #b7f0d4; +} +.btn-green:hover:not(:disabled) { + background: rgba(70, 217, 154, 0.22); +} +.btn-danger { + background: var(--red-bg); + border-color: var(--red-bd); + color: #ffb4b2; +} +.btn-danger:hover:not(:disabled) { + background: rgba(240, 114, 111, 0.22); +} +.btn-sm { + padding: 6px 10px; + font-size: 12px; +} +.btn-block { + width: 100%; +} + +/* ── Inputs ───────────────────────────────────────────────────────────────── */ +.input, +.select, +.textarea { + width: 100%; + padding: 9px 12px; + 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); +} +.input::placeholder, +.textarea::placeholder { + color: var(--text-3); +} +.input:focus, +.select:focus, +.textarea:focus { + border-color: var(--border-active); + box-shadow: 0 0 0 3px var(--accent-glow); + outline: none; +} +.field { + display: flex; + flex-direction: column; + gap: 7px; +} +.field-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); + display: flex; + justify-content: space-between; +} +input[type="range"] { + width: 100%; + accent-color: var(--accent); +} +input[type="color"] { + width: 42px; + height: 38px; + padding: 2px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); +} + +/* ── Segmented control ────────────────────────────────────────────────────── */ +.seg { + display: inline-flex; + padding: 3px; + gap: 2px; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); + flex-wrap: wrap; +} +.seg-btn { + border: 0; + background: transparent; + color: var(--text-2); + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: background 150ms var(--ease-out), color 150ms var(--ease-out); +} +.seg-btn:hover { + color: var(--text); +} +.seg-btn.active { + background: var(--surface4); + color: var(--text); + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +/* ── Chips / toggles ──────────────────────────────────────────────────────── */ +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--surface2); + color: var(--text-2); + font-size: 12.5px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: all 150ms var(--ease-out); +} +.chip:hover { + border-color: var(--border-strong); + color: var(--text); +} +.chip.active { + background: var(--accent-soft); + border-color: var(--border-active); + color: var(--text); +} + +/* ── Badges ───────────────────────────────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid var(--border); + background: var(--surface3); + color: var(--text-2); + white-space: nowrap; +} +.badge-ok { + background: var(--green-bg); + border-color: var(--green-bd); + color: #b7f0d4; +} +.badge-warn { + background: var(--amber-bg); + border-color: var(--amber-bd); + color: #f6d98c; +} +.badge-bad { + background: var(--red-bg); + border-color: var(--red-bd); + color: #ffb4b2; +} +.badge-accent { + background: var(--accent-soft); + border-color: rgba(54, 214, 224, 0.3); + color: var(--accent-h); +} + +/* ── Tables ───────────────────────────────────────────────────────────────── */ +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.data-table thead th { + text-align: left; + padding: 11px 14px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} +.data-table tbody td { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + color: var(--text-2); + vertical-align: middle; +} +.data-table tbody tr { + transition: background 140ms var(--ease-out); +} +.data-table tbody tr:hover { + background: rgba(54, 214, 224, 0.04); +} +.data-table tbody tr:last-child td { + border-bottom: 0; +} +.cell-strong { + color: var(--text); + font-weight: 600; +} +.cell-sub { + color: var(--text-3); + font-size: 12px; +} +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + color: #06121a; + flex-shrink: 0; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +/* ── Grids ────────────────────────────────────────────────────────────────── */ +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 16px; +} +.media-card { + border: 1px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + background: var(--surface); + cursor: pointer; + transition: border-color 160ms var(--ease-out), transform 160ms var(--ease-out); +} +.media-card:hover { + border-color: var(--border-strong); + transform: translateY(-2px); +} +.media-card.selected { + border-color: var(--border-active); + box-shadow: 0 0 0 1px var(--accent-glow); +} +.media-cover { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + background: var(--surface3); +} +.media-cover.poster { + aspect-ratio: 2 / 3; +} +.media-body { + padding: 11px 12px; +} +.media-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.media-sub { + font-size: 13px; + color: var(--text-3); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Result list (sidebar search) ─────────────────────────────────────────── */ +.result-item { + display: flex; + align-items: center; + gap: 11px; + padding: 9px 10px; + border-radius: 9px; + cursor: pointer; + border: 1px solid transparent; + transition: background 140ms var(--ease-out); +} +.result-item:hover { + background: var(--surface2); +} +.result-item.active { + background: var(--accent-soft); + border-color: var(--border-active); +} +.result-poster { + width: 38px; + height: 57px; + border-radius: 6px; + object-fit: cover; + background: var(--surface3); + flex-shrink: 0; +} +.result-name { + font-size: 15px; + font-weight: 600; + color: var(--text); +} +.result-sub { + font-size: 12px; + color: var(--text-3); + margin-top: 2px; + display: flex; + gap: 6px; + align-items: center; +} + +/* ── Empty / loading ──────────────────────────────────────────────────────── */ +.empty { + padding: 48px 24px; + text-align: center; + color: var(--text-3); + line-height: 1.6; +} +.empty svg { + width: 38px; + height: 38px; + margin-bottom: 12px; + opacity: 0.5; +} +.spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid var(--border); + border-top-color: var(--accent); + animation: spin 0.7s linear infinite; + display: inline-block; +} +.spinner.lg { + width: 30px; + height: 30px; + border-width: 3px; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.center-load { + display: grid; + place-items: center; + padding: 60px; +} + +/* ── Toast ────────────────────────────────────────────────────────────────── */ +.toast-wrap { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 10px; + max-width: 380px; +} +.toast { + padding: 12px 16px; + border-radius: 12px; + font-size: 13px; + font-weight: 500; + box-shadow: var(--shadow-soft); + border: 1px solid var(--border); + background: var(--surface3); + color: var(--text); + animation: toast-in 240ms var(--ease-out); +} +.toast.ok { + background: var(--green-bg); + border-color: var(--green-bd); + color: #b7f0d4; +} +.toast.err { + background: var(--red-bg); + border-color: var(--red-bd); + color: #ffb4b2; +} +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } +} + +/* ── Utility ──────────────────────────────────────────────────────────────── */ +.row { + display: flex; + align-items: center; + gap: 10px; +} +.row.wrap { + flex-wrap: wrap; +} +.between { + justify-content: space-between; +} +.col { + display: flex; + flex-direction: column; + gap: 14px; +} +.muted { + color: var(--text-2); +} +.dim { + color: var(--text-3); +} +.grow { + flex: 1; +} +.mono { + font-variant-numeric: tabular-nums; +} +.gap-sm { + gap: 8px; +} +.mt { + margin-top: 16px; +} +.hint { + font-size: 12px; + color: var(--text-3); + line-height: 1.5; +} + +/* ── Search bar ───────────────────────────────────────────────────────────── */ +.search-inner { + position: relative; +} +.search-inner svg { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + color: var(--text-3); +} +.search-inner .input { + padding-left: 36px; +} + +/* ── Two-column workbench (generator) ─────────────────────────────────────── */ +.workbench { + display: grid; + grid-template-columns: 320px 1fr 300px; + gap: 18px; + align-items: start; +} +@media (max-width: 1100px) { + .workbench { + grid-template-columns: 1fr; + } +} +.scroll-col { + max-height: calc(100vh - 180px); + overflow-y: auto; +} + +/* preview frame */ +.preview-shell { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; +} +.preview-frame { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--r); + overflow: hidden; + display: grid; + place-items: center; + min-height: 220px; +} +.preview-frame img { + width: 100%; + display: block; +} + +/* log console */ +.console { + background: #06090d; + border: 1px solid var(--border); + border-radius: var(--r); + padding: 12px 14px; + font-family: "SF Mono", "Cascadia Code", Consolas, monospace; + font-size: 12px; + line-height: 1.7; + max-height: 360px; + overflow-y: auto; +} +.log-line { + display: flex; + gap: 10px; +} +.log-tag { + flex-shrink: 0; + width: 58px; + font-weight: 700; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.05em; + padding-top: 1px; +} +.log-ok { + color: var(--green); +} +.log-dry { + color: var(--accent); +} +.log-skip { + color: var(--text-3); +} +.log-warn { + color: var(--amber); +} +.log-info { + color: var(--text-2); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c1183c9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2021", + "useDefineForClassFields": true, + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..05cf50d --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +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"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..bd7b44a --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The FastAPI backend serves the built app from ./dist and owns every /api route. +// During `npm run dev` we proxy /api (and Emby/Navidrome image routes) to it. +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/api": "http://localhost:8500", + }, + }, +}); diff --git a/music-covers.py b/music-covers.py new file mode 100644 index 0000000..bb42d9e --- /dev/null +++ b/music-covers.py @@ -0,0 +1,777 @@ +from pathlib import Path +import itertools +import shutil +import sys +import re +import threading +import time +import requests +import musicbrainzngs +from mutagen import File, MutagenError + +MUSIC_ROOT = Path(r"\\Matt-htpc\d\Music") + +DRY_RUN = False # keep True first. Set False only after checking output. +ENABLE_LYRICS = False +ENABLE_FOLDER_CLEANUP = True +ENABLE_RENAME = True +ENABLE_FILE_CLEANUP = True +RECENT_FOLDERS_ONLY = False +RECENT_FOLDER_WINDOW_SECONDS = 2 * 60 * 60 + +COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg") +COVER_NAMES = set(COVER_NAME_PRIORITY) +COVER_MISSING_MARKER = ".cover-not-found" +LYRICS_MISSING_MARKER = ".lyrics-not-found" +LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"} +AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"} +YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$") + +musicbrainzngs.set_useragent( + "NavidromeCoverDownloader", + "1.0", + "your-email@example.com" +) + + +class UI: + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + WHITE = "\033[37m" + ORANGE = "\033[38;5;208m" + MUTED = "\033[38;5;244m" + BG = "\033[48;5;236m" + + enabled = sys.stdout.isatty() + + +def enable_terminal_colors(): + if not UI.enabled: + return + + if sys.platform != "win32": + return + + try: + import ctypes + + kernel32 = ctypes.windll.kernel32 + handle = kernel32.GetStdHandle(-11) + mode = ctypes.c_uint() + if kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + kernel32.SetConsoleMode(handle, mode.value | 0x0004) + except Exception: + UI.enabled = False + + +def style(text: str, *codes: str) -> str: + if not UI.enabled: + return text + return "".join(codes) + text + UI.RESET + + +def line(char: str = "-") -> str: + width = shutil.get_terminal_size((88, 20)).columns + return style(char * min(width, 88), UI.MUTED) + + +def print_banner(): + print() + print(style("Music Covers", UI.BOLD, UI.ORANGE)) + print(style("Clean albums, rename tracks, fetch lyrics, and fill missing covers.", UI.DIM)) + print(line()) + + +def info(message: str): + print(f"{style('>', UI.CYAN)} {message}") + + +def success(message: str): + print(f"{style('OK', UI.GREEN, UI.BOLD)} {message}") + + +def warn(message: str): + print(f"{style('WARN', UI.YELLOW, UI.BOLD)} {message}") + + +def skip(message: str): + print(f"{style('SKIP', UI.MUTED, UI.BOLD)} {message}") + + +def action(label: str, message: str): + print(f"{style(label, UI.ORANGE, UI.BOLD)} {message}") + + +class Spinner: + def __init__(self, message: str): + self.message = message + self.done = threading.Event() + self.thread = threading.Thread(target=self._spin, daemon=True) + + def __enter__(self): + if UI.enabled: + self.thread.start() + else: + info(self.message) + return self + + def __exit__(self, exc_type, exc, tb): + if not UI.enabled: + return + + self.done.set() + self.thread.join() + sys.stdout.write("\r" + " " * shutil.get_terminal_size((88, 20)).columns + "\r") + sys.stdout.flush() + + def _spin(self): + for frame in itertools.cycle("-\\|/"): + if self.done.is_set(): + break + sys.stdout.write(f"\r{style(frame, UI.ORANGE)} {style(self.message, UI.DIM)}") + sys.stdout.flush() + time.sleep(0.08) + + +def get_key() -> str: + if sys.platform == "win32": + import msvcrt + + key = msvcrt.getch() + if key in (b"\x00", b"\xe0"): + key = msvcrt.getch() + return key.decode(errors="ignore") + + import termios + import tty + + fd = sys.stdin.fileno() + old = termios.tcgetattr(fd) + try: + tty.setraw(fd) + key = sys.stdin.read(1) + if key == "\x1b": + key += sys.stdin.read(2) + return key + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + + +def choose_modes(): + global ENABLE_LYRICS, ENABLE_FOLDER_CLEANUP, ENABLE_RENAME, ENABLE_FILE_CLEANUP, RECENT_FOLDERS_ONLY + + options = [ + { + "label": "Lyric mode", + "description": "Fetch missing .lrc or .txt sidecar lyrics", + "enabled": ENABLE_LYRICS, + }, + { + "label": "Folder cleanup mode", + "description": "Normalize album folder names to 'YEAR - Album'", + "enabled": ENABLE_FOLDER_CLEANUP, + }, + { + "label": "Rename mode", + "description": "Rename audio files to 'NN - Title.mp3'", + "enabled": ENABLE_RENAME, + }, + { + "label": "File cleanup mode", + "description": "Remove files except audio, cover art, and lyric sidecars", + "enabled": ENABLE_FILE_CLEANUP, + }, + { + "label": "Recent folders only", + "description": "Process album folders created in the last 2 hours", + "enabled": RECENT_FOLDERS_ONLY, + }, + ] + + if not sys.stdin.isatty(): + return + + if not UI.enabled: + print("Select modes. Press Enter to keep defaults, or type numbers to toggle, e.g. 1 3.") + for index, option in enumerate(options, 1): + marker = "x" if option["enabled"] else " " + print(f"[{marker}] {index}. {option['label']} - {option['description']}") + answer = input("> ").strip() + for token in answer.replace(",", " ").split(): + if token.isdigit() and 1 <= int(token) <= len(options): + options[int(token) - 1]["enabled"] = not options[int(token) - 1]["enabled"] + else: + selected = 0 + instructions = "Space toggles Up/Down moves Enter starts" + + while True: + sys.stdout.write("\033[?25l") + sys.stdout.write("\033[H\033[J") + print_banner() + print(style("Startup Modes", UI.BOLD, UI.WHITE)) + print(style(instructions, UI.DIM)) + print() + + for index, option in enumerate(options): + pointer = style(">", UI.ORANGE, UI.BOLD) if index == selected else " " + checkbox = style("[x]", UI.GREEN, UI.BOLD) if option["enabled"] else style("[ ]", UI.MUTED) + label_color = UI.WHITE if index == selected else UI.RESET + print(f"{pointer} {checkbox} {style(option['label'], UI.BOLD, label_color)}") + print(f" {style(option['description'], UI.DIM)}") + + key = get_key() + if key in ("\r", "\n"): + break + if key in (" ",): + options[selected]["enabled"] = not options[selected]["enabled"] + elif key in ("H", "\x1b[A"): + selected = (selected - 1) % len(options) + elif key in ("P", "\x1b[B"): + selected = (selected + 1) % len(options) + + sys.stdout.write("\033[?25h") + sys.stdout.write("\033[H\033[J") + + ENABLE_LYRICS = options[0]["enabled"] + ENABLE_FOLDER_CLEANUP = options[1]["enabled"] + ENABLE_RENAME = options[2]["enabled"] + ENABLE_FILE_CLEANUP = options[3]["enabled"] + RECENT_FOLDERS_ONLY = options[4]["enabled"] + + print_banner() + enabled_modes = ", ".join(option["label"] for option in options if option["enabled"]) or "none" + info(f"Enabled modes: {enabled_modes}") + print(line()) + + +def clean_name(text: str) -> str: + text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text) + text = text.replace("_", " ").replace("-", " ") + return " ".join(text.split()).strip() + + +def clean_album_folder_name(text: str) -> str: + match = YEAR_ALBUM_FOLDER_RE.match(text) + if match: + text = match.group(2) + return clean_name(text) + + +def get_year_from_album_folder_name(text: str) -> str | None: + match = YEAR_ALBUM_FOLDER_RE.match(text) + if match: + return match.group(1) + return None + + +def safe_filename(text: str) -> str: + text = re.sub(r'[<>:"/\\|?*]', "", text) + text = text.strip().rstrip(".") + return " ".join(text.split()) + + +def clean_track_number(value) -> str | None: + if not value: + return None + + text = str(value[0] if isinstance(value, list) else value).strip() + + # handles "1/12", "01/12", "1" + text = text.split("/")[0].strip() + + if not text.isdigit(): + return None + + return text.zfill(2) + + +def get_first_tag(audio, names): + for name in names: + value = audio.get(name) + if value: + return str(value[0]).strip() + return None + + +def load_audio_metadata(path: Path): + try: + audio = File(path, easy=True) + except (MutagenError, OSError) as e: + warn(f"could not read metadata ({type(e).__name__}): {path}") + return None + + if audio is None: + skip(f"unsupported audio metadata: {path}") + + return audio + + +def extract_year(value: str | None) -> str | None: + if not value: + return None + + match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value)) + if match: + return match.group(1) + + return None + + +def get_album_metadata_from_files(album_folder: Path): + for file in album_folder.iterdir(): + if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS: + continue + + audio = load_audio_metadata(file) + if audio is None: + continue + + artist = get_first_tag(audio, ["albumartist", "artist"]) + album = get_first_tag(audio, ["album"]) + year = extract_year(get_first_tag(audio, ["date", "originaldate", "year"])) + + if artist or album or year: + return artist, album, year + + return None, None, None + + +def find_album_year(artist: str, album: str) -> str | None: + try: + result = musicbrainzngs.search_releases( + artist=artist, + release=album, + limit=5 + ) + + for release in result.get("release-list", []): + year = extract_year(release.get("date")) + if year: + return year + + except Exception as e: + warn(f"Error finding album year: {e}") + + return None + + +def is_top_level_music_folder(folder: Path) -> bool: + try: + return folder.resolve().parent == MUSIC_ROOT.resolve() + except OSError: + return folder.parent == MUSIC_ROOT + + +def ensure_album_folder_name(album_folder: Path, artist: str, album: str, year: str | None) -> Path: + if not album or not year: + return album_folder + + if is_top_level_music_folder(album_folder): + skip(f"refusing to rename top-level folder as album: {album_folder}") + return album_folder + + new_name = f"{year} - {safe_filename(album)}" + new_folder = album_folder.with_name(new_name) + + if album_folder.name == new_name: + return album_folder + + if new_folder.exists(): + skip(f"folder rename target exists: {new_folder}") + return album_folder + + action("FOLDER", f"{artist}") + print(f" {style('From', UI.DIM)} {album_folder.name}") + print(f" {style('To', UI.DIM)} {new_name}") + + if DRY_RUN: + return album_folder + + album_folder.rename(new_folder) + return new_folder + + +def rename_audio_file(path: Path): + if path.suffix.lower() not in AUDIO_EXTENSIONS: + return path + + audio = load_audio_metadata(path) + if audio is None: + return path + + artist = get_first_tag(audio, ["artist", "albumartist"]) + album = get_first_tag(audio, ["album"]) + title = get_first_tag(audio, ["title"]) + track = clean_track_number(audio.get("tracknumber")) + + if not artist or not album or not title or not track: + skip(f"missing metadata: {path}") + return path + + # only rename files already inside Artist\Album structure + album_folder = path.parent + artist_folder = album_folder.parent + + if not artist_folder.exists() or not album_folder.exists(): + return path + + new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}" + new_path = path.with_name(new_name) + + if path.name == new_name: + return path + + if new_path.exists(): + skip(f"target exists: {new_path}") + return path + + action("RENAME", path.name) + print(f" {style('To', UI.DIM)} {new_name}") + + if not DRY_RUN: + path.rename(new_path) + return new_path + + return path + + +def get_album_cover_to_keep(album_folder: Path) -> str | None: + existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} + + for name in COVER_NAME_PRIORITY: + if name in existing: + return name + + return None + + +def should_keep_album_file(path: Path, cover_to_keep: str | None) -> bool: + name = path.name.lower() + suffix = path.suffix.lower() + + return ( + suffix in AUDIO_EXTENSIONS + or suffix in LYRICS_SIDECAR_EXTENSIONS + or name == cover_to_keep + ) + + +def clean_album_files(album_folder: Path): + cover_to_keep = get_album_cover_to_keep(album_folder) + + for file in album_folder.iterdir(): + if not file.is_file() or should_keep_album_file(file, cover_to_keep): + continue + + action("REMOVE", str(file)) + + if DRY_RUN: + warn(f"DRY RUN: would remove {file}") + continue + + try: + file.unlink() + success(f"Removed {file}") + except OSError as e: + warn(f"could not remove {file}: {e}") + + +def get_track_metadata(path: Path): + audio = load_audio_metadata(path) + if audio is None: + return None, None, None, None + + artist = get_first_tag(audio, ["artist", "albumartist"]) + album = get_first_tag(audio, ["album"]) + title = get_first_tag(audio, ["title"]) + duration = None + + info = getattr(audio, "info", None) + if info and info.length: + duration = round(info.length) + + return artist, album, title, duration + + +def has_cover(album_folder: Path) -> bool: + existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} + return any(name in existing for name in COVER_NAMES) + + +def cover_lookup_previously_failed(album_folder: Path, artist: str, album: str) -> bool: + marker = album_folder / COVER_MISSING_MARKER + if not marker.exists(): + return False + + try: + return marker.read_text(encoding="utf-8").strip() == f"{artist}\n{album}" + except OSError: + return False + + +def mark_cover_lookup_failed(album_folder: Path, artist: str, album: str): + if DRY_RUN: + return + + marker = album_folder / COVER_MISSING_MARKER + marker.write_text(f"{artist}\n{album}", encoding="utf-8") + + +def lyrics_lookup_key(artist: str, album: str, title: str, duration: int | None) -> str: + duration_text = str(duration) if duration else "" + return f"{artist}\t{album}\t{title}\t{duration_text}" + + +def get_failed_lyrics_lookups(album_folder: Path) -> set[str]: + marker = album_folder / LYRICS_MISSING_MARKER + if not marker.exists(): + return set() + + try: + return { + line.strip() + for line in marker.read_text(encoding="utf-8").splitlines() + if line.strip() + } + except OSError: + return set() + + +def mark_lyrics_lookup_failed(album_folder: Path, key: str): + if DRY_RUN: + return + + marker = album_folder / LYRICS_MISSING_MARKER + failed = get_failed_lyrics_lookups(album_folder) + failed.add(key) + marker.write_text("\n".join(sorted(failed)) + "\n", encoding="utf-8") + + +def has_lyrics(audio_file: Path) -> bool: + return any( + audio_file.with_suffix(extension).exists() + for extension in LYRICS_SIDECAR_EXTENSIONS + ) + + +def find_album_cover(artist: str, album: str): + try: + result = musicbrainzngs.search_releases( + artist=artist, + release=album, + limit=3 + ) + + releases = result.get("release-list", []) + if not releases: + return None + + mbid = releases[0]["id"] + url = f"https://coverartarchive.org/release/{mbid}/front-500" + + response = requests.get(url, timeout=20, allow_redirects=True) + + if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"): + return response.content + + except Exception as e: + warn(f"Error finding cover: {e}") + + return None + + +def find_track_lyrics(artist: str, album: str, title: str, duration: int | None): + if not duration: + skip("lyrics lookup missing duration") + return None + + try: + response = requests.get( + "https://lrclib.net/api/get", + params={ + "artist_name": artist, + "track_name": title, + "album_name": album, + "duration": duration, + }, + headers={ + "User-Agent": "NavidromeCoverDownloader/1.0 (local music library script)" + }, + timeout=20, + ) + + if response.status_code == 404: + return None + + if response.status_code != 200: + warn(f"Lyrics lookup failed: HTTP {response.status_code}") + return None + + data = response.json() + synced_lyrics = data.get("syncedLyrics") + plain_lyrics = data.get("plainLyrics") + + if synced_lyrics: + return ".lrc", synced_lyrics.strip() + "\n" + + if plain_lyrics: + return ".txt", plain_lyrics.strip() + "\n" + + except Exception as e: + warn(f"Error finding lyrics: {e}") + + return None + + +def download_lyrics_for_track(audio_file: Path, album_artist: str, album_name: str): + if has_lyrics(audio_file): + return + + track_artist, track_album, title, duration = get_track_metadata(audio_file) + artist = track_artist or album_artist + album = track_album or album_name + + if not artist or not album or not title: + skip(f"lyrics missing metadata: {audio_file}") + return + + key = lyrics_lookup_key(artist, album, title, duration) + failed = get_failed_lyrics_lookups(audio_file.parent) + + if key in failed: + skip(f"lyrics lookup already failed: {artist} - {title}") + return + + action("LYRICS", f"{artist} - {title}") + with Spinner("Searching LRCLIB"): + lyrics = find_track_lyrics(artist, album, title, duration) + + if not lyrics: + skip("no lyrics found") + mark_lyrics_lookup_failed(audio_file.parent, key) + return + + extension, text = lyrics + output_file = audio_file.with_suffix(extension) + + if DRY_RUN: + warn(f"DRY RUN: would save {output_file}") + else: + output_file.write_text(text, encoding="utf-8") + success(f"Saved {output_file}") + + time.sleep(1) + + +def process_album_folder(album_folder: Path): + print() + action("ALBUM", str(album_folder)) + + tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder) + artist = tag_artist or clean_name(album_folder.parent.name) + album = tag_album or clean_album_folder_name(album_folder.name) + year = tag_year or get_year_from_album_folder_name(album_folder.name) + + if ENABLE_FOLDER_CLEANUP and not year and artist and album: + with Spinner("Finding album year"): + year = find_album_year(artist, album) + + if ENABLE_FOLDER_CLEANUP: + album_folder = ensure_album_folder_name(album_folder, artist, album, year) + + audio_files = [] + + for file in album_folder.iterdir(): + if file.is_file() and file.suffix.lower() in AUDIO_EXTENSIONS: + if ENABLE_RENAME: + audio_files.append(rename_audio_file(file)) + else: + audio_files.append(file) + + if ENABLE_LYRICS: + for file in audio_files: + download_lyrics_for_track(file, artist, album) + + if has_cover(album_folder): + pass + elif cover_lookup_previously_failed(album_folder, artist, album): + skip(f"cover lookup already failed: {artist} - {album}") + else: + action("COVER", f"{artist} - {album}") + + with Spinner("Searching Cover Art Archive"): + image_data = find_album_cover(artist, album) + + if not image_data: + skip("no cover found") + mark_cover_lookup_failed(album_folder, artist, album) + else: + output_file = album_folder / "cover.jpg" + + if DRY_RUN: + warn(f"DRY RUN: would save {output_file}") + else: + output_file.write_bytes(image_data) + success(f"Saved {output_file}") + + time.sleep(1) + + if ENABLE_FILE_CLEANUP: + clean_album_files(album_folder) + + +def was_created_within_recent_window(folder: Path) -> bool: + try: + created_at = folder.stat().st_ctime + except OSError as e: + warn(f"could not read folder timestamps: {folder} ({e})") + return False + + age_seconds = time.time() - created_at + return 0 <= age_seconds <= RECENT_FOLDER_WINDOW_SECONDS + + +def main(): + enable_terminal_colors() + choose_modes() + info(f"MUSIC_ROOT: {MUSIC_ROOT}") + info(f"Exists: {MUSIC_ROOT.exists()}") + info(f"Is folder: {MUSIC_ROOT.is_dir()}") + + if not MUSIC_ROOT.exists(): + warn("Music root does not exist.") + return + + if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in MUSIC_ROOT.iterdir()): + warn("Music root contains audio files directly; skipping root-level album processing.") + + for first_level_folder in MUSIC_ROOT.iterdir(): + if not first_level_folder.is_dir(): + continue + + if any(p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS for p in first_level_folder.iterdir()): + warn(f"Skipping top-level folder with audio files: {first_level_folder}") + continue + + # Process only Artist\Album folders. Album folders should never be created + # or renamed directly under MUSIC_ROOT. + for album_folder in first_level_folder.iterdir(): + if not album_folder.is_dir(): + continue + + if RECENT_FOLDERS_ONLY and not was_created_within_recent_window(album_folder): + skip(f"outside 2-hour creation window: {album_folder}") + continue + + process_album_folder(album_folder) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index bc906f5..be3b946 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ httpx Pillow onnxruntime python-multipart -jinja2 \ No newline at end of file +requests +mutagen +musicbrainzngs diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..3fc146b --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,8 @@ +"""Service layer for the User Favourites feature. + +Every module here depends only on an injected ``client`` object exposing four +async methods (``get``, ``get_all``, ``post``, ``delete``). Production code passes +an adapter around the existing Emby helpers in ``app.py``; tests pass a fake. This +keeps the services free of FastAPI/Emby/Pillow import weight and fully unit +testable. +""" diff --git a/services/db.py b/services/db.py new file mode 100644 index 0000000..c741ddf --- /dev/null +++ b/services/db.py @@ -0,0 +1,157 @@ +"""SQLite database layer for HomelabToolkit. + +Boring and database-first: a single local SQLite file holds the music-library +scan results, MusicBrainz cache, and collection-completeness data. The schema is +created idempotently at startup (``init_db``), which doubles as the migration — +every statement uses ``IF NOT EXISTS``. + +Connections are opened per call (cheap for SQLite) so each thread/request gets +its own handle. WAL mode lets the UI keep reading while a scan job writes. +""" + +from __future__ import annotations + +import os +import sqlite3 +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path + +DB_PATH = Path(os.environ.get("DB_PATH", "cache/homelab.db")) + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + + +@contextmanager +def connect(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH), timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA busy_timeout=8000") + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS library_scan_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, -- 'scan' | 'metadata' + status TEXT NOT NULL, -- 'running' | 'completed' | 'failed' + started_at TEXT, + completed_at TEXT, + error_message TEXT, + files_scanned INTEGER DEFAULT 0, + albums_found INTEGER DEFAULT 0, + artists_found INTEGER DEFAULT 0, + progress TEXT +); + +CREATE TABLE IF NOT EXISTS library_artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + name_normalized TEXT NOT NULL UNIQUE, + mbid TEXT, + is_various INTEGER DEFAULT 0, + is_active INTEGER DEFAULT 1, + created_at TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS library_albums ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, + title TEXT NOT NULL, + title_normalized TEXT NOT NULL, + year INTEGER DEFAULT 0, + mbid TEXT, + is_active INTEGER DEFAULT 1, + created_at TEXT, + updated_at TEXT, + UNIQUE(artist_id, title_normalized, year) +); + +CREATE TABLE IF NOT EXISTS library_tracks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + album_id INTEGER NOT NULL REFERENCES library_albums(id) ON DELETE CASCADE, + title TEXT, + track_number INTEGER, + disc_number INTEGER, + file_path TEXT NOT NULL UNIQUE, + file_mtime REAL, + file_size INTEGER, + mbid TEXT, + is_active INTEGER DEFAULT 1, + last_seen_scan_id INTEGER, + created_at TEXT, + updated_at TEXT +); + +CREATE TABLE IF NOT EXISTS external_artist_matches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER NOT NULL UNIQUE REFERENCES library_artists(id) ON DELETE CASCADE, + mb_artist_mbid TEXT, + mb_artist_name TEXT, + confidence REAL DEFAULT 0, + status TEXT, -- 'matched' | 'not_found' | 'error' | 'skipped' + checked_at TEXT +); + +CREATE TABLE IF NOT EXISTS external_releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, + mb_release_group_mbid TEXT NOT NULL, + title TEXT NOT NULL, + title_normalized TEXT, + first_release_year INTEGER DEFAULT 0, + primary_type TEXT, + secondary_types TEXT, -- JSON array + fetched_at TEXT, + UNIQUE(artist_id, mb_release_group_mbid) +); + +CREATE TABLE IF NOT EXISTS collection_completeness ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + artist_id INTEGER NOT NULL REFERENCES library_artists(id) ON DELETE CASCADE, + release_group_mbid TEXT NOT NULL, + local_album_id INTEGER REFERENCES library_albums(id) ON DELETE SET NULL, + title TEXT, + year INTEGER DEFAULT 0, + status TEXT NOT NULL, -- owned|probably_owned|missing|ignored|uncertain + confidence REAL DEFAULT 0, + reason TEXT, + source TEXT DEFAULT 'musicbrainz', + manual_override INTEGER DEFAULT 0, + updated_at TEXT, + UNIQUE(artist_id, release_group_mbid) +); + +CREATE TABLE IF NOT EXISTS mb_cache ( + cache_key TEXT PRIMARY KEY, + payload TEXT, + fetched_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); +CREATE INDEX IF NOT EXISTS idx_releases_artist ON external_releases(artist_id); +CREATE INDEX IF NOT EXISTS idx_completeness_artist ON collection_completeness(artist_id); +CREATE INDEX IF NOT EXISTS idx_completeness_status ON collection_completeness(status); +CREATE INDEX IF NOT EXISTS idx_scan_runs_kind ON library_scan_runs(kind, id); +""" + + +def init_db() -> None: + with connect() as conn: + conn.executescript(SCHEMA) diff --git a/services/emby_collections.py b/services/emby_collections.py new file mode 100644 index 0000000..d8d8d68 --- /dev/null +++ b/services/emby_collections.py @@ -0,0 +1,149 @@ +"""Favourites collection detection and item operations. + +A "favourites collection" is any Emby collection (a ``BoxSet``) named +``"{UserName} Favorites"``. Unlike playlists, collection membership is keyed by +the item's own id (there is no per-entry id), so additions and removals operate +on item ids via ``/Collections/{id}/Items``. +""" + +from __future__ import annotations + +FAVORITES_SUFFIX = "Favorites" + +# Fields requested for every collection/candidate item so the recommendation +# engine and the UI have what they need in one round trip. +ITEM_FIELDS = ( + "Genres,Studios,Tags,People,ProductionYear,RunTimeTicks," + "SeriesName,CommunityRating,MediaType,Overview" +) + +# Emby stores runtime as 100ns ticks. 1 minute = 60 * 1e7 ticks. +_TICKS_PER_MINUTE = 600_000_000 + + +def parse_favorites_owner(collection_name: str | None) -> str | None: + """Return the owner name from ``"{Name} Favorites"`` or ``None``. + + Matching is case-insensitive on the suffix but preserves the owner's casing. + ``"Favorites"`` on its own (no owner) is not a per-user favourites collection. + """ + if not collection_name: + return None + name = collection_name.strip() + suffix = " " + FAVORITES_SUFFIX + if len(name) <= len(suffix): + return None + if name[-len(suffix):].casefold() != suffix.casefold(): + return None + owner = name[: -len(suffix)].strip() + return owner or None + + +def normalize_item(raw: dict) -> dict: + """Flatten an Emby item into the shape the feature uses everywhere.""" + user_data = raw.get("UserData") or {} + ticks = raw.get("RunTimeTicks") + runtime_minutes = round(ticks / _TICKS_PER_MINUTE) if ticks else None + people = raw.get("People") or [] + return { + "id": raw.get("Id", ""), + "title": raw.get("Name", ""), + "type": raw.get("Type", ""), + "media_type": raw.get("MediaType", "") or raw.get("Type", ""), + "year": raw.get("ProductionYear"), + "runtime_minutes": runtime_minutes, + "watched": bool(user_data.get("Played", False)), + "community_rating": raw.get("CommunityRating"), + "genres": [g for g in (raw.get("Genres") or []) if g], + "studios": [s.get("Name") for s in (raw.get("Studios") or []) if s.get("Name")], + "tags": [t for t in (raw.get("Tags") or []) if t], + "series_name": raw.get("SeriesName"), + "directors": [p.get("Name") for p in people if p.get("Type") == "Director" and p.get("Name")], + "actors": [p.get("Name") for p in people if p.get("Type") == "Actor" and p.get("Name")], + } + + +async def find_all_collections(client) -> list[dict]: + """Return every Emby collection (BoxSet), sorted by name. + + ``owner_name`` is the parsed ``"{Name} Favorites"`` owner (or ``None``), and + ``is_favorites`` flags whether the collection follows that convention. + ``[{"collection_id", "collection_name", "owner_name", "is_favorites", "item_count"}]`` + """ + data = await client.get_all( + "/Items", + { + "IncludeItemTypes": "BoxSet", + "Recursive": "true", + "Fields": "ChildCount", + }, + ) + collections = [] + for raw in data: + owner = parse_favorites_owner(raw.get("Name")) + collections.append( + { + "collection_id": raw.get("Id", ""), + "collection_name": raw.get("Name", ""), + "owner_name": owner, + "is_favorites": owner is not None, + "item_count": raw.get("ChildCount"), + } + ) + collections.sort(key=lambda c: (c["collection_name"] or "").casefold()) + return collections + + +async def find_favorites_collections(client) -> list[dict]: + """Return only the detected ``"{Name} Favorites"`` collections.""" + return [c for c in await find_all_collections(client) if c["is_favorites"]] + + +async def find_user_favorites_collection(client, user_name: str) -> dict | None: + """Find the ``"{user_name} Favorites"`` collection, or ``None``.""" + if not user_name: + return None + target = user_name.strip().casefold() + for collection in await find_favorites_collections(client): + if collection["owner_name"].casefold() == target: + return collection + return None + + +async def list_collection_items(client, collection_id: str, user_id: str) -> list[dict]: + """List a collection's items with watched status resolved for ``user_id``. + + Collection children are retrieved via ``ParentId``. Passing the user scope + makes Emby populate ``UserData.Played`` for that specific user, which is what + makes the watched flag user-specific. + """ + data = await client.get( + f"/Users/{user_id}/Items", + { + "ParentId": collection_id, + "Fields": ITEM_FIELDS, + "EnableUserData": "true", + }, + ) + items = data.get("Items", []) if isinstance(data, dict) else (data or []) + return [normalize_item(raw) for raw in items] + + +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: + return None + return await client.post( + f"/Collections/{collection_id}/Items", + {"Ids": ",".join(item_ids)}, + ) + + +async def remove_collection_items(client, collection_id: str, item_ids: list[str]): + """Remove items from a collection by id. No-op when empty.""" + if not item_ids: + return None + return await client.delete( + f"/Collections/{collection_id}/Items", + {"Ids": ",".join(item_ids)}, + ) diff --git a/services/emby_users.py b/services/emby_users.py new file mode 100644 index 0000000..eb108f8 --- /dev/null +++ b/services/emby_users.py @@ -0,0 +1,39 @@ +"""Emby user lookups.""" + +from __future__ import annotations + + +def _normalize_user(raw: dict) -> dict: + return {"id": raw.get("Id", ""), "name": raw.get("Name", "")} + + +async def fetch_users(client) -> list[dict]: + """Return all Emby users as ``[{"id", "name"}]``. + + ``GET /Users`` returns a bare JSON array on most Emby builds, but some return + a ``{"Items": [...]}`` envelope. Handle both. + """ + data = await client.get("/Users") + raw_users = data.get("Items", []) if isinstance(data, dict) else (data or []) + return [_normalize_user(u) for u in raw_users if u.get("Id")] + + +async def resolve_user_id_by_name(client, name: str) -> str | None: + """Case-insensitive lookup of a user id by display name.""" + if not name: + return None + target = name.strip().casefold() + for user in await fetch_users(client): + if user["name"].casefold() == target: + return user["id"] + return None + + +async def get_user(client, user_id: str) -> dict | None: + """Return ``{"id", "name"}`` for a user id, or ``None`` if not found.""" + if not user_id: + return None + for user in await fetch_users(client): + if user["id"] == user_id: + return user + return None diff --git a/services/emby_watch_history.py b/services/emby_watch_history.py new file mode 100644 index 0000000..e767bed --- /dev/null +++ b/services/emby_watch_history.py @@ -0,0 +1,50 @@ +"""Per-user watch history. + +Every query here is scoped to a single ``user_id`` via the ``/Users/{id}/Items`` +endpoint, so one user's history is never mixed with another's. +""" + +from __future__ import annotations + +from .emby_collections import ITEM_FIELDS, normalize_item + +# Movies and series are what we recommend; episodes are folded into their series. +WATCHED_ITEM_TYPES = "Movie,Series" + + +async def get_watched_items(client, user_id: str, item_types: str = WATCHED_ITEM_TYPES) -> list[dict]: + """Return the user's played items (normalized) for the given types.""" + if not user_id: + return [] + raw_items = await client.get_all( + f"/Users/{user_id}/Items", + { + "Recursive": "true", + "IsPlayed": "true", + "Filters": "IsPlayed", + "IncludeItemTypes": item_types, + "Fields": ITEM_FIELDS, + "EnableUserData": "true", + }, + ) + return [normalize_item(raw) for raw in raw_items] + + +async def get_watched_item_ids(client, user_id: str, item_types: str = WATCHED_ITEM_TYPES) -> set[str]: + """Return the set of item ids the user has watched.""" + return {item["id"] for item in await get_watched_items(client, user_id, item_types) if item["id"]} + + +async def is_item_watched(client, user_id: str, item_id: str) -> bool: + """Whether ``user_id`` has played ``item_id`` (user-specific).""" + if not (user_id and item_id): + return False + data = await client.get( + f"/Users/{user_id}/Items", + {"Ids": item_id, "EnableUserData": "true"}, + ) + items = data.get("Items", []) if isinstance(data, dict) else (data or []) + if not items: + return False + user_data = items[0].get("UserData") or {} + return bool(user_data.get("Played", False)) diff --git a/services/favorites.py b/services/favorites.py new file mode 100644 index 0000000..8a7a676 --- /dev/null +++ b/services/favorites.py @@ -0,0 +1,311 @@ +"""Orchestration for the User Favourites feature. + +Combines the user / collection / watch-history / recommendation services into the +operations the API exposes: list users, browse collections, view a collection's +items, clean up watched items, and regenerate recommendations. Dry-run is the +default for both destructive (cleanup) and bulk (regenerate) actions, and the +destructive actions only run on a user's own ``"{Name} Favorites"`` collection. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from pathlib import Path + +from . import emby_collections, emby_users, emby_watch_history, recommendations +from .recommendations import DEFAULT_TARGET_SIZE + +LOG_DIR = Path("logs") +_logger: logging.Logger | None = None + + +class FavoritesError(Exception): + """Raised for expected, user-facing problems (missing user/collection, etc.). + + Carries an HTTP-ish ``status`` so the route layer can map it cleanly. + """ + + def __init__(self, message: str, status: int = 400): + super().__init__(message) + self.message = message + self.status = status + + +def _get_logger() -> logging.Logger: + """Lazily configure a dedicated favourites logger with a file handler. + + Guards against duplicate handlers across uvicorn reloads. + """ + global _logger + if _logger is not None: + return _logger + log = logging.getLogger("homelabtoolkit.favorites") + log.setLevel(logging.INFO) + if not any(isinstance(h, logging.FileHandler) for h in log.handlers): + try: + LOG_DIR.mkdir(exist_ok=True) + handler = logging.FileHandler(LOG_DIR / "favorites.log", encoding="utf-8") + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")) + log.addHandler(handler) + except OSError: + # Filesystem unavailable (read-only container): fall back to console. + pass + _logger = log + return log + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _log_action(user_name, collection_name, item, reason, action): + record = { + "timestamp": _now_iso(), + "action": action, + "user": user_name, + "collection": collection_name, + "title": item.get("title", ""), + "item_id": item.get("id", ""), + "reason": reason, + } + _get_logger().info( + "%s | user=%s | collection=%s | item=%s (%s) | reason=%s", + action, user_name, collection_name, record["title"], record["item_id"], reason, + ) + return record + + +async def _resolve_collection(client, collection_id: str, user_id: str) -> tuple[dict, dict]: + """Resolve ``(user, collection)`` by id or raise :class:`FavoritesError`.""" + user = await emby_users.get_user(client, user_id) + if not user: + raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404) + collection = next( + (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id), + None, + ) + if not collection: + raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404) + return user, collection + + +async def list_favorites_users(client) -> list[dict]: + """Users that have a detected ``"{Name} Favorites"`` collection.""" + users = await emby_users.fetch_users(client) + by_name = {u["name"].casefold(): u for u in users} + result = [] + for collection in await emby_collections.find_favorites_collections(client): + user = by_name.get(collection["owner_name"].casefold()) + if not user: + continue # orphan collection with no matching user + result.append( + { + "user_id": user["id"], + "user_name": user["name"], + "collection_id": collection["collection_id"], + "collection_name": collection["collection_name"], + "item_count": collection.get("item_count"), + } + ) + result.sort(key=lambda r: r["user_name"].casefold()) + return result + + +async def list_collections_overview(client) -> dict: + """All collections plus all users, for the browse pickers. + + Each collection is annotated with ``owner_user_id`` when its ``"{Name} + Favorites"`` owner resolves to a real Emby user. + """ + users = await emby_users.fetch_users(client) + by_name = {u["name"].casefold(): u for u in users} + collections = [] + for collection in await emby_collections.find_all_collections(client): + owner = by_name.get(collection["owner_name"].casefold()) if collection["owner_name"] else None + collections.append({**collection, "owner_user_id": owner["id"] if owner else None}) + return {"collections": collections, "users": users} + + +async def get_collection_items_view(client, collection_id: str, user_id: str) -> dict: + """View any collection's items with watched status resolved for ``user_id``. + + Watched status is user-specific. ``actions_enabled`` is true only when the + collection is a ``"{Name} Favorites"`` collection and the selected user is its + owner, so cleanup/regenerate never touch shared or themed collections. + """ + user = await emby_users.get_user(client, user_id) + if not user: + raise FavoritesError(f"No Emby user found for id {user_id!r}.", status=404) + + collection = next( + (c for c in await emby_collections.find_all_collections(client) if c["collection_id"] == collection_id), + None, + ) + if not collection: + raise FavoritesError(f"No collection found for id {collection_id!r}.", status=404) + + items = await emby_collections.list_collection_items(client, collection_id, user_id) + watched_count = sum(1 for i in items if i["watched"]) + + # Cleanup/regenerate are available for any collection. They act on the + # selected user's watch data; removal affects the shared collection itself. + actions_enabled = True + actions_reason = "" + + return { + "collection_id": collection_id, + "collection_name": collection["collection_name"], + "is_favorites": collection["is_favorites"], + "owner_name": collection["owner_name"], + "user_id": user["id"], + "user_name": user["name"], + "actions_enabled": actions_enabled, + "actions_reason": actions_reason, + "items": items, + "summary": { + "current_count": len(items), + "watched_count": watched_count, + "unwatched_count": len(items) - watched_count, + }, + } + + +async def cleanup_watched(client, collection_id: str, user_id: str, dry_run: bool = True) -> dict: + """Preview or remove items the selected user has already watched. + + Which items are "watched" is resolved per user (via ``UserData.Played`` for + this user only). The removal itself operates on the collection, which is + shared, so removed items leave the collection for everyone. + """ + user, collection = await _resolve_collection(client, collection_id, user_id) + items = await emby_collections.list_collection_items(client, collection_id, user_id) + + watched = [i for i in items if i["watched"]] + + records = [] + applied = False + if not dry_run and watched: + await emby_collections.remove_collection_items( + client, collection["collection_id"], [i["id"] for i in watched] + ) + applied = True + records = [ + _log_action(user["name"], collection["collection_name"], i, "watched-by-user", "removed") + for i in watched + ] + + final_count = len(items) - (len(watched) if applied else 0) + return { + "dry_run": dry_run, + "applied": applied, + "user_id": user["id"], + "user_name": user["name"], + "collection_name": collection["collection_name"], + "watched_found": len(watched), + "removed": [ + {"title": i["title"], "item_id": i["id"], "reason": "watched-by-user"} + for i in watched + ], + "log": records, + "summary": { + "current_count": len(items), + "watched_count": len(watched), + "removed_count": len(watched) if applied else 0, + "final_count": final_count, + }, + } + + +async def regenerate( + client, + collection_id: str, + user_id: str, + dry_run: bool = True, + target_size: int = DEFAULT_TARGET_SIZE, +) -> dict: + """Preview or add recommendations derived from the selected user's history. + + Candidates exclude items the user has watched and items already in the + collection, and are never watched items. New items are added until the + collection reaches ``target_size``. + """ + if target_size < 0: + raise FavoritesError("targetSize must be zero or greater.", status=400) + + user, collection = await _resolve_collection(client, collection_id, user_id) + items = await emby_collections.list_collection_items(client, collection_id, user_id) + current_ids = {i["id"] for i in items if i["id"]} + + watched_items = await emby_watch_history.get_watched_items(client, user_id) + watched_ids = {i["id"] for i in watched_items if i["id"]} + profile = recommendations.build_profile(watched_items) + + summary_base = { + "current_count": len(items), + "watched_history_count": len(watched_items), + "target_size": target_size, + } + + if profile.is_empty: + return { + "dry_run": dry_run, + "applied": False, + "user_id": user["id"], + "user_name": user["name"], + "collection_name": collection["collection_name"], + "message": "No watch history for this user yet, so no recommendations can be made.", + "recommended": [], + "log": [], + "summary": {**summary_base, "recommended_count": 0, "added_count": 0, "final_count": len(items)}, + } + + exclude_ids = current_ids | watched_ids + candidates = await recommendations.build_candidates(client, user_id, profile, exclude_ids) + ranked = recommendations.rank_candidates(candidates, profile) + + need = max(0, target_size - len(items)) + chosen = ranked[:need] + + records = [] + applied = False + if not dry_run and chosen: + await emby_collections.add_collection_items( + client, collection["collection_id"], [c["id"] for c in chosen] + ) + applied = True + records = [ + _log_action( + user["name"], collection["collection_name"], c, + f"recommended (score={c['score']})", "added", + ) + for c in chosen + ] + + final_count = len(items) + (len(chosen) if applied else 0) + return { + "dry_run": dry_run, + "applied": applied, + "user_id": user["id"], + "user_name": user["name"], + "collection_name": collection["collection_name"], + "recommended": [ + { + "title": c["title"], + "item_id": c["id"], + "type": c["type"], + "year": c["year"], + "runtime_minutes": c["runtime_minutes"], + "community_rating": c["community_rating"], + "score": c["score"], + } + for c in chosen + ], + "log": records, + "summary": { + **summary_base, + "recommended_count": len(chosen), + "added_count": len(chosen) if applied else 0, + "final_count": final_count, + }, + } diff --git a/services/music_covers.py b/services/music_covers.py new file mode 100644 index 0000000..2b048e3 --- /dev/null +++ b/services/music_covers.py @@ -0,0 +1,533 @@ +"""Music library maintenance, refactored from the original ``music-covers.py`` CLI. + +The interactive terminal UI is gone; what remains is pure, importable logic the +web app drives. Two entry points matter: + +* :func:`scan_library` — read-only analysis. Walks ``MUSIC_ROOT`` and reports, per + album, what each maintenance mode *would* do. Safe to call any time. +* :func:`process_library` — performs the work. Honours ``dry_run`` (the web UI + default) so nothing is renamed, deleted, or downloaded unless the caller opts in. + +Both are synchronous (filesystem + blocking HTTP); call them from FastAPI via +``asyncio.to_thread``. Progress is reported through an optional ``log`` callback. +""" + +from __future__ import annotations + +import os +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +import requests + +try: # Optional: only needed for the "find missing year/cover" online lookups. + import musicbrainzngs + + musicbrainzngs.set_useragent("HomelabToolkit", "1.0", "homelab-toolkit@example.com") + _HAS_MUSICBRAINZ = True +except Exception: # pragma: no cover - optional dependency + _HAS_MUSICBRAINZ = False + +from mutagen import File as MutagenFile, MutagenError + +MUSIC_ROOT = Path(os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music")) + +COVER_NAME_PRIORITY = ("cover.jpg", "folder.jpg", "front.jpg") +COVER_NAMES = set(COVER_NAME_PRIORITY) +COVER_MISSING_MARKER = ".cover-not-found" +LYRICS_MISSING_MARKER = ".lyrics-not-found" +LYRICS_SIDECAR_EXTENSIONS = {".lrc", ".txt"} +AUDIO_EXTENSIONS = {".mp3", ".flac", ".m4a"} +YEAR_ALBUM_FOLDER_RE = re.compile(r"^\s*(\d{4})\s*-\s*(.+?)\s*$") + +LogCallback = Callable[[dict], None] + + +@dataclass +class ProcessOptions: + folder_cleanup: bool = False + rename: bool = False + file_cleanup: bool = False + lyrics: bool = False + covers: bool = True + dry_run: bool = True + + @classmethod + def from_dict(cls, data: dict) -> "ProcessOptions": + return cls( + folder_cleanup=bool(data.get("folder_cleanup", False)), + rename=bool(data.get("rename", False)), + file_cleanup=bool(data.get("file_cleanup", False)), + lyrics=bool(data.get("lyrics", False)), + covers=bool(data.get("covers", True)), + dry_run=bool(data.get("dry_run", True)), + ) + + +@dataclass +class _Recorder: + """Collects structured action records and forwards them to an optional sink.""" + + sink: LogCallback | None = None + actions: list[dict] = field(default_factory=list) + counts: dict[str, int] = field(default_factory=dict) + + def emit(self, level: str, action: str, message: str, **extra) -> None: + record = {"level": level, "action": action, "message": message, **extra} + self.actions.append(record) + self.counts[action] = self.counts.get(action, 0) + 1 + if self.sink: + self.sink(record) + + +# ── pure string helpers ────────────────────────────────────────────────────── + + +def clean_name(text: str) -> str: + text = re.sub(r"\[(.*?)\]|\((.*?)\)", "", text) + text = text.replace("_", " ").replace("-", " ") + return " ".join(text.split()).strip() + + +def clean_album_folder_name(text: str) -> str: + match = YEAR_ALBUM_FOLDER_RE.match(text) + if match: + text = match.group(2) + return clean_name(text) + + +def get_year_from_album_folder_name(text: str) -> str | None: + match = YEAR_ALBUM_FOLDER_RE.match(text) + return match.group(1) if match else None + + +def safe_filename(text: str) -> str: + text = re.sub(r'[<>:"/\\|?*]', "", text) + text = text.strip().rstrip(".") + return " ".join(text.split()) + + +def clean_track_number(value) -> str | None: + if not value: + return None + text = str(value[0] if isinstance(value, list) else value).strip() + text = text.split("/")[0].strip() + return text.zfill(2) if text.isdigit() else None + + +def extract_year(value: str | None) -> str | None: + if not value: + return None + match = re.search(r"\b(19\d{2}|20\d{2})\b", str(value)) + return match.group(1) if match else None + + +# ── metadata reading ───────────────────────────────────────────────────────── + + +def _load_audio(path: Path): + try: + return MutagenFile(path, easy=True) + except (MutagenError, OSError): + return None + + +def _first_tag(audio, names): + for name in names: + value = audio.get(name) + if value: + return str(value[0]).strip() + return None + + +def get_album_metadata_from_files(album_folder: Path): + for file in album_folder.iterdir(): + if not file.is_file() or file.suffix.lower() not in AUDIO_EXTENSIONS: + continue + audio = _load_audio(file) + if audio is None: + continue + artist = _first_tag(audio, ["albumartist", "artist"]) + album = _first_tag(audio, ["album"]) + year = extract_year(_first_tag(audio, ["date", "originaldate", "year"])) + if artist or album or year: + return artist, album, year + return None, None, None + + +def get_track_metadata(path: Path): + audio = _load_audio(path) + if audio is None: + return None, None, None, None + artist = _first_tag(audio, ["artist", "albumartist"]) + album = _first_tag(audio, ["album"]) + title = _first_tag(audio, ["title"]) + duration = None + info = getattr(audio, "info", None) + if info and getattr(info, "length", None): + duration = round(info.length) + return artist, album, title, duration + + +# ── album inspection ───────────────────────────────────────────────────────── + + +def _cover_to_keep(album_folder: Path) -> str | None: + existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} + for name in COVER_NAME_PRIORITY: + if name in existing: + return name + return None + + +def _has_cover(album_folder: Path) -> bool: + existing = {p.name.lower() for p in album_folder.iterdir() if p.is_file()} + return any(name in existing for name in COVER_NAMES) + + +def _should_keep_file(path: Path, cover_to_keep: str | None) -> bool: + name = path.name.lower() + suffix = path.suffix.lower() + return ( + suffix in AUDIO_EXTENSIONS + or suffix in LYRICS_SIDECAR_EXTENSIONS + or name == cover_to_keep + ) + + +def _has_lyrics(audio_file: Path) -> bool: + return any( + audio_file.with_suffix(extension).exists() + for extension in LYRICS_SIDECAR_EXTENSIONS + ) + + +def analyze_album(album_folder: Path) -> dict: + """Read-only summary of an album folder and the pending maintenance work.""" + tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder) + artist = tag_artist or clean_name(album_folder.parent.name) + album = tag_album or clean_album_folder_name(album_folder.name) + year = tag_year or get_year_from_album_folder_name(album_folder.name) + + audio_files = [ + f for f in album_folder.iterdir() + if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS + ] + cover_to_keep = _cover_to_keep(album_folder) + extra_files = [ + f.name for f in album_folder.iterdir() + if f.is_file() and not _should_keep_file(f, cover_to_keep) + ] + + suggested_folder = None + if album and year: + candidate = f"{year} - {safe_filename(album)}" + if candidate != album_folder.name: + suggested_folder = candidate + + missing_lyrics = sum(1 for f in audio_files if not _has_lyrics(f)) + + return { + "path": str(album_folder), + "folder_name": album_folder.name, + "artist": artist or "", + "album": album or "", + "year": year, + "track_count": len(audio_files), + "has_cover": _has_cover(album_folder), + "suggested_folder": suggested_folder, + "needs_folder_rename": suggested_folder is not None, + "extra_files": extra_files, + "extra_file_count": len(extra_files), + "missing_lyrics_count": missing_lyrics, + } + + +def _iter_album_folders(root: Path): + for first_level in root.iterdir(): + if not first_level.is_dir(): + continue + try: + has_audio = any( + p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS + for p in first_level.iterdir() + ) + except OSError: + continue + if has_audio: + # Top-level folder holding audio directly is not an Artist/Album tree. + continue + for album_folder in first_level.iterdir(): + if album_folder.is_dir(): + yield album_folder + + +def scan_library(root: Path | None = None) -> dict: + root = root or MUSIC_ROOT + if not root.exists(): + return {"root": str(root), "exists": False, "albums": []} + + albums = [] + for album_folder in _iter_album_folders(root): + try: + albums.append(analyze_album(album_folder)) + except OSError: + continue + albums.sort(key=lambda a: (a["artist"].lower(), a["year"] or "", a["album"].lower())) + + return { + "root": str(root), + "exists": True, + "album_count": len(albums), + "missing_cover_count": sum(1 for a in albums if not a["has_cover"]), + "needs_rename_count": sum(1 for a in albums if a["needs_folder_rename"]), + "extra_file_count": sum(a["extra_file_count"] for a in albums), + "albums": albums, + } + + +# ── online lookups (year / cover / lyrics) ─────────────────────────────────── + + +def find_album_year(artist: str, album: str) -> str | None: + if not _HAS_MUSICBRAINZ: + return None + try: + result = musicbrainzngs.search_releases(artist=artist, release=album, limit=5) + for release in result.get("release-list", []): + year = extract_year(release.get("date")) + if year: + return year + except Exception: + return None + return None + + +def find_album_cover(artist: str, album: str) -> bytes | None: + if not _HAS_MUSICBRAINZ: + return None + try: + result = musicbrainzngs.search_releases(artist=artist, release=album, limit=3) + releases = result.get("release-list", []) + if not releases: + return None + mbid = releases[0]["id"] + url = f"https://coverartarchive.org/release/{mbid}/front-500" + response = requests.get(url, timeout=20, allow_redirects=True) + if response.status_code == 200 and response.headers.get("content-type", "").startswith("image"): + return response.content + except Exception: + return None + return None + + +def find_track_lyrics(artist: str, album: str, title: str, duration: int | None): + if not duration: + return None + try: + response = requests.get( + "https://lrclib.net/api/get", + params={ + "artist_name": artist, + "track_name": title, + "album_name": album, + "duration": duration, + }, + headers={"User-Agent": "HomelabToolkit/1.0 (local music library tool)"}, + timeout=20, + ) + if response.status_code != 200: + return None + data = response.json() + if data.get("syncedLyrics"): + return ".lrc", data["syncedLyrics"].strip() + "\n" + if data.get("plainLyrics"): + return ".txt", data["plainLyrics"].strip() + "\n" + except Exception: + return None + return None + + +# ── mutating operations (respect dry_run) ──────────────────────────────────── + + +def _is_top_level(folder: Path, root: Path) -> bool: + try: + return folder.resolve().parent == root.resolve() + except OSError: + return folder.parent == root + + +def _rename_album_folder(album_folder, artist, album, year, root, opts, rec) -> Path: + if not album or not year or _is_top_level(album_folder, root): + return album_folder + new_name = f"{year} - {safe_filename(album)}" + if album_folder.name == new_name: + return album_folder + new_folder = album_folder.with_name(new_name) + if new_folder.exists(): + rec.emit("skip", "folder", f"Rename target already exists: {new_name}") + return album_folder + rec.emit( + "dry" if opts.dry_run else "ok", + "folder", + f"{album_folder.name} → {new_name}", + path=str(album_folder), + ) + if opts.dry_run: + return album_folder + album_folder.rename(new_folder) + return new_folder + + +def _rename_track(path: Path, opts, rec) -> Path: + audio = _load_audio(path) + if audio is None: + return path + artist = _first_tag(audio, ["artist", "albumartist"]) + album = _first_tag(audio, ["album"]) + title = _first_tag(audio, ["title"]) + track = clean_track_number(audio.get("tracknumber")) + if not (artist and album and title and track): + return path + new_name = f"{track} - {safe_filename(title)}{path.suffix.lower()}" + if path.name == new_name: + return path + new_path = path.with_name(new_name) + if new_path.exists(): + rec.emit("skip", "rename", f"Target exists: {new_name}") + return path + rec.emit("dry" if opts.dry_run else "ok", "rename", f"{path.name} → {new_name}") + if opts.dry_run: + return path + path.rename(new_path) + return new_path + + +def _clean_files(album_folder: Path, opts, rec) -> None: + cover_to_keep = _cover_to_keep(album_folder) + for file in album_folder.iterdir(): + if not file.is_file() or _should_keep_file(file, cover_to_keep): + continue + rec.emit("dry" if opts.dry_run else "ok", "remove", f"Remove {file.name}", path=str(file)) + if opts.dry_run: + continue + try: + file.unlink() + except OSError as exc: + rec.emit("warn", "remove", f"Could not remove {file.name}: {exc}") + + +def _fetch_cover(album_folder, artist, album, opts, rec) -> None: + if _has_cover(album_folder): + return + if not (artist and album): + return + rec.emit("info", "cover", f"Looking up cover: {artist} - {album}") + image = find_album_cover(artist, album) + if not image: + rec.emit("skip", "cover", f"No cover found: {artist} - {album}") + return + output = album_folder / "cover.jpg" + rec.emit("dry" if opts.dry_run else "ok", "cover", f"Save cover.jpg for {album}") + if not opts.dry_run: + output.write_bytes(image) + time.sleep(1) + + +def _fetch_lyrics(audio_file, album_artist, album_name, opts, rec) -> None: + if _has_lyrics(audio_file): + return + t_artist, t_album, title, duration = get_track_metadata(audio_file) + artist = t_artist or album_artist + album = t_album or album_name + if not (artist and album and title): + return + lyrics = find_track_lyrics(artist, album, title, duration) + if not lyrics: + rec.emit("skip", "lyrics", f"No lyrics: {artist} - {title}") + return + extension, text = lyrics + output = audio_file.with_suffix(extension) + rec.emit("dry" if opts.dry_run else "ok", "lyrics", f"Save lyrics for {title}") + if not opts.dry_run: + output.write_text(text, encoding="utf-8") + time.sleep(1) + + +def _process_album(album_folder: Path, root: Path, opts: ProcessOptions, rec: _Recorder) -> None: + tag_artist, tag_album, tag_year = get_album_metadata_from_files(album_folder) + artist = tag_artist or clean_name(album_folder.parent.name) + album = tag_album or clean_album_folder_name(album_folder.name) + year = tag_year or get_year_from_album_folder_name(album_folder.name) + + if opts.folder_cleanup and not year and artist and album: + year = find_album_year(artist, album) + if opts.folder_cleanup: + album_folder = _rename_album_folder(album_folder, artist, album, year, root, opts, rec) + + audio_files = [ + f for f in album_folder.iterdir() + if f.is_file() and f.suffix.lower() in AUDIO_EXTENSIONS + ] + if opts.rename: + audio_files = [_rename_track(f, opts, rec) for f in audio_files] + + if opts.lyrics: + for file in audio_files: + _fetch_lyrics(file, artist, album, opts, rec) + + if opts.covers: + _fetch_cover(album_folder, artist, album, opts, rec) + + if opts.file_cleanup: + _clean_files(album_folder, opts, rec) + + +def process_library( + options: ProcessOptions, + *, + root: Path | None = None, + log: LogCallback | None = None, + album_paths: list[str] | None = None, +) -> dict: + """Run the selected maintenance modes. Honours ``options.dry_run``. + + ``album_paths`` optionally limits the run to specific album folders. + """ + root = root or MUSIC_ROOT + rec = _Recorder(sink=log) + + 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)) + + rec.emit( + "info", + "start", + f"{'Dry run' if options.dry_run else 'Applying'} across {len(folders)} album(s)", + ) + for album_folder in folders: + try: + _process_album(album_folder, root, options, rec) + 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, + } diff --git a/services/music_library.py b/services/music_library.py new file mode 100644 index 0000000..1a5773f --- /dev/null +++ b/services/music_library.py @@ -0,0 +1,625 @@ +"""Music-library scanning, MusicBrainz enrichment, and completeness logic. + +Design rules (per the feature spec): +* Disk is only walked by an explicit background job, never on page render. +* The UI reads exclusively from the database. +* Jobs write progress to ``library_scan_runs`` so the UI can poll. +* Manual user decisions (ignore / mark owned / mark missing) are never + overwritten by a metadata refresh. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from pathlib import Path + +from . import db, musicbrainz +from .music_covers import AUDIO_EXTENSIONS, MUSIC_ROOT, _first_tag, _load_audio, clean_name, clean_track_number, extract_year +from .text_normalize import ( + MISSING, + OWNED, + classify_release, + is_various_artists, + normalize_artist, + normalize_title, +) + +logger = logging.getLogger("homelabtoolkit.music_library") + +_OWNED_STATUSES = (OWNED, "probably_owned") +_BATCH_SIZE = 200 + +# Only one job of each kind runs at a time (single process). +_running: set[str] = set() +_running_lock = threading.Lock() + + +# ── job guards ──────────────────────────────────────────────────────────────── + + +def _try_acquire(kind: str) -> bool: + with _running_lock: + if kind in _running: + return False + _running.add(kind) + return True + + +def _release(kind: str) -> None: + with _running_lock: + _running.discard(kind) + + +def _is_running(kind: str) -> bool: + with _running_lock: + return kind in _running + + +# ── scan run bookkeeping ────────────────────────────────────────────────────── + + +def _create_run(kind: str) -> int: + with db.connect() as conn: + cur = conn.execute( + "INSERT INTO library_scan_runs(kind, status, started_at) VALUES(?, 'running', ?)", + (kind, db.now_iso()), + ) + return cur.lastrowid + + +def _update_run(run_id: int, **fields) -> None: + if not fields: + return + cols = ", ".join(f"{k}=?" for k in fields) + with db.connect() as conn: + conn.execute(f"UPDATE library_scan_runs SET {cols} WHERE id=?", (*fields.values(), run_id)) + + +def _finish_run(run_id: int, status: str, **fields) -> None: + _update_run(run_id, status=status, completed_at=db.now_iso(), **fields) + + +# ── tag reading ─────────────────────────────────────────────────────────────── + + +def _read_tags(path: Path) -> dict | None: + audio = _load_audio(path) + if audio is None: + return None + album_artist = _first_tag(audio, ["albumartist", "album artist"]) + track_artist = _first_tag(audio, ["artist", "albumartist"]) + artist = album_artist or track_artist or clean_name(path.parent.parent.name) + album = _first_tag(audio, ["album"]) or clean_name(path.parent.name) + title = _first_tag(audio, ["title"]) or path.stem + track_no = clean_track_number(audio.get("tracknumber")) + disc_no = clean_track_number(audio.get("discnumber")) + return { + "artist": artist, + "album": album, + "title": title, + "track_number": int(track_no) if track_no else None, + "disc_number": int(disc_no) if disc_no else None, + "year": int(extract_year(_first_tag(audio, ["date", "originaldate", "year"])) or 0), + "artist_mbid": _first_tag(audio, ["musicbrainz_artistid"]), + "album_mbid": _first_tag(audio, ["musicbrainz_releasegroupid", "musicbrainz_albumid"]), + "track_mbid": _first_tag(audio, ["musicbrainz_trackid"]), + } + + +def _iter_audio_files(root: Path): + """Yield audio file paths lazily so we never hold the library in memory.""" + for dirpath, _dirnames, filenames in os.walk(root): + for name in filenames: + if Path(name).suffix.lower() in AUDIO_EXTENSIONS: + yield Path(dirpath) / name + + +# ── upserts (in-run caches keep artist/album lookups cheap) ─────────────────── + + +def _get_artist_id(conn, cache: dict, meta: dict) -> int: + name = meta["artist"] + norm = normalize_artist(name) + if norm in cache: + return cache[norm] + now = db.now_iso() + various = 1 if is_various_artists(name) else 0 + row = conn.execute("SELECT id FROM library_artists WHERE name_normalized=?", (norm,)).fetchone() + if row: + conn.execute( + "UPDATE library_artists SET is_active=1, is_various=?, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?", + (various, now, meta.get("artist_mbid"), row["id"]), + ) + artist_id = row["id"] + else: + cur = conn.execute( + "INSERT INTO library_artists(name, name_normalized, mbid, is_various, is_active, created_at, updated_at) " + "VALUES(?,?,?,?,1,?,?)", + (name, norm, meta.get("artist_mbid"), various, now, now), + ) + artist_id = cur.lastrowid + cache[norm] = artist_id + return artist_id + + +def _get_album_id(conn, cache: dict, artist_id: int, meta: dict) -> int: + title = meta["album"] + norm = normalize_title(title) + year = meta.get("year") or 0 + key = (artist_id, norm, year) + if key in cache: + return cache[key] + now = db.now_iso() + row = conn.execute( + "SELECT id FROM library_albums WHERE artist_id=? AND title_normalized=? AND year=?", + (artist_id, norm, year), + ).fetchone() + if row: + conn.execute( + "UPDATE library_albums SET is_active=1, updated_at=?, mbid=COALESCE(mbid, ?) WHERE id=?", + (now, meta.get("album_mbid"), row["id"]), + ) + album_id = row["id"] + else: + cur = conn.execute( + "INSERT INTO library_albums(artist_id, title, title_normalized, year, mbid, is_active, created_at, updated_at) " + "VALUES(?,?,?,?,?,1,?,?)", + (artist_id, title, norm, year, meta.get("album_mbid"), now, now), + ) + album_id = cur.lastrowid + cache[key] = album_id + return album_id + + +def _upsert_track(conn, album_id: int, meta: dict, path: Path, size: int, mtime: float, run_id: int) -> None: + now = db.now_iso() + conn.execute( + """ + INSERT INTO library_tracks(album_id, title, track_number, disc_number, file_path, file_mtime, + file_size, mbid, is_active, last_seen_scan_id, created_at, updated_at) + VALUES(?,?,?,?,?,?,?,?,1,?,?,?) + ON CONFLICT(file_path) DO UPDATE SET + album_id=excluded.album_id, title=excluded.title, track_number=excluded.track_number, + disc_number=excluded.disc_number, file_mtime=excluded.file_mtime, file_size=excluded.file_size, + mbid=excluded.mbid, is_active=1, last_seen_scan_id=excluded.last_seen_scan_id, updated_at=excluded.updated_at + """, + ( + album_id, meta["title"], meta["track_number"], meta["disc_number"], str(path), mtime, + size, meta.get("track_mbid"), run_id, now, now, + ), + ) + + +# ── scanner ─────────────────────────────────────────────────────────────────── + + +def run_scan(root: Path | None = None) -> dict: + """Walk the library and upsert artists/albums/tracks. Unchanged files + (same path + size + mtime) are skipped without reading tags. Files that + vanished are marked inactive, never hard-deleted.""" + root = Path(root) if root else MUSIC_ROOT + run_id = _create_run("scan") + logger.info("Scan %d started: %s", run_id, root) + + if not root.exists(): + _finish_run(run_id, "failed", error_message=f"Music root not found: {root}") + logger.warning("Scan %d aborted: root missing", run_id) + return {"run_id": run_id, "status": "failed"} + + files_scanned = 0 + try: + with db.connect() as conn: + artist_cache: dict = {} + album_cache: dict = {} + batch = 0 + for path in _iter_audio_files(root): + try: + stat = path.stat() + except OSError: + continue + size, mtime = stat.st_size, stat.st_mtime + existing = conn.execute( + "SELECT id, file_size, file_mtime FROM library_tracks WHERE file_path=?", + (str(path),), + ).fetchone() + if existing and existing["file_size"] == size and abs((existing["file_mtime"] or 0) - mtime) < 1: + conn.execute( + "UPDATE library_tracks SET is_active=1, last_seen_scan_id=? WHERE id=?", + (run_id, existing["id"]), + ) + else: + meta = _read_tags(path) + if meta is not None: + artist_id = _get_artist_id(conn, artist_cache, meta) + album_id = _get_album_id(conn, album_cache, artist_id, meta) + _upsert_track(conn, album_id, meta, path, size, mtime, run_id) + files_scanned += 1 + batch += 1 + if batch >= _BATCH_SIZE: + conn.commit() + batch = 0 + conn.execute( + "UPDATE library_scan_runs SET files_scanned=?, progress=? WHERE id=?", + (files_scanned, f"Scanned {files_scanned} files", run_id), + ) + conn.commit() + conn.commit() + + # Mark vanished files inactive, then cascade activity up. + conn.execute( + "UPDATE library_tracks SET is_active=0 WHERE COALESCE(last_seen_scan_id, -1) != ? AND is_active=1", + (run_id,), + ) + conn.execute( + "UPDATE library_albums SET is_active = " + "CASE WHEN EXISTS(SELECT 1 FROM library_tracks t WHERE t.album_id=library_albums.id AND t.is_active=1) " + "THEN 1 ELSE 0 END" + ) + conn.execute( + "UPDATE library_artists SET is_active = " + "CASE WHEN EXISTS(SELECT 1 FROM library_albums al WHERE al.artist_id=library_artists.id AND al.is_active=1) " + "THEN 1 ELSE 0 END" + ) + artists_found = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"] + albums_found = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"] + conn.commit() + + _finish_run( + run_id, "completed", + files_scanned=files_scanned, albums_found=albums_found, artists_found=artists_found, + progress="Done", + ) + logger.info("Scan %d completed: %d files, %d artists, %d albums", run_id, files_scanned, artists_found, albums_found) + return {"run_id": run_id, "status": "completed", "files_scanned": files_scanned} + except Exception as exc: # pragma: no cover - defensive + logger.exception("Scan %d failed", run_id) + _finish_run(run_id, "failed", files_scanned=files_scanned, error_message=str(exc)) + return {"run_id": run_id, "status": "failed", "error": str(exc)} + + +# ── completeness ────────────────────────────────────────────────────────────── + + +def _local_albums_for_artist(conn, artist_id: int) -> list[dict]: + rows = conn.execute( + "SELECT id, title, title_normalized, year, mbid FROM library_albums WHERE artist_id=? AND is_active=1", + (artist_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def recompute_completeness(conn, artist_id: int) -> None: + """Rebuild completeness rows for one artist from stored external releases and + local albums. Manual decisions (manual_override=1) are preserved.""" + local_albums = _local_albums_for_artist(conn, artist_id) + releases = conn.execute( + "SELECT mb_release_group_mbid, title, first_release_year, primary_type, secondary_types " + "FROM external_releases WHERE artist_id=?", + (artist_id,), + ).fetchall() + + existing = { + r["release_group_mbid"]: dict(r) + for r in conn.execute( + "SELECT release_group_mbid, manual_override FROM collection_completeness WHERE artist_id=?", + (artist_id,), + ).fetchall() + } + + qualifying_mbids: list[str] = [] + now = db.now_iso() + for rel in releases: + secondary = json.loads(rel["secondary_types"] or "[]") + if not musicbrainz.is_official_album(rel["primary_type"], secondary): + continue + mbid = rel["mb_release_group_mbid"] + qualifying_mbids.append(mbid) + + prior = existing.get(mbid) + if prior and prior["manual_override"]: + # Keep the user's decision; only refresh descriptive fields. + conn.execute( + "UPDATE collection_completeness SET title=?, year=?, source='musicbrainz', updated_at=? " + "WHERE artist_id=? AND release_group_mbid=?", + (rel["title"], rel["first_release_year"] or 0, now, artist_id, mbid), + ) + continue + + status, confidence, reason, local_id = classify_release( + rel["title"], rel["first_release_year"], mbid, local_albums + ) + conn.execute( + """ + INSERT INTO collection_completeness(artist_id, release_group_mbid, local_album_id, title, year, + status, confidence, reason, source, manual_override, updated_at) + VALUES(?,?,?,?,?,?,?,?, 'musicbrainz', 0, ?) + ON CONFLICT(artist_id, release_group_mbid) DO UPDATE SET + local_album_id=excluded.local_album_id, title=excluded.title, year=excluded.year, + status=excluded.status, confidence=excluded.confidence, reason=excluded.reason, + source='musicbrainz', updated_at=excluded.updated_at + WHERE collection_completeness.manual_override=0 + """, + (artist_id, mbid, local_id, rel["title"], rel["first_release_year"] or 0, + status, confidence, reason, now), + ) + + # Drop non-manual rows that are no longer qualifying (e.g. filter changes). + placeholders = ",".join("?" for _ in qualifying_mbids) or "''" + conn.execute( + f"DELETE FROM collection_completeness WHERE artist_id=? AND manual_override=0 " + f"AND release_group_mbid NOT IN ({placeholders})", + (artist_id, *qualifying_mbids), + ) + + +# ── metadata refresh job (per-artist MusicBrainz lookups) ───────────────────── + + +def run_metadata_refresh() -> dict: + run_id = _create_run("metadata") + logger.info("Metadata refresh %d started", run_id) + processed = 0 + try: + with db.connect() as conn: + artists = conn.execute( + "SELECT id, name, mbid, is_various FROM library_artists WHERE is_active=1 ORDER BY name" + ).fetchall() + total = len(artists) + + for artist in artists: + artist_id = artist["id"] + if artist["is_various"]: + with db.connect() as conn: + conn.execute( + "INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'skipped', ?) " + "ON CONFLICT(artist_id) DO UPDATE SET status='skipped', checked_at=excluded.checked_at", + (artist_id, db.now_iso()), + ) + processed += 1 + continue + + match = musicbrainz.search_artist(artist["name"]) + with db.connect() as conn: + if not match or not match.get("mbid"): + conn.execute( + "INSERT INTO external_artist_matches(artist_id, status, checked_at) VALUES(?, 'not_found', ?) " + "ON CONFLICT(artist_id) DO UPDATE SET status='not_found', checked_at=excluded.checked_at", + (artist_id, db.now_iso()), + ) + processed += 1 + _bump_metadata_progress(run_id, processed, total) + continue + conn.execute( + "INSERT INTO external_artist_matches(artist_id, mb_artist_mbid, mb_artist_name, confidence, status, checked_at) " + "VALUES(?,?,?,?, 'matched', ?) " + "ON CONFLICT(artist_id) DO UPDATE SET mb_artist_mbid=excluded.mb_artist_mbid, " + "mb_artist_name=excluded.mb_artist_name, confidence=excluded.confidence, status='matched', checked_at=excluded.checked_at", + (artist_id, match["mbid"], match["name"], match["confidence"], db.now_iso()), + ) + + release_groups = musicbrainz.fetch_release_groups(match["mbid"]) + with db.connect() as conn: + for rg in release_groups: + if not rg.get("mbid"): + continue + conn.execute( + """ + INSERT INTO external_releases(artist_id, mb_release_group_mbid, title, title_normalized, + first_release_year, primary_type, secondary_types, fetched_at) + VALUES(?,?,?,?,?,?,?,?) + ON CONFLICT(artist_id, mb_release_group_mbid) DO UPDATE SET + title=excluded.title, title_normalized=excluded.title_normalized, + first_release_year=excluded.first_release_year, primary_type=excluded.primary_type, + secondary_types=excluded.secondary_types, fetched_at=excluded.fetched_at + """, + ( + artist_id, rg["mbid"], rg["title"], normalize_title(rg["title"]), + rg["first_release_year"], rg["primary_type"], json.dumps(rg["secondary_types"]), + db.now_iso(), + ), + ) + recompute_completeness(conn, artist_id) + + processed += 1 + _bump_metadata_progress(run_id, processed, total) + + _finish_run(run_id, "completed", artists_found=processed, progress=f"Checked {processed} artists") + logger.info("Metadata refresh %d completed: %d artists", run_id, processed) + return {"run_id": run_id, "status": "completed", "artists": processed} + except Exception as exc: # pragma: no cover - defensive + logger.exception("Metadata refresh %d failed", run_id) + _finish_run(run_id, "failed", error_message=str(exc)) + return {"run_id": run_id, "status": "failed", "error": str(exc)} + + +def _bump_metadata_progress(run_id: int, processed: int, total: int) -> None: + _update_run(run_id, artists_found=processed, progress=f"Checked {processed}/{total} artists") + + +# ── job launchers ───────────────────────────────────────────────────────────── + + +def start_scan_job(root: Path | None = None) -> dict: + if not _try_acquire("scan"): + return {"started": False, "reason": "A scan is already running."} + + def _worker(): + try: + run_scan(root) + finally: + _release("scan") + + threading.Thread(target=_worker, name="scan_music_collection", daemon=True).start() + return {"started": True} + + +def start_metadata_job() -> dict: + if not _try_acquire("metadata"): + return {"started": False, "reason": "A metadata refresh is already running."} + + def _worker(): + try: + run_metadata_refresh() + finally: + _release("metadata") + + threading.Thread(target=_worker, name="refresh_music_metadata", daemon=True).start() + return {"started": True} + + +# ── read-side queries (UI; database only) ───────────────────────────────────── + + +def _latest_run(conn, kind: str) -> dict | None: + row = conn.execute( + "SELECT * FROM library_scan_runs WHERE kind=? ORDER BY id DESC LIMIT 1", (kind,) + ).fetchone() + return dict(row) if row else None + + +def get_status() -> dict: + with db.connect() as conn: + scan = _latest_run(conn, "scan") + metadata = _latest_run(conn, "metadata") + return { + "scan": scan, + "metadata": metadata, + "scan_running": _is_running("scan"), + "metadata_running": _is_running("metadata"), + } + + +def get_overview() -> dict: + with db.connect() as conn: + scan = _latest_run(conn, "scan") + metadata = _latest_run(conn, "metadata") + totals = conn.execute( + """ + SELECT + SUM(CASE WHEN status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned, + SUM(CASE WHEN status='missing' THEN 1 ELSE 0 END) AS missing, + SUM(CASE WHEN status='uncertain' THEN 1 ELSE 0 END) AS uncertain, + SUM(CASE WHEN status='ignored' THEN 1 ELSE 0 END) AS ignored + FROM collection_completeness c + JOIN library_artists a ON a.id=c.artist_id AND a.is_active=1 + """ + ).fetchone() + artist_count = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"] + album_count = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"] + + owned = totals["owned"] or 0 + missing = totals["missing"] or 0 + uncertain = totals["uncertain"] or 0 + ignored = totals["ignored"] or 0 + denom = owned + missing + uncertain + completeness = round(owned / denom * 100, 1) if denom else 0.0 + return { + "last_scan": scan, + "last_metadata": metadata, + "scan_running": _is_running("scan"), + "metadata_running": _is_running("metadata"), + "owned": owned, + "missing": missing, + "uncertain": uncertain, + "ignored": ignored, + "completeness": completeness, + "library_artists": artist_count, + "library_albums": album_count, + } + + +def get_artists_completeness(search: str = "") -> list[dict]: + where = "WHERE a.is_active=1" + params: list = [] + if search.strip(): + where += " AND a.name LIKE ?" + params.append(f"%{search.strip()}%") + with db.connect() as conn: + rows = conn.execute( + f""" + SELECT a.id, a.name, + SUM(CASE WHEN c.status IN ('owned','probably_owned') THEN 1 ELSE 0 END) AS owned, + SUM(CASE WHEN c.status='missing' THEN 1 ELSE 0 END) AS missing, + SUM(CASE WHEN c.status='uncertain' THEN 1 ELSE 0 END) AS uncertain, + SUM(CASE WHEN c.status='ignored' THEN 1 ELSE 0 END) AS ignored, + COUNT(c.id) AS total + FROM library_artists a + JOIN collection_completeness c ON c.artist_id=a.id + {where} + GROUP BY a.id + HAVING total > 0 + ORDER BY missing DESC, a.name COLLATE NOCASE + """, + params, + ).fetchall() + result = [] + for r in rows: + owned, missing, uncertain = r["owned"] or 0, r["missing"] or 0, r["uncertain"] or 0 + denom = owned + missing + uncertain + result.append( + { + "id": r["id"], + "name": r["name"], + "owned": owned, + "missing": missing, + "uncertain": uncertain, + "ignored": r["ignored"] or 0, + "completeness": round(owned / denom * 100, 1) if denom else 0.0, + } + ) + return result + + +def get_artist_albums(artist_id: int) -> dict: + with db.connect() as conn: + artist = conn.execute("SELECT id, name FROM library_artists WHERE id=?", (artist_id,)).fetchone() + rows = conn.execute( + "SELECT id, release_group_mbid, title, year, status, confidence, reason, source, manual_override " + "FROM collection_completeness WHERE artist_id=? ORDER BY year, title COLLATE NOCASE", + (artist_id,), + ).fetchall() + return { + "artist": dict(artist) if artist else None, + "albums": [dict(r) for r in rows], + } + + +def set_album_decision(completeness_id: int, action: str) -> dict: + action = (action or "").lower() + valid = {"ignore", "owned", "missing", "reset"} + if action not in valid: + raise ValueError(f"Unknown action: {action}") + + now = db.now_iso() + with db.connect() as conn: + row = conn.execute( + "SELECT id, artist_id FROM collection_completeness WHERE id=?", (completeness_id,) + ).fetchone() + if not row: + raise LookupError("Completeness row not found.") + + if action == "ignore": + conn.execute( + "UPDATE collection_completeness SET status='ignored', manual_override=1, reason='Manually ignored', updated_at=? WHERE id=?", + (now, completeness_id), + ) + elif action == "owned": + conn.execute( + "UPDATE collection_completeness SET status='owned', confidence=1.0, manual_override=1, reason='Manually marked owned', updated_at=? WHERE id=?", + (now, completeness_id), + ) + elif action == "missing": + conn.execute( + "UPDATE collection_completeness SET status='missing', confidence=0, manual_override=1, reason='Manually marked missing', updated_at=? WHERE id=?", + (now, completeness_id), + ) + elif action == "reset": + conn.execute( + "UPDATE collection_completeness SET manual_override=0, updated_at=? WHERE id=?", + (now, completeness_id), + ) + recompute_completeness(conn, row["artist_id"]) + return {"status": "ok"} diff --git a/services/musicbrainz.py b/services/musicbrainz.py new file mode 100644 index 0000000..f34fc75 --- /dev/null +++ b/services/musicbrainz.py @@ -0,0 +1,187 @@ +"""MusicBrainz client: the default free metadata source. + +Deliberately small and polite: +* one global throttle so we never exceed ~1 request/second (MB's published limit); +* a descriptive User-Agent (MB rejects anonymous clients); +* retry/backoff on 503 and network errors; +* responses cached in the ``mb_cache`` table so completeness never hits the API + during a normal page render. +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time + +import requests + +from . import db + +logger = logging.getLogger("homelabtoolkit.musicbrainz") + +MB_BASE = "https://musicbrainz.org/ws/2" +USER_AGENT = os.environ.get( + "MUSICBRAINZ_USER_AGENT", + "HomelabToolkit/1.0 ( https://github.com/homelabtoolkit )", +) +MIN_INTERVAL_SECONDS = 1.1 +CACHE_MAX_AGE_DAYS = 30 + +# Release-group filtering. Primary type must be Album; any of these secondary +# types excludes it (live albums, compilations, soundtracks, remixes, etc.). +# Kept as module constants so the filter can be relaxed later in one place. +INCLUDED_PRIMARY_TYPES = {"album"} +EXCLUDED_SECONDARY_TYPES = { + "live", + "compilation", + "soundtrack", + "remix", + "dj-mix", + "mixtape/street", + "demo", + "interview", + "audiobook", + "audio drama", + "spokenword", +} + +_throttle_lock = threading.Lock() +_last_request_at = 0.0 + + +def _throttle() -> None: + global _last_request_at + with _throttle_lock: + wait = MIN_INTERVAL_SECONDS - (time.monotonic() - _last_request_at) + if wait > 0: + time.sleep(wait) + _last_request_at = time.monotonic() + + +def _cache_get(cache_key: str, max_age_days: int = CACHE_MAX_AGE_DAYS): + cutoff = time.time() - max_age_days * 86400 + with db.connect() as conn: + row = conn.execute( + "SELECT payload, fetched_at FROM mb_cache WHERE cache_key=?", (cache_key,) + ).fetchone() + if not row: + return None + # fetched_at is ISO; treat anything older than the cutoff as a miss. + try: + import datetime as _dt + + fetched = _dt.datetime.fromisoformat((row["fetched_at"] or "").replace("Z", "+00:00")) + if fetched.timestamp() < cutoff: + return None + except ValueError: + pass + try: + return json.loads(row["payload"]) + except (TypeError, ValueError): + return None + + +def _cache_put(cache_key: str, payload) -> None: + with db.connect() as conn: + conn.execute( + "INSERT INTO mb_cache(cache_key, payload, fetched_at) VALUES(?,?,?) " + "ON CONFLICT(cache_key) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at", + (cache_key, json.dumps(payload), db.now_iso()), + ) + + +def _request(path: str, params: dict, cache_key: str, *, use_cache: bool = True): + if use_cache: + cached = _cache_get(cache_key) + if cached is not None: + return cached + + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + query = {**params, "fmt": "json"} + for attempt in range(4): + _throttle() + try: + resp = requests.get(f"{MB_BASE}/{path}", params=query, headers=headers, timeout=25) + if resp.status_code == 503: + time.sleep(2.0 * (attempt + 1)) + continue + resp.raise_for_status() + data = resp.json() + _cache_put(cache_key, data) + return data + except requests.RequestException as exc: + logger.warning("MusicBrainz request failed (%s attempt %d): %s", path, attempt + 1, exc) + time.sleep(1.5 * (attempt + 1)) + return None + + +def search_artist(name: str) -> dict | None: + """Best artist match for a name, with a 0–1 confidence score.""" + cache_key = f"artist_search::{name.lower()}" + data = _request("artist", {"query": name, "limit": 5}, cache_key) + if not data: + return None + artists = data.get("artists") or [] + if not artists: + return None + best = artists[0] + return { + "mbid": best.get("id"), + "name": best.get("name") or "", + "confidence": round(int(best.get("score", 0)) / 100.0, 3), + } + + +def fetch_release_groups(artist_mbid: str) -> list[dict]: + """All release groups for an artist (paged). Filtering happens later so the + completeness filters can change without re-fetching.""" + results: list[dict] = [] + offset = 0 + limit = 100 + while True: + cache_key = f"release_groups::{artist_mbid}::{offset}" + data = _request( + "release-group", + {"artist": artist_mbid, "type": "album", "limit": limit, "offset": offset}, + cache_key, + ) + if not data: + break + batch = data.get("release-groups") or [] + for rg in batch: + results.append( + { + "mbid": rg.get("id"), + "title": rg.get("title") or "", + "first_release_year": _year(rg.get("first-release-date")), + "primary_type": (rg.get("primary-type") or "").strip(), + "secondary_types": [s.strip() for s in (rg.get("secondary-types") or [])], + } + ) + total = int(data.get("release-group-count", len(results))) + offset += limit + if offset >= total or not batch: + break + return results + + +def is_official_album(primary_type: str | None, secondary_types: list[str] | None) -> bool: + """Apply the default album filter (excludes EP/single/live/comp/etc.).""" + if (primary_type or "").strip().lower() not in INCLUDED_PRIMARY_TYPES: + return False + for secondary in secondary_types or []: + if secondary.strip().lower() in EXCLUDED_SECONDARY_TYPES: + return False + return True + + +def _year(date_str: str | None) -> int: + if not date_str: + return 0 + try: + return int(str(date_str)[:4]) + except (ValueError, TypeError): + return 0 diff --git a/services/navidrome.py b/services/navidrome.py new file mode 100644 index 0000000..38a8595 --- /dev/null +++ b/services/navidrome.py @@ -0,0 +1,308 @@ +"""Navidrome integration via the Subsonic API. + +Navidrome speaks the Subsonic/OpenSubsonic REST API. Authentication uses the +salted-token scheme (``t = md5(password + salt)``) so the password never travels +in the clear. All functions take an ``httpx.AsyncClient`` so they share the +app-wide client and stay easy to test. +""" + +from __future__ import annotations + +import hashlib +import os +import secrets + +import httpx + +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", "") +NAVIDROME_CLIENT = "HomelabToolkit" +SUBSONIC_API_VERSION = "1.16.1" + + +class NavidromeError(Exception): + def __init__(self, message: str, status: int = 502): + self.message = message + self.status = status + super().__init__(message) + + +def is_configured() -> bool: + return bool(NAVIDROME_URL and NAVIDROME_USER and NAVIDROME_PASSWORD) + + +def _require_configured() -> None: + if not is_configured(): + raise NavidromeError( + "Navidrome is not configured. Set NAVIDROME_URL, NAVIDROME_USER and " + "NAVIDROME_PASSWORD.", + status=503, + ) + + +def _auth_params() -> dict: + salt = secrets.token_hex(8) + token = hashlib.md5((NAVIDROME_PASSWORD + salt).encode("utf-8")).hexdigest() + return { + "u": NAVIDROME_USER, + "t": token, + "s": salt, + "v": SUBSONIC_API_VERSION, + "c": NAVIDROME_CLIENT, + "f": "json", + } + + +def _base_url() -> str: + return NAVIDROME_URL.rstrip("/") + + +async def _call(client: httpx.AsyncClient, method: str, params: dict | None = None) -> dict: + _require_configured() + url = f"{_base_url()}/rest/{method}.view" + request_params = {**_auth_params(), **(params or {})} + try: + response = await client.get(url, params=request_params) + except httpx.RequestError as exc: + raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + raise NavidromeError( + f"Navidrome returned HTTP {response.status_code} for {method}.", status=502 + ) from exc + + try: + payload = response.json().get("subsonic-response", {}) + except ValueError as exc: + raise NavidromeError("Navidrome returned an invalid response.", status=502) from exc + + if payload.get("status") != "ok": + error = payload.get("error") or {} + message = error.get("message") or "Navidrome request failed." + code = error.get("code") + # Subsonic auth failures (codes 40/41) are the user's credentials, not a + # server error — surface as 401 so the UI can prompt for setup. + status = 401 if code in (40, 41, 44) else 502 + raise NavidromeError(message, status=status) + + return payload + + +def _cover_url(cover_art: str | None) -> str | None: + if not cover_art: + return None + return f"/api/navidrome/cover/{cover_art}" + + +def _map_album(raw: dict) -> dict: + return { + "id": raw.get("id"), + "name": raw.get("name") or raw.get("album") or "", + "artist": raw.get("artist") or "", + "artist_id": raw.get("artistId"), + "year": raw.get("year"), + "genre": raw.get("genre"), + "song_count": raw.get("songCount") or 0, + "duration": raw.get("duration") or 0, + "cover_art": raw.get("coverArt"), + "cover_url": _cover_url(raw.get("coverArt")), + "created": raw.get("created"), + "starred": bool(raw.get("starred")), + } + + +def _map_song(raw: dict) -> dict: + return { + "id": raw.get("id"), + "title": raw.get("title") or "", + "track": raw.get("track"), + "disc": raw.get("discNumber"), + "artist": raw.get("artist") or "", + "album": raw.get("album") or "", + "year": raw.get("year"), + "duration": raw.get("duration") or 0, + "bitrate": raw.get("bitRate"), + "suffix": raw.get("suffix"), + "size": raw.get("size"), + "path": raw.get("path"), + } + + +async def ping(client: httpx.AsyncClient) -> dict: + if not is_configured(): + return {"connected": False, "configured": False, "url": NAVIDROME_URL} + try: + payload = await _call(client, "ping") + return { + "connected": True, + "configured": True, + "url": NAVIDROME_URL, + "version": payload.get("version"), + "server": payload.get("type") or payload.get("serverVersion"), + } + except NavidromeError as exc: + return { + "connected": False, + "configured": True, + "url": NAVIDROME_URL, + "error": exc.message, + } + + +async def get_artists(client: httpx.AsyncClient) -> list[dict]: + payload = await _call(client, "getArtists") + indexes = ((payload.get("artists") or {}).get("index")) or [] + artists: list[dict] = [] + for index in indexes: + for artist in index.get("artist") or []: + artists.append( + { + "id": artist.get("id"), + "name": artist.get("name") or "", + "album_count": artist.get("albumCount") or 0, + "cover_art": artist.get("coverArt"), + "cover_url": _cover_url(artist.get("coverArt")), + } + ) + artists.sort(key=lambda entry: entry["name"].lower()) + return artists + + +async def get_albums( + client: httpx.AsyncClient, + *, + list_type: str = "alphabeticalByName", + size: int = 100, + offset: int = 0, +) -> list[dict]: + payload = await _call( + client, + "getAlbumList2", + {"type": list_type, "size": size, "offset": offset}, + ) + raw_albums = ((payload.get("albumList2") or {}).get("album")) or [] + return [_map_album(album) for album in raw_albums] + + +async def search_albums(client: httpx.AsyncClient, query: str, *, count: int = 60) -> list[dict]: + payload = await _call( + client, + "search3", + {"query": query, "albumCount": count, "artistCount": 0, "songCount": 0}, + ) + raw_albums = ((payload.get("searchResult3") or {}).get("album")) or [] + return [_map_album(album) for album in raw_albums] + + +async def get_album(client: httpx.AsyncClient, album_id: str) -> dict: + payload = await _call(client, "getAlbum", {"id": album_id}) + raw = payload.get("album") or {} + album = _map_album(raw) + album["songs"] = [_map_song(song) for song in (raw.get("song") or [])] + return album + + +async def get_cover_art( + client: httpx.AsyncClient, cover_id: str, size: int | None = None +) -> tuple[bytes, str]: + _require_configured() + url = f"{_base_url()}/rest/getCoverArt.view" + params = {**_auth_params(), "id": cover_id} + if size: + params["size"] = size + try: + response = await client.get(url, params=params) + except httpx.RequestError as exc: + raise NavidromeError(f"Could not reach Navidrome at {NAVIDROME_URL}: {exc}") from exc + if response.status_code != 200: + raise NavidromeError("Cover art not found.", status=404) + content_type = (response.headers.get("content-type") or "image/jpeg").split(";")[0] + if not content_type.startswith("image/"): + # Subsonic returns a JSON error document on failure. + raise NavidromeError("Cover art not found.", status=404) + return response.content, content_type + + +async def start_scan(client: httpx.AsyncClient, *, full: bool = False) -> dict: + """Trigger a Navidrome library scan (Subsonic ``startScan`` extension).""" + payload = await _call(client, "startScan", {"fullScan": "true" if full else "false"}) + status = payload.get("scanStatus") or {} + return {"scanning": bool(status.get("scanning")), "count": status.get("count")} + + +async def get_genres(client: httpx.AsyncClient) -> list[dict]: + payload = await _call(client, "getGenres") + raw = ((payload.get("genres") or {}).get("genre")) or [] + genres = [ + { + "name": g.get("value") or g.get("name") or "Unknown", + "song_count": g.get("songCount") or 0, + "album_count": g.get("albumCount") or 0, + } + for g in raw + ] + genres.sort(key=lambda g: g["song_count"], reverse=True) + return genres + + +async def get_format_breakdown( + client: httpx.AsyncClient, *, page_size: int = 500, max_pages: int = 400 +) -> dict: + """Count tracks by file format (flac, mp3, m4a, …) by paging all songs. + + Subsonic has no aggregate format endpoint, so we walk ``search3`` with an + empty query (Navidrome returns the whole library) and tally each song's + ``suffix``. Cap the page count so a runaway library can't loop forever. + """ + counts: dict[str, int] = {} + offset = 0 + for _ in range(max_pages): + payload = await _call( + client, + "search3", + { + "query": "", + "artistCount": 0, + "albumCount": 0, + "songCount": page_size, + "songOffset": offset, + }, + ) + songs = ((payload.get("searchResult3") or {}).get("song")) or [] + if not songs: + break + for song in songs: + suffix = (song.get("suffix") or "").lower() or "other" + counts[suffix] = counts.get(suffix, 0) + 1 + if len(songs) < page_size: + break + offset += page_size + + total = sum(counts.values()) + formats = sorted( + ({"format": fmt, "count": count} for fmt, count in counts.items()), + key=lambda entry: entry["count"], + reverse=True, + ) + return {"total": total, "formats": formats} + + +async def get_stats(client: httpx.AsyncClient) -> dict: + """Library stats for the dashboard: artists, albums, tracks and genres.""" + artists = await get_artists(client) + album_count = sum(artist["album_count"] for artist in artists) + try: + genres = await get_genres(client) + except NavidromeError: + genres = [] + song_count = sum(g["song_count"] for g in genres) + return { + "artist_count": len(artists), + "album_count": album_count, + "song_count": song_count, + "genre_count": len(genres), + "top_genres": genres[:8], + } diff --git a/services/recommendations.py b/services/recommendations.py new file mode 100644 index 0000000..876176d --- /dev/null +++ b/services/recommendations.py @@ -0,0 +1,159 @@ +"""Deterministic, watch-history-based recommendation engine. + +The scoring model is intentionally simple and explainable (section 8 of the +spec). It is pure: ``build_profile`` and ``score_candidate`` touch no I/O and are +the unit under test. ``build_candidates`` is the only function that calls Emby. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from .emby_collections import ITEM_FIELDS, normalize_item + +# Points awarded per matching facet. Genre is the strongest signal. +SCORE_GENRE = 5 # per shared genre +SCORE_SERIES = 4 # same series / franchise +SCORE_DIRECTOR = 3 # per shared director +SCORE_ACTOR = 2 # per shared actor +SCORE_STUDIO = 2 # per shared studio +SCORE_DECADE = 1 # release decade seen in history +SCORE_MEDIA_TYPE = 1 # media type seen in history + +DEFAULT_TARGET_SIZE = 25 +_CANDIDATE_FETCH_LIMIT = 300 +_MAX_GENRE_QUERY = 8 + + +def _decade(year) -> int | None: + try: + return (int(year) // 10) * 10 + except (TypeError, ValueError): + return None + + +@dataclass +class TasteProfile: + """Aggregated facets of a user's watch history.""" + + genres: set[str] = field(default_factory=set) + series: set[str] = field(default_factory=set) + directors: set[str] = field(default_factory=set) + actors: set[str] = field(default_factory=set) + studios: set[str] = field(default_factory=set) + decades: set[int] = field(default_factory=set) + media_types: set[str] = field(default_factory=set) + + @property + def is_empty(self) -> bool: + return not ( + self.genres or self.series or self.directors or self.actors + or self.studios or self.decades or self.media_types + ) + + +def build_profile(watched_items: list[dict]) -> TasteProfile: + """Aggregate normalized watched items into a :class:`TasteProfile`.""" + profile = TasteProfile() + for item in watched_items: + profile.genres.update(item.get("genres") or []) + profile.directors.update(item.get("directors") or []) + profile.actors.update(item.get("actors") or []) + profile.studios.update(item.get("studios") or []) + profile.media_types.add(item.get("media_type") or item.get("type") or "") + series = item.get("series_name") + if series: + profile.series.add(series) + # A watched series is itself a "franchise" anchor for similar items. + if (item.get("type") == "Series") and item.get("title"): + profile.series.add(item["title"]) + decade = _decade(item.get("year")) + if decade is not None: + profile.decades.add(decade) + profile.media_types.discard("") + return profile + + +def score_candidate(candidate: dict, profile: TasteProfile) -> int: + """Score a candidate against the profile. Higher is more similar.""" + score = 0 + score += SCORE_GENRE * len(set(candidate.get("genres") or []) & profile.genres) + score += SCORE_DIRECTOR * len(set(candidate.get("directors") or []) & profile.directors) + score += SCORE_ACTOR * len(set(candidate.get("actors") or []) & profile.actors) + score += SCORE_STUDIO * len(set(candidate.get("studios") or []) & profile.studios) + + series = candidate.get("series_name") or (candidate.get("title") if candidate.get("type") == "Series" else None) + if series and series in profile.series: + score += SCORE_SERIES + + decade = _decade(candidate.get("year")) + if decade is not None and decade in profile.decades: + score += SCORE_DECADE + + media_type = candidate.get("media_type") or candidate.get("type") + if media_type and media_type in profile.media_types: + score += SCORE_MEDIA_TYPE + + return score + + +def rank_candidates(candidates: list[dict], profile: TasteProfile) -> list[dict]: + """Score, filter to score > 0, and sort candidates. + + Order: score desc, then community rating desc, then title asc. Each returned + item carries an added ``score`` key for transparency in the UI/logs. + """ + scored = [] + for candidate in candidates: + score = score_candidate(candidate, profile) + if score <= 0: + continue + scored.append({**candidate, "score": score}) + scored.sort( + key=lambda c: (-c["score"], -(c.get("community_rating") or 0.0), (c.get("title") or "").casefold()) + ) + return scored + + +async def build_candidates(client, user_id: str, profile: TasteProfile, exclude_ids: set[str]) -> list[dict]: + """Fetch unplayed library items similar to the profile, minus exclusions. + + Uses the user-scoped ``IsUnplayed`` filter so already-watched items never + enter the pool. Anything in ``exclude_ids`` (playlist members, defensively + re-checked watched ids) is dropped. + """ + if profile.is_empty: + return [] + + genres = sorted(profile.genres)[:_MAX_GENRE_QUERY] + media_types = ",".join(sorted(t for t in profile.media_types if t)) or "Movie,Series" + params = { + "Recursive": "true", + "Filters": "IsUnplayed", + "IsPlayed": "false", + "IncludeItemTypes": media_types if media_types in ("Movie", "Series", "Movie,Series") else "Movie,Series", + "Fields": ITEM_FIELDS, + "EnableUserData": "true", + "SortBy": "CommunityRating", + "SortOrder": "Descending", + "Limit": str(_CANDIDATE_FETCH_LIMIT), + } + if genres: + # Emby treats "|" as OR across genre values. + params["Genres"] = "|".join(genres) + + data = await client.get(f"/Users/{user_id}/Items", params) + raw_items = data.get("Items", []) if isinstance(data, dict) else (data or []) + + seen: set[str] = set() + candidates: list[dict] = [] + for raw in raw_items: + item = normalize_item(raw) + item_id = item["id"] + if not item_id or item_id in exclude_ids or item_id in seen: + continue + if item.get("watched"): # defensive: never recommend a watched item + continue + seen.add(item_id) + candidates.append(item) + return candidates diff --git a/services/settings.py b/services/settings.py new file mode 100644 index 0000000..fe09cc8 --- /dev/null +++ b/services/settings.py @@ -0,0 +1,63 @@ +"""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. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "cache/settings.json")) + +FIELDS = ( + "emby_url", + "emby_api_key", + "navidrome_url", + "navidrome_user", + "navidrome_password", + "music_root", +) + + +def env_defaults() -> dict: + return { + "emby_url": os.environ.get("EMBY_URL", "http://10.0.0.2:8096"), + "emby_api_key": os.environ.get("EMBY_API_KEY", ""), + "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", ""), + "music_root": os.environ.get("MUSIC_ROOT", r"\\Matt-htpc\d\Music"), + } + + +def _read_file() -> dict: + if not SETTINGS_FILE.exists(): + return {} + try: + 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} + + +def load() -> dict: + """Effective settings: env defaults overlaid with the saved file.""" + values = env_defaults() + 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: + 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") + return load() diff --git a/services/text_normalize.py b/services/text_normalize.py new file mode 100644 index 0000000..5b2c637 --- /dev/null +++ b/services/text_normalize.py @@ -0,0 +1,145 @@ +"""Name normalisation and fuzzy matching for collection completeness. + +Normalisation never mutates the stored original — callers keep both the original +and normalised values. The point is to make "Album (Deluxe Edition)" and +"Album - 2009 Remaster" collapse to the same comparable key without losing the +display name. +""" + +from __future__ import annotations + +import re +from difflib import SequenceMatcher + +_BRACKET_RE = re.compile(r"[\(\[\{].*?[\)\]\}]") +_PUNCT_RE = re.compile(r"[^\w\s]", re.UNICODE) +_WS_RE = re.compile(r"\s+") + +# Edition / remaster qualifiers stripped from album titles before comparison. +_EDITION_PATTERNS = [ + r"\bsuper deluxe( edition)?\b", + r"\bdeluxe( edition| version)?\b", + r"\bexpanded( edition| version)?\b", + r"\bspecial edition\b", + r"\bcollector'?s edition\b", + r"\blegacy edition\b", + r"\banniversary( edition)?\b", + r"\b\d{1,3}(st|nd|rd|th) anniversary\b", + r"\bremaster(ed)?\b", + r"\bre-?master(ed)?\b", + r"\breissue\b", + r"\bbonus track(s)?( version)?\b", + r"\bbonus edition\b", + r"\b\d{4} remaster\b", + r"\bexplicit( version)?\b", + r"\bclean( version)?\b", + r"\bmono\b", + r"\bstereo\b", + r"\bdisc \d+\b", + r"\bcd\d+\b", +] +_EDITION_RE = re.compile("|".join(_EDITION_PATTERNS), re.IGNORECASE) + +VARIOUS_ARTISTS = { + "various artists", + "various", + "va", + "v a", + "soundtrack", + "original soundtrack", +} + + +def _base_clean(text: str) -> str: + text = text.lower() + text = _BRACKET_RE.sub(" ", text) # drop (...) [...] {...} + text = _EDITION_RE.sub(" ", text) # drop edition/remaster words + text = text.replace("&", " and ") + text = _PUNCT_RE.sub(" ", text) # drop remaining punctuation + return _WS_RE.sub(" ", text).strip() + + +def normalize_title(text: str | None) -> str: + if not text: + return "" + return _base_clean(text) + + +def normalize_artist(text: str | None) -> str: + if not text: + return "" + cleaned = _base_clean(text) + if cleaned.startswith("the "): + cleaned = cleaned[4:] + return cleaned + + +def is_various_artists(name: str | None) -> bool: + if not name: + return False + return normalize_artist(name) in VARIOUS_ARTISTS or _base_clean(name) in VARIOUS_ARTISTS + + +def similarity(a: str | None, b: str | None) -> float: + na, nb = normalize_title(a), normalize_title(b) + if not na or not nb: + return 0.0 + if na == nb: + return 1.0 + return SequenceMatcher(None, na, nb).ratio() + + +# ── Completeness classification ─────────────────────────────────────────────── +# Statuses: owned | probably_owned | missing | uncertain (ignored is manual). + +OWNED = "owned" +PROBABLY_OWNED = "probably_owned" +UNCERTAIN = "uncertain" +MISSING = "missing" + +FUZZY_PROBABLE = 0.88 +FUZZY_UNCERTAIN = 0.60 + + +def classify_release( + ext_title: str, + ext_year: int | None, + ext_mbid: str | None, + local_albums: list[dict], +) -> tuple[str, float, str, int | None]: + """Decide a status for one external release group against local albums. + + ``local_albums`` items: ``{id, title, title_normalized, year, mbid}``. + Match order: MusicBrainz id → normalised title (+year) → fuzzy title. + Returns ``(status, confidence, reason, local_album_id|None)``. + """ + ext_norm = normalize_title(ext_title) + + # 1) Exact MusicBrainz id match. + if ext_mbid: + for la in local_albums: + if la.get("mbid") and la["mbid"] == ext_mbid: + return (OWNED, 1.0, "MusicBrainz ID match", la["id"]) + + # 2) Normalised title (with year corroboration). + if ext_norm: + for la in local_albums: + if la.get("title_normalized") == ext_norm: + ly, ey = la.get("year") or 0, ext_year or 0 + if ly and ey and abs(ly - ey) <= 1: + return (OWNED, 0.95, "Title and year match", la["id"]) + return (PROBABLY_OWNED, 0.85, "Normalised title match", la["id"]) + + # 3) Fuzzy title. + best_ratio, best = 0.0, None + for la in local_albums: + r = similarity(ext_title, la.get("title")) + if r > best_ratio: + best_ratio, best = r, la + if best is not None: + if best_ratio >= FUZZY_PROBABLE: + return (PROBABLY_OWNED, round(best_ratio, 3), f"Fuzzy title match ({best_ratio:.2f})", best["id"]) + if best_ratio >= FUZZY_UNCERTAIN: + return (UNCERTAIN, round(best_ratio, 3), f"Weak title match ({best_ratio:.2f})", best["id"]) + + return (MISSING, 0.0, "No matching local album", None) diff --git a/static/app-theme.css b/static/app-theme.css new file mode 100644 index 0000000..9de3c90 --- /dev/null +++ b/static/app-theme.css @@ -0,0 +1,799 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); + +/* ============================================================================ + EmbyToolkit — Tracearr-modeled design system + Authored as a single overriding layer. Tokens use :root:root so they win over + each page's legacy inline :root, recoloring existing var() usage for free. + ========================================================================== */ + +:root:root { + /* Canvas + surfaces — near-black, cool cyan-tinted neutrals */ + --bg: #0a0c0f; + --bg-2: #0c0f13; + --surface: #101419; + --surface2: #151a21; + --surface3: #1b222b; + --surface4: #232c37; + + /* Lines */ + --border: rgba(151, 167, 187, 0.12); + --border-strong: rgba(151, 167, 187, 0.22); + --border-active: #36d6e0; + + /* Text */ + --text: #e8edf3; + --text-2: #9aa7b6; + --text-3: #5e6b7b; + + /* Accent — vivid cyan */ + --accent: #36d6e0; + --accent-h: #5ee7ef; + --accent-2: #2bb6c4; + --accent-glow: rgba(54, 214, 224, 0.15); + --accent-soft: rgba(54, 214, 224, 0.12); + + /* Status */ + --green: #46d99a; + --green-bg: rgba(70, 217, 154, 0.12); + --green-bd: rgba(70, 217, 154, 0.30); + --amber: #f3c969; + --amber-bg: rgba(243, 201, 105, 0.12); + --amber-bd: rgba(243, 201, 105, 0.30); + --red: #f0726f; + --red-bg: rgba(240, 114, 111, 0.12); + --red-bd: rgba(240, 114, 111, 0.30); + + /* Elevation + radii */ + --shadow-soft: 0 14px 38px rgba(2, 5, 10, 0.42); + --shadow-strong: 0 26px 60px rgba(2, 5, 10, 0.55); + --r: 10px; + --r-lg: 14px; + + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); +} + +html { color-scheme: dark; } + +body, +input, +button, +select, +textarea { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; +} + +body { + background: + radial-gradient(900px 420px at 78% -8%, rgba(54, 214, 224, 0.07), transparent 60%), + var(--bg) !important; + color: var(--text); +} + +::selection { background: var(--accent-glow); color: var(--text); } + +/* Tabular numerics for data-ish fields */ +.results-count, +.results-page, +.selection-meta, +.pager-meta, +.toolbar-meta, +.hero-badge, +.preview-meta, +.thumb-preview-meta, +.primary-preview-note, +.slider-val, +.asset-count, +.bd-counter, +.stat-value, +.cell-num, +.trust-score { + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; +} + +/* Scrollbars */ +* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +*::-webkit-scrollbar { width: 9px; height: 9px; } +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} +*::-webkit-scrollbar-thumb:hover { background: var(--text-3); background-clip: padding-box; } + +/* ============================================================================ + Sidebar + ========================================================================== */ + +.app-nav { + width: 236px !important; + background: var(--surface) !important; + border-right: 1px solid var(--border) !important; + backdrop-filter: blur(14px); +} + +.app-nav-brand { + padding: 18px 16px 14px !important; + gap: 11px !important; + border-bottom: 1px solid var(--border) !important; +} + +.app-nav-logo { + width: 30px !important; + height: 30px !important; + border-radius: 9px !important; + background: linear-gradient(155deg, #5ee7ef 0%, #36d6e0 45%, #1f9aa6 100%) !important; + color: #04181b !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.35), + 0 4px 14px rgba(54, 214, 224, 0.28) !important; +} + +.app-nav-name { + font-size: 15px !important; + font-weight: 700 !important; + letter-spacing: -0.02em !important; + color: var(--text) !important; +} + +/* Emby connection status chip (reuses #statusDot / #statusText) */ +.app-nav-status { + display: flex; + align-items: center; + gap: 8px; + margin: 12px 12px 4px; + padding: 8px 11px; + border-radius: 9px; + background: var(--surface2); + border: 1px solid var(--border); + font-size: 12px; + font-weight: 600; + color: var(--text-2); +} + +.app-nav-items { + padding: 8px 10px !important; + display: flex; + flex-direction: column; + gap: 2px; +} + +.app-nav-item { + position: relative; + min-height: 38px; + gap: 11px !important; + padding: 8px 12px !important; + border: 1px solid transparent; + border-radius: 9px !important; + font-size: 13px !important; + font-weight: 500 !important; + color: var(--text-2) !important; + transition: background 160ms var(--ease-out), color 160ms var(--ease-out) !important; +} + +.app-nav-item svg { opacity: 0.9; } + +.app-nav-item:hover { + background: var(--surface2) !important; + border-color: transparent !important; + color: var(--text) !important; +} + +.app-nav-item.active { + background: var(--accent-soft) !important; + border-color: transparent !important; + color: var(--accent-h) !important; +} + +.app-nav-item.active svg { opacity: 1; } + +/* Inset indicator pill (not a side-stripe border) */ +.app-nav-item.active::before { + content: ""; + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 16px; + border-radius: 99px; + background: var(--accent); + box-shadow: 0 0 10px var(--accent-glow); +} + +/* Footer: status + social + version */ +.app-nav-footer, +.app-nav-foot { + padding: 12px 14px !important; + border-top: 1px solid var(--border) !important; + display: flex; + flex-direction: column; + gap: 12px; + flex-shrink: 0; +} + +.app-nav-foot .app-nav-status { margin: 0; } + +.app-nav-social { + display: flex; + align-items: center; + gap: 4px; +} + +.app-nav-social a { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border-radius: 8px; + color: var(--text-3); + transition: background 150ms var(--ease-out), color 150ms var(--ease-out); +} + +.app-nav-social a:hover { background: var(--surface2); color: var(--accent-h); } + +.app-nav-version { + font-size: 11px; + color: var(--text-3); + letter-spacing: 0.02em; + font-variant-numeric: tabular-nums; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green) !important; + box-shadow: 0 0 0 3px var(--green-bg), 0 0 8px var(--green) !important; + flex-shrink: 0; +} + +.dot.off { background: var(--red) !important; box-shadow: 0 0 0 3px var(--red-bg), 0 0 8px var(--red) !important; } + +/* ============================================================================ + Secondary sidebar (search + results column on Generator / Collections) + One shared 16px gutter down the whole column so the search field, the + results-count row, every list item, and the pager align on the same left + edge. The header zone matches the primary nav brand height (64px) so the two + sidebars' top dividers line up across the seam. + ========================================================================== */ + +.sidebar { background: rgba(16, 20, 25, 0.92) !important; } + +.search-wrap { + display: flex; + flex-direction: column; + justify-content: center; + gap: 10px; + min-height: 64px; + padding: 13px 16px !important; + border-bottom: 1px solid var(--border) !important; +} + +.results-toolbar, +.results-footer { + padding: 12px 16px !important; + gap: 10px; +} + +.results-count { + font-size: 11px !important; + font-weight: 600 !important; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3) !important; +} + +.results-page { color: var(--text-3) !important; } + +.results { padding: 8px !important; } + +.results .result-item + .result-item { margin-top: 2px; } + +.result-item { + gap: 11px !important; + padding: 9px 8px !important; /* 8 (results) + 8 = 16px content gutter */ + border-radius: 9px !important; +} + +.result-poster { + width: 38px !important; + height: 57px !important; + border-radius: 6px !important; +} + +.result-name { font-size: 13px !important; } + +.empty { padding: 48px 24px !important; line-height: 1.5; } + +/* Align the primary nav brand divider with the search-wrap divider */ +.app-nav-brand { + min-height: 64px; + padding: 0 16px !important; + align-items: center; +} + +/* ============================================================================ + Page chrome + header + ========================================================================== */ + +.page, +.main, +.body { background: transparent !important; } + +.app-topbar { + background: rgba(16, 20, 25, 0.86) !important; + border-bottom: 1px solid var(--border) !important; + backdrop-filter: blur(14px); +} + +.hero { + align-items: flex-start !important; +} + +.hero-copy h2, +.collection-name, +.card-title, +.panel-head h3 { + letter-spacing: -0.02em !important; + color: var(--text) !important; +} + +.hero-copy h2 { + font-size: 28px !important; + font-weight: 700 !important; +} + +.hero-copy p { + color: var(--text-2) !important; + max-width: 72ch; + line-height: 1.6; +} + +.hero-copy p strong { color: var(--text); font-weight: 600; } + +.hero-badge { + align-self: center; + background: var(--surface2) !important; + border: 1px solid var(--border) !important; + border-radius: 999px !important; + color: var(--text-2) !important; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* ============================================================================ + Surfaces — toolbars, panels, cards + ========================================================================== */ + +.sidebar, +.controls, +.results-toolbar, +.results-footer, +.toolbar, +.panel { + background: rgba(16, 20, 25, 0.88) !important; + border-color: var(--border) !important; + backdrop-filter: blur(14px); +} + +.toolbar, +.panel, +.card, +.preview-shell, +.dropzone, +.asset-card, +.preview-frame, +.primary-preview-frame { + border-radius: var(--r-lg) !important; + box-shadow: var(--shadow-soft); +} + +.toolbar, +.panel, +.card { + background: linear-gradient(180deg, rgba(19, 24, 31, 0.96), rgba(13, 17, 22, 0.96)) !important; + border: 1px solid var(--border) !important; +} + +.panel-head { border-color: var(--border) !important; } + +.preview-shell { + background: + radial-gradient(circle at top right, rgba(54, 214, 224, 0.1), transparent 32%), + linear-gradient(180deg, rgba(22, 28, 37, 0.94), rgba(11, 15, 21, 0.98)) !important; + border: 1px solid var(--border) !important; +} + +.preview-frame, +.primary-preview-frame { + background: #0d141c !important; + box-shadow: var(--shadow-strong) !important; +} + +.hero-badge, +.preview-meta, +.thumb-preview-tools, +.preview-tools, +.thumb-preview-hint, +.preview-hint, +.primary-preview-hint, +.bd-counter { + background: rgba(8, 12, 17, 0.8) !important; + border-color: var(--border-strong) !important; +} + +/* ============================================================================ + Stat cards (Tracearr summary row) — available vocabulary for the pages + ========================================================================== */ + +.stat-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 14px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 14px; + padding: 18px 20px; + border-radius: var(--r-lg); + background: linear-gradient(180deg, rgba(19, 24, 31, 0.96), rgba(13, 17, 22, 0.96)); + border: 1px solid var(--border); + box-shadow: var(--shadow-soft); +} + +.stat-icon { + width: 38px; + height: 38px; + border-radius: 10px; + display: grid; + place-items: center; + background: var(--accent-soft); + color: var(--accent-h); + flex-shrink: 0; +} + +.stat-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.stat-value { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; color: var(--text); } +.stat-label { font-size: 12px; color: var(--text-2); } + +/* ============================================================================ + Segmented controls / date pills + dropdown buttons + ========================================================================== */ + +.seg { + display: inline-flex; + padding: 3px; + gap: 2px; + border-radius: 10px; + background: var(--surface2); + border: 1px solid var(--border); +} + +.seg-btn { + border: 0 !important; + background: transparent !important; + color: var(--text-2) !important; + padding: 6px 12px !important; + border-radius: 8px !important; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 150ms var(--ease-out), color 150ms var(--ease-out) !important; +} + +.seg-btn:hover:not(:disabled) { color: var(--text) !important; background: transparent !important; } + +.seg-btn.active { + background: var(--surface4) !important; + color: var(--text) !important; + box-shadow: inset 0 0 0 1px var(--border-strong); +} + +/* ============================================================================ + Inputs + buttons + ========================================================================== */ + +.search-input, +.ctrl-input, +.ctrl-select, +.ctrl-textarea, +.color-wrap input[type="text"], +.color-wrap input[type="color"], +.picker-row input[type="text"], +.picker-row input[type="color"], +.btn, +.pager-btn, +.tab-btn, +.seg-btn, +.slider-step, +.asset-btn, +.chip-toggle, +.toggle-chip, +.studio-reset-btn, +.studio-bulk-btn, +.icon-btn { + border-radius: 10px !important; +} + +.search-input, +.ctrl-input, +.ctrl-select, +.ctrl-textarea, +.color-wrap input[type="text"], +.color-wrap input[type="color"], +.picker-row input[type="text"], +.picker-row input[type="color"], +.asset-btn, +.slider-step, +.btn, +.pager-btn, +.tab-btn, +.toggle-chip, +.chip-toggle, +.studio-reset-btn, +.icon-btn { + background: var(--surface2) !important; + border: 1px solid var(--border) !important; + color: var(--text) !important; +} + +.search-input::placeholder, +.ctrl-input::placeholder, +.ctrl-textarea::placeholder { color: var(--text-3) !important; } + +.search-input:focus, +.ctrl-input:focus, +.ctrl-select:focus, +.ctrl-textarea:focus, +.color-wrap input[type="text"]:focus, +.picker-row input[type="text"]:focus { + border-color: var(--border-active) !important; + box-shadow: 0 0 0 3px var(--accent-glow) !important; + outline: none; +} + +.search-inner svg { color: var(--text-3); } + +.btn, +.pager-btn, +.tab-btn, +.seg-btn, +.slider-step, +.asset-btn, +.toggle-chip, +.chip-toggle, +.studio-bulk-btn, +.studio-reset-btn, +.icon-btn { + transition: + background 160ms var(--ease-out), + border-color 160ms var(--ease-out), + color 160ms var(--ease-out), + transform 160ms var(--ease-out), + opacity 160ms var(--ease-out) !important; +} + +.btn:hover:not(:disabled), +.pager-btn:hover:not(:disabled), +.tab-btn:hover:not(:disabled), +.slider-step:hover:not(:disabled), +.asset-btn:hover:not(:disabled), +.toggle-chip:hover:not(:disabled), +.chip-toggle:hover:not(:disabled), +.icon-btn:hover:not(:disabled) { + background: var(--surface3) !important; + border-color: var(--border-strong) !important; +} + +.btn:active:not(:disabled), +.pager-btn:active:not(:disabled) { transform: translateY(1px); } + +.btn-primary, +.studio-bulk-btn { + background: var(--accent) !important; + border-color: rgba(54, 214, 224, 0.4) !important; + color: #04181b !important; + font-weight: 600; +} + +.btn-primary:hover:not(:disabled), +.studio-bulk-btn:hover:not(:disabled) { + background: var(--accent-h) !important; + border-color: rgba(94, 231, 239, 0.5) !important; +} + +.btn-green { + background: var(--green-bg) !important; + border-color: var(--green-bd) !important; + color: #b7f0d4 !important; +} + +.btn-green:hover:not(:disabled) { background: rgba(70, 217, 154, 0.22) !important; } + +/* Active / selected states across pickers */ +.tab-btn.active, +.toggle-chip.active, +.pos-btn.active, +.corner-btn.active, +.studio-btn.active, +.result-item.active { + background: var(--accent-soft) !important; + border-color: var(--border-active) !important; + color: var(--text) !important; +} + +.result-item:hover, +.asset-card:hover { background: var(--surface2) !important; } + +.result-poster { background: var(--surface3) !important; } + +.results::-webkit-scrollbar-thumb { background: var(--border-strong); } + +/* ============================================================================ + Tables (Tracearr history/users vocabulary) + ========================================================================== */ + +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } + +.data-table thead th { + text-align: left; + padding: 11px 14px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-3); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.data-table tbody td { + padding: 12px 14px; + border-bottom: 1px solid var(--border); + color: var(--text-2); + vertical-align: middle; +} + +.data-table tbody tr { transition: background 140ms var(--ease-out); } +.data-table tbody tr:hover { background: rgba(54, 214, 224, 0.04); } +.data-table tbody tr:last-child td { border-bottom: 0; } +.data-table .cell-strong { color: var(--text); font-weight: 600; } +.data-table .cell-sub { color: var(--text-3); font-size: 12px; } +.data-table .cell-num { color: var(--text); font-variant-numeric: tabular-nums; } + +/* Avatars */ +.avatar { + width: 30px; + height: 30px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 700; + color: #04181b; + background: linear-gradient(155deg, #5ee7ef, #2bb6c4); + flex-shrink: 0; +} + +/* Status badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.02em; + border: 1px solid var(--border); + background: var(--surface3); + color: var(--text-2); +} + +.badge-ok, +.badge-watched, +.badge-direct, +.badge-trusted { + background: var(--green-bg); + border-color: var(--green-bd); + color: #b7f0d4; +} + +.badge-warn, +.badge-sampled { + background: var(--amber-bg); + border-color: var(--amber-bd); + color: #f6d98c; +} + +.badge-bad, +.badge-abandoned { + background: var(--red-bg); + border-color: var(--red-bd); + color: #ffb4b2; +} + +/* Trust pill */ +.trust-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + background: var(--green-bg); + border: 1px solid var(--green-bd); + color: #b7f0d4; +} + +/* Thin progress bar */ +.bar { + width: 84px; + height: 5px; + border-radius: 99px; + background: var(--surface4); + overflow: hidden; +} + +.bar-fill { + height: 100%; + border-radius: 99px; + background: linear-gradient(90deg, var(--accent-2), var(--accent)); +} + +/* Generic chips / tags / pills used by the result lists */ +.result-sub .tag, +.tag, +.meta-chip, +.pill, +.asset-pill { + letter-spacing: 0.03em !important; +} + +.result-sub .tag, +.tag, +.meta-chip, +.pill-muted, +.asset-pill.off { + background: var(--surface3) !important; + border-color: var(--border) !important; + color: var(--text-2) !important; +} + +.pill-green, +.meta-chip.ok, +.asset-pill { + background: var(--green-bg) !important; + border-color: var(--green-bd) !important; + color: #b7f0d4 !important; +} + +/* ============================================================================ + Sliders + spinner accent + ========================================================================== */ + +input[type="range"] { accent-color: var(--accent); } +.spinner { border-color: var(--border) !important; border-top-color: var(--accent) !important; } + +/* ============================================================================ + Toast + ========================================================================== */ + +.toast { border-radius: 12px !important; box-shadow: var(--shadow-soft); } + +.toast.ok { + background: var(--green-bg) !important; + border: 1px solid var(--green-bd) !important; + color: #b7f0d4 !important; +} + +.toast.err { + background: var(--red-bg) !important; + border: 1px solid var(--red-bd) !important; + color: #ffb4b2 !important; +} diff --git a/templates/airing.html b/templates/airing.html index 4777744..288f256 100644 --- a/templates/airing.html +++ b/templates/airing.html @@ -4,8 +4,7 @@ EmbyToolkit - - + + + + +
+ +
EmbyToolkit
+
+ + + + + +
+
+ +
+
+

User Favourites

+

Browse any Emby collection and view it as a given user. For a user's own "{Name} Favorites" collection you can also remove items they have already watched and top it back up with recommendations built only from that user's watch history. Destructive and bulk actions preview first; nothing changes until you apply.

+
+
+ +
+ +
+
Loading collections…
+
+
+ +
+ +
+
+ + + + + +
+
+ +
+ + + + diff --git a/templates/index.html b/templates/index.html index 872cd66..e83e8ec 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,8 +4,7 @@ EmbyToolkit - - +