Homelabtoolkit v1

This commit is contained in:
2026-06-08 00:01:55 +12:00
parent c8838a485d
commit 040fbacc70
56 changed files with 12477 additions and 151 deletions
+12
View File
@@ -0,0 +1,12 @@
__pycache__/
*.pyc
.git/
.pytest_cache/
cache/
output/
logs/
.app.out.log
.app.err.log
frontend/node_modules/
frontend/dist/
templates/
+9
View File
@@ -5,3 +5,12 @@ __pycache__/
# Runtime image cache # Runtime image cache
cache/ cache/
# App logs
logs/
.app.out.log
.app.err.log
# Frontend
frontend/node_modules/
frontend/dist/
+39
View File
@@ -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.
+12
View File
@@ -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 FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -17,6 +26,9 @@ RUN python -m pip install --upgrade pip \
COPY . . COPY . .
# Bring in the built SPA from the frontend stage.
COPY --from=frontend /frontend/dist ./frontend/dist
RUN mkdir -p /app/cache /app/output RUN mkdir -p /app/cache /app/output
EXPOSE 8500 EXPOSE 8500
+62 -75
View File
@@ -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 ### Emby
2. Search/browse your movie and TV library - **Thumbnail Generator** — composite landscape thumbnails from posters, logos and
3. Pulls the poster for a selected item backdrops (subject-aware), then push them back to Emby as custom Thumb images.
4. Extracts the subject (person/character) using rembg (offline, no external API) - **Collection Art** — design Thumb/Primary cover artwork for your collections.
5. Generates a landscape thumbnail with the subject positioned to one side and the title on the other - **Airing & New Seasons** — track currently-airing series and stamp "New Season"
6. Optionally pushes the generated thumbnail back to Emby as a custom Thumb image 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 ## Architecture
- **Subject Right, Text Left** — character on the right, title text on the left
- **Subject Center, Text Overlay** — character centered with title overlaid
## 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 ## Configuration (environment variables)
- **Blurred Poster** — darkened, heavily blurred version of the original poster
- **Solid Colour** — pick your own background colour
## 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) ## Run with 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
```
1. Edit `docker-compose.yml` with your Emby/Navidrome details and mount your music
share to match `MUSIC_ROOT`.
2. Build and run: 2. Build and run:
```bash ```bash
docker compose up -d --build 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` ## Local development
### 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:
Backend:
```bash ```bash
export TMDB_BEARER_TOKEN=your-tmdb-read-access-token pip install -r requirements.txt
# or: export TMDB_API_KEY=your-tmdb-v3-api-key python app.py # http://localhost:8500
export GOOGLE_CUSTOM_SEARCH_API_KEY=your-google-api-key
export GOOGLE_CUSTOM_SEARCH_ENGINE_ID=your-google-search-engine-id
``` ```
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) ## Deploy to a NAS
- 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
## Tech Stack `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:
- **Backend:** Python, FastAPI, Pillow, rembg (U2-Net) ```powershell
- **Frontend:** Vanilla HTML/CSS/JS .\deploy.ps1 -NasHost MATT-NAS -NasUser ssh
- **Deployment:** Docker ```
+520 -27
View File
@@ -15,15 +15,25 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
logger = logging.getLogger("embytoolkit") logger = logging.getLogger("homelabtoolkit")
import httpx import httpx
from fastapi import FastAPI, HTTPException, Query, Request 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.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageChops, ImageDraw, ImageFont, ImageFilter, ImageColor, ImageOps, UnidentifiedImageError 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 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") 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 --- # --- 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 @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
apply_settings(settings_service.load())
db_service.init_db()
get_http_client() get_http_client()
try: try:
yield yield
@@ -302,9 +325,14 @@ async def lifespan(app: FastAPI):
http_client = None http_client = None
app = FastAPI(title="EmbyToolkit", lifespan=lifespan) app = FastAPI(title="HomelabToolkit", lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static") 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: def get_http_client() -> httpx.AsyncClient:
global http_client global http_client
@@ -1983,25 +2011,6 @@ async def render_collection_art_preview(options: dict) -> tuple[str, bytes]:
# --- API Routes --- # --- 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") @app.get("/api/bulk-assign/series")
async def get_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] = [] skipped_missing_assets: list[str] = []
failed: list[dict] = [] 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): for i, item_id in enumerate(item_ids):
item = items_by_id.get(item_id) item = items_by_id.get(item_id)
if item is None: if item is None:
@@ -2892,11 +2901,495 @@ async def bulk_apply_category(request: Request):
@app.get("/api/config") @app.get("/api/config")
async def get_config(): async def get_config():
return { return {
"emby_url": EMBY_URL, "app_name": "HomelabToolkit",
"connected": bool(EMBY_API_KEY), "emby": {
"url": EMBY_URL,
"connected": bool(EMBY_API_KEY),
},
"navidrome": {
"url": navidrome_service.NAVIDROME_URL,
"configured": navidrome_service.is_configured(),
},
"music": {
"root": str(music_service.MUSIC_ROOT),
"available": music_service.MUSIC_ROOT.exists(),
},
} }
# ── Dashboard overview ───────────────────────────────────────────────────────
@app.get("/api/dashboard")
async def get_dashboard():
"""Aggregate library counts for the homepage. Resilient: any failing source
degrades to null/0 rather than failing the whole response."""
client = get_http_client()
async def emby_count(item_types: str) -> int:
data = await emby_get("/Items", {
"IncludeItemTypes": item_types,
"Recursive": "true",
"Limit": "1",
"ImageTypeLimit": "0",
})
return int(data.get("TotalRecordCount", 0))
async def latest_added() -> str | None:
data = await emby_get("/Items", {
"IncludeItemTypes": "Movie,Episode",
"Recursive": "true",
"SortBy": "DateCreated",
"SortOrder": "Descending",
"Limit": "1",
"Fields": "DateCreated",
})
items = data.get("Items") or []
return items[0].get("DateCreated") if items else None
async def user_count() -> int:
data = await emby_get("/Users")
return len(data) if isinstance(data, list) else 0
async def favorites_count() -> int:
return len(await favorites_service.list_favorites_users(emby_client_adapter))
async def navidrome_stats() -> dict:
status = await navidrome_service.ping(client)
if not status.get("connected"):
return {"connected": False, "configured": status.get("configured", False)}
stats = await navidrome_service.get_stats(client)
return {"connected": True, "configured": True, **stats}
(
movies, series, episodes, collections, users, favorites, last_added, navidrome,
) = await asyncio.gather(
emby_count("Movie"),
emby_count("Series"),
emby_count("Episode"),
emby_count("BoxSet"),
user_count(),
favorites_count(),
latest_added(),
navidrome_stats(),
return_exceptions=True,
)
def safe(value, default=0):
return default if isinstance(value, Exception) else value
return {
"emby": {
"connected": bool(EMBY_API_KEY),
"url": EMBY_URL,
"movies": safe(movies),
"series": safe(series),
"episodes": safe(episodes),
"collections": safe(collections),
"users": safe(users),
"favorites_collections": safe(favorites),
"last_added": safe(last_added, None),
},
"navidrome": navidrome if not isinstance(navidrome, Exception) else {"connected": False},
"music": {
"available": music_service.MUSIC_ROOT.exists(),
"root": str(music_service.MUSIC_ROOT),
},
}
@app.post("/api/emby/refresh-libraries")
async def emby_refresh_libraries():
"""Trigger a scan of all Emby libraries."""
response = await emby_request(
"POST", "/Library/Refresh", headers={"X-Emby-Token": EMBY_API_KEY}
)
if response.status_code not in (200, 204):
raise HTTPException(
status_code=502,
detail=f"Emby library refresh failed ({response.status_code}).",
)
return {"status": "started"}
@app.post("/api/navidrome/scan")
async def navidrome_scan(full: bool = Query(False)):
"""Trigger a Navidrome library scan."""
try:
result = await navidrome_service.start_scan(get_http_client(), full=full)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
return {"status": "started", **result}
@app.get("/api/emby/user-activity")
async def emby_user_activity():
"""Per-user last login + last activity, enriched with the most recent
session's IP/device. ``/Users`` carries login timestamps; ``/Sessions``
carries the remote endpoint (IP)."""
users, sessions = await asyncio.gather(
emby_get("/Users"),
emby_get("/Sessions"),
return_exceptions=True,
)
if isinstance(users, Exception):
raise HTTPException(status_code=502, detail="Could not load Emby users.")
if isinstance(sessions, Exception) or not isinstance(sessions, list):
sessions = []
def clean_ip(value: str | None) -> str | None:
if not value:
return None
value = value.strip()
if value.startswith("::ffff:"):
value = value[len("::ffff:"):]
return value or None
def platform_of(client: str | None, device: str | None) -> str:
text = f"{client or ''} {device or ''}".lower()
if "android" in text:
return "android"
if any(k in text for k in ("ios", "iphone", "ipad", "apple tv", "tvos")):
return "ios"
if any(k in text for k in ("web", "browser", "chrome", "firefox", "safari", "edge")):
return "web"
return "other"
latest_session: dict[str, dict] = {}
devices: dict[str, str] = {} # device id -> platform
for session in sessions:
client = session.get("Client")
device = session.get("DeviceName")
device_id = session.get("DeviceId") or f"{device}::{client}"
if device_id:
devices[device_id] = platform_of(client, device)
user_id = session.get("UserId")
if not user_id:
continue
last = session.get("LastActivityDate") or ""
existing = latest_session.get(user_id)
if existing is None or last > existing["last"]:
latest_session[user_id] = {
"last": last,
"ip": clean_ip(session.get("RemoteEndPoint")),
"device": device,
"client": client,
}
rows = []
for user in users if isinstance(users, list) else []:
user_id = user.get("Id")
session = latest_session.get(user_id, {})
rows.append({
"id": user_id,
"name": user.get("Name", ""),
"last_login": user.get("LastLoginDate"),
"last_activity": user.get("LastActivityDate"),
"ip": session.get("ip"),
"device": session.get("device"),
"client": session.get("client"),
})
rows.sort(key=lambda r: (r["last_activity"] or r["last_login"] or ""), reverse=True)
platform_counts = {"android": 0, "ios": 0, "web": 0, "other": 0}
for platform in devices.values():
platform_counts[platform] += 1
device_count = len(devices)
def pct(value: int) -> int:
return round(value / device_count * 100) if device_count else 0
summary = {
"user_count": len(rows),
"device_count": device_count,
"platforms": platform_counts,
"platform_pct": {key: pct(value) for key, value in platform_counts.items()},
}
return {"users": rows, "summary": summary}
# ── Settings ─────────────────────────────────────────────────────────────────
@app.get("/api/settings")
async def get_settings():
return settings_service.load()
@app.post("/api/settings")
async def update_settings(request: Request):
try:
body = await request.json()
except Exception:
body = {}
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="Settings payload must be an object.")
values = settings_service.save(body)
apply_settings(values)
navidrome_status = await navidrome_service.ping(get_http_client())
return {
"settings": values,
"navidrome": navidrome_status,
"music_available": music_service.MUSIC_ROOT.exists(),
}
# ── Navidrome (Subsonic API) ─────────────────────────────────────────────────
def _handle_navidrome_error(exc: NavidromeError) -> HTTPException:
return HTTPException(status_code=exc.status, detail=exc.message)
@app.get("/api/navidrome/status")
async def navidrome_status():
return await navidrome_service.ping(get_http_client())
@app.get("/api/navidrome/artists")
async def navidrome_artists():
try:
return {"items": await navidrome_service.get_artists(get_http_client())}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/albums")
async def navidrome_albums(
q: str = Query(""),
type: str = Query("alphabeticalByName"),
size: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
):
client = get_http_client()
try:
if q.strip():
items = await navidrome_service.search_albums(client, q.strip(), count=size)
else:
items = await navidrome_service.get_albums(client, list_type=type, size=size, offset=offset)
return {"items": items, "offset": offset, "size": size, "has_more": len(items) >= size}
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache: dict = {"data": None, "expires": 0.0}
NAVIDROME_FORMATS_TTL = 1800
@app.get("/api/navidrome/formats")
async def navidrome_formats(refresh: bool = Query(False)):
now = time.time()
cached = _navidrome_formats_cache
if not refresh and cached["data"] is not None and cached["expires"] > now:
return cached["data"]
try:
data = await navidrome_service.get_format_breakdown(get_http_client())
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
_navidrome_formats_cache.update(data=data, expires=now + NAVIDROME_FORMATS_TTL)
return data
@app.get("/api/navidrome/album/{album_id}")
async def navidrome_album(album_id: str):
try:
return await navidrome_service.get_album(get_http_client(), album_id)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
@app.get("/api/navidrome/cover/{cover_id}")
async def navidrome_cover(cover_id: str, size: int = Query(0, ge=0, le=1500)):
try:
image_bytes, content_type = await navidrome_service.get_cover_art(
get_http_client(), cover_id, size or None
)
except NavidromeError as exc:
raise _handle_navidrome_error(exc) from exc
return Response(content=image_bytes, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"})
# ── Music library maintenance (music-covers) ─────────────────────────────────
@app.get("/api/music/scan")
async def music_scan():
return await asyncio.to_thread(music_service.scan_library)
# ── Music Collection Completeness ────────────────────────────────────────────
@app.post("/api/music-collection/scan")
async def music_collection_scan():
return library_service.start_scan_job()
@app.post("/api/music-collection/refresh-metadata")
async def music_collection_refresh_metadata():
return library_service.start_metadata_job()
@app.get("/api/music-collection/status")
async def music_collection_status():
return await asyncio.to_thread(library_service.get_status)
@app.get("/api/music-collection/overview")
async def music_collection_overview():
return await asyncio.to_thread(library_service.get_overview)
@app.get("/api/music-collection/artists")
async def music_collection_artists(q: str = Query("")):
return {"artists": await asyncio.to_thread(library_service.get_artists_completeness, q)}
@app.get("/api/music-collection/artist/{artist_id}/albums")
async def music_collection_artist_albums(artist_id: int):
return await asyncio.to_thread(library_service.get_artist_albums, artist_id)
@app.post("/api/music-collection/album/{completeness_id}/decision")
async def music_collection_decision(completeness_id: int, request: Request):
try:
body = await request.json()
except Exception:
body = {}
action = (body or {}).get("action", "")
try:
return await asyncio.to_thread(library_service.set_album_decision, completeness_id, action)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@app.post("/api/music/process")
async def music_process(request: Request):
try:
body = await request.json()
except Exception:
body = {}
options = ProcessOptions.from_dict(body if isinstance(body, dict) else {})
album_paths = body.get("album_paths") if isinstance(body, dict) else None
result = await asyncio.to_thread(
music_service.process_library, options, album_paths=album_paths
)
return result
# ── User Favourites ──────────────────────────────────────────────────────────
class EmbyClientAdapter:
"""Adapts the module-level Emby helpers to the services' client protocol.
Write operations carry the ``X-Emby-Token`` header (as the image write paths
do) and surface Emby errors as HTTPExceptions via ``ensure_emby_success``.
"""
async def get(self, path: str, params: dict | None = None):
return await emby_get(path, params)
async def get_all(self, path: str, params: dict | None = None):
return await emby_get_all(path, params)
async def post(self, path: str, params: dict | None = None, **kwargs):
resp = await emby_request("POST", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
return ensure_emby_success(resp, context=f"Emby POST {path}")
async def delete(self, path: str, params: dict | None = None, **kwargs):
resp = await emby_request("DELETE", path, params=params, headers={"X-Emby-Token": EMBY_API_KEY}, **kwargs)
return ensure_emby_success(resp, context=f"Emby DELETE {path}")
emby_client_adapter = EmbyClientAdapter()
async def _favorites_body(request: Request) -> dict:
try:
body = await request.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
@app.get("/api/favorites/users")
async def favorites_users():
return {"users": await favorites_service.list_favorites_users(emby_client_adapter)}
@app.get("/api/favorites/collections")
async def favorites_collections_overview():
return await favorites_service.list_collections_overview(emby_client_adapter)
@app.get("/api/favorites/collection/{collection_id}")
async def favorites_collection_items(collection_id: str, user_id: str = Query(...)):
try:
return await favorites_service.get_collection_items_view(emby_client_adapter, collection_id, user_id)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@app.post("/api/favorites/collection/{collection_id}/cleanup")
async def favorites_cleanup(collection_id: str, request: Request):
body = await _favorites_body(request)
user_id = body.get("userId", "")
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
try:
return await favorites_service.cleanup_watched(
emby_client_adapter, collection_id, user_id, dry_run=dry_run
)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
@app.post("/api/favorites/collection/{collection_id}/regenerate")
async def favorites_regenerate(collection_id: str, request: Request):
body = await _favorites_body(request)
user_id = body.get("userId", "")
dry_run = bool(body.get("dryRun", True)) # dry-run is the default
try:
target_size = int(body.get("targetSize", DEFAULT_TARGET_SIZE))
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="targetSize must be an integer.") from exc
try:
return await favorites_service.regenerate(
emby_client_adapter, collection_id, user_id, dry_run=dry_run, target_size=target_size
)
except FavoritesError as exc:
raise HTTPException(status_code=exc.status, detail=exc.message) from exc
# ── React SPA (must stay last so /api routes win) ────────────────────────────
@app.get("/", response_class=HTMLResponse)
@app.get("/{full_path:path}", response_class=HTMLResponse)
async def serve_spa(full_path: str = ""):
"""Serve the built React app, falling back to index.html for client routes."""
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not found")
index_file = FRONTEND_DIST / "index.html"
if not index_file.exists():
return HTMLResponse(
"<h1>HomelabToolkit</h1><p>Frontend not built. Run "
"<code>npm install &amp;&amp; npm run build</code> in <code>frontend/</code>.</p>",
status_code=200,
)
# Serve real files (favicon, etc.) directly when they exist.
if full_path:
candidate = FRONTEND_DIST / full_path
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index_file)
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8500) uvicorn.run(app, host="0.0.0.0", port=8500)
+22 -6
View File
@@ -21,7 +21,7 @@ param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$NasUser, [string]$NasUser,
[string]$RemoteAppDir = "/share/Docker/embycovers" [string]$RemoteAppDir = "/share/Docker/homelabtoolkit"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -38,11 +38,11 @@ Require-Command scp
$LocalDir = Split-Path -Parent $MyInvocation.MyCommand.Path $LocalDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Remote = "$NasUser@$NasHost" $Remote = "$NasUser@$NasHost"
Write-Host "Deploying embycovers to ${Remote}:$RemoteAppDir" Write-Host "Deploying HomelabToolkit to ${Remote}:$RemoteAppDir"
ssh $Remote @" ssh $Remote @"
set -e 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. # Copy top-level project files.
@@ -53,9 +53,25 @@ scp `
"$LocalDir/requirements.txt" ` "$LocalDir/requirements.txt" `
"${Remote}:$RemoteAppDir/" "${Remote}:$RemoteAppDir/"
# Copy templates and static assets recursively. # Copy static assets (studio logos, served at /static).
scp -r "$LocalDir/templates/." "${Remote}:$RemoteAppDir/templates/" scp -r "$LocalDir/static/." "${Remote}:$RemoteAppDir/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. # Copy any loose logo images that live at the repo root.
$LogoFiles = Get-ChildItem -Path $LocalDir -File | $LogoFiles = Get-ChildItem -Path $LocalDir -File |
+13 -4
View File
@@ -1,21 +1,30 @@
services: services:
embycovers: homelabtoolkit:
build: . build: .
container_name: embytoolkit container_name: homelabtoolkit
ports: ports:
- "8500:8500" - "8500:8500"
environment: environment:
- TZ=Pacific/Auckland - TZ=Pacific/Auckland
# Emby
- EMBY_URL=http://10.0.0.2:8096 - EMBY_URL=http://10.0.0.2:8096
- EMBY_API_KEY=b9af54b630f6448289ab96422add567a - 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: # Optional external artwork providers:
# - TMDB_BEARER_TOKEN= # - TMDB_BEARER_TOKEN=
# - TMDB_API_KEY= # - TMDB_API_KEY=
# - GOOGLE_CUSTOM_SEARCH_API_KEY= # - GOOGLE_CUSTOM_SEARCH_API_KEY=
# - GOOGLE_CUSTOM_SEARCH_ENGINE_ID= # - GOOGLE_CUSTOM_SEARCH_ENGINE_ID=
volumes: volumes:
- /share/Docker/embytoolkit/output:/app/output - /share/Docker/homelabtoolkit/output:/app/output
- /share/Docker/embytoolkit/cache:/app/cache - /share/Docker/homelabtoolkit/cache:/app/cache
# Mount your music library so the Cover Manager can scan/maintain it:
- /share/Music:/music
restart: unless-stopped restart: unless-stopped
networks: networks:
- npm_network - npm_network
+9
View File
@@ -0,0 +1,9 @@
{
"folders": [
{
"name": "embycovers",
"path": "."
}
],
"settings": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HomelabToolkit</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2336d6e0'/%3E%3Cpath d='M9 22V10h3v4.5h4V10h3v12h-3v-4.5h-4V22z' fill='%2304181b'/%3E%3C/svg%3E" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1774
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+80
View File
@@ -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<string, [string, string]> = {
"/": ["", "Dashboard"],
"/emby/generator": ["Emby", "Thumbnail Generator"],
"/emby/collections": ["Emby", "Collection Art"],
"/emby/airing": ["Emby", "Airing & New Seasons"],
"/emby/bulk-assign": ["Emby", "Bulk Assign"],
"/emby/favorites": ["Emby", "User Favorites"],
"/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<AppConfig | null>(null);
const [navidromeConnected, setNavidromeConnected] = useState(false);
const location = useLocation();
function refreshConfig() {
apiGet<AppConfig>("/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 (
<div className="app">
<Sidebar config={config} navidromeConnected={navidromeConnected} />
<div className="main">
<header className="topbar">
<div className="crumbs">
{section && (
<>
<b>{section}</b> &nbsp;/&nbsp;{" "}
</>
)}
{page}
</div>
<div className="topbar-spacer" />
</header>
<div className="content">
<Routes>
<Route path="/" element={<Dashboard config={config} navidromeConnected={navidromeConnected} />} />
<Route path="/emby/generator" element={<Generator />} />
<Route path="/emby/collections" element={<Collections />} />
<Route path="/emby/airing" element={<Airing />} />
<Route path="/emby/bulk-assign" element={<BulkAssign />} />
<Route path="/emby/favorites" element={<Favorites />} />
<Route path="/navidrome/library" element={<Library />} />
<Route path="/navidrome/covers" element={<CoverManager />} />
<Route path="/collection-completeness" element={<CollectionCompleteness />} />
<Route path="/settings" element={<Settings onSaved={refreshConfig} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
</div>
</div>
);
}
+79
View File
@@ -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<string> {
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<T = any>(path: string): Promise<T> {
const res = await fetch(path);
if (!res.ok) throw new ApiError(await parseError(res), res.status);
return res.json();
}
export async function apiPost<T = any>(path: string, body?: unknown): Promise<T> {
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;
}
+143
View File
@@ -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: <IconEmby />,
links: [
{ to: "/emby/generator", label: "Thumbnail Generator", icon: <IconImage /> },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers /> },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar /> },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid /> },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart /> },
],
},
{
id: "navidrome",
label: "Navidrome",
icon: <IconMusic />,
links: [
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc /> },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand /> },
{ to: "/collection-completeness", label: "Collection Completeness", icon: <IconLayers /> },
],
},
{
id: "system",
label: "System",
icon: <IconSettings />,
links: [{ to: "/settings", label: "Settings", icon: <IconSettings /> }],
},
];
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 (
<div className="status-chip">
<span className={`dot ${cls}`} />
<span className="label">{label}</span>
<span className="dim" style={{ fontSize: 11 }}>
{text}
</span>
</div>
);
}
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<Record<string, boolean>>(() => (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 (
<nav className="nav">
<div className="nav-brand">
<div className="nav-logo">H</div>
<div className="nav-name">
HomelabToolkit
<small>media operations</small>
</div>
</div>
<div className="nav-scroll">
<div className="nav-group">
<NavLink to="/" end className={({ isActive }) => `nav-item nav-item-top ${isActive ? "active" : ""}`}>
<IconHome />
Dashboard
</NavLink>
</div>
{GROUPS.map((g) => {
const isOpen = !!open[g.id];
return (
<div className="nav-group" key={g.id}>
<button className="nav-group-head" onClick={() => toggle(g.id)} aria-expanded={isOpen}>
{g.icon}
<span className="grow" style={{ textAlign: "left" }}>
{g.label}
</span>
<IconChevron className={`nav-caret ${isOpen ? "open" : ""}`} />
</button>
<div className={`nav-group-panel ${isOpen ? "open" : ""}`}>
<div className="nav-group-inner">
{g.links.map((l) => (
<NavLink
key={l.to}
to={l.to}
className={({ isActive }) => `nav-item ${isActive ? "active" : ""}`}
>
{l.icon}
{l.label}
</NavLink>
))}
</div>
</div>
</div>
);
})}
</div>
<div className="nav-foot">
<Chip label="Emby" ok={!!config?.emby.connected} configured={!!config?.emby.connected} />
<Chip label="Navidrome" ok={navidromeConnected} configured={!!config?.navidrome.configured} />
<div className="nav-version">HomelabToolkit v1.0</div>
</div>
</nav>
);
}
+141
View File
@@ -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) => (
<svg {...base} {...p}>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<path d="M21 15l-5-5L5 21" />
</svg>
);
export const IconLayers = (p: P) => (
<svg {...base} {...p}>
<path d="M12 2l9 5-9 5-9-5 9-5z" />
<path d="M3 12l9 5 9-5M3 17l9 5 9-5" />
</svg>
);
export const IconCalendar = (p: P) => (
<svg {...base} {...p}>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
);
export const IconGrid = (p: P) => (
<svg {...base} {...p}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
</svg>
);
export const IconHeart = (p: P) => (
<svg {...base} {...p}>
<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z" />
</svg>
);
export const IconMusic = (p: P) => (
<svg {...base} {...p}>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
export const IconDisc = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="3" />
</svg>
);
export const IconWand = (p: P) => (
<svg {...base} {...p}>
<path d="M15 4V2M15 16v-2M8 9h2M20 9h2M17.8 11.8L19 13M15 9l-7 7-4 4" />
<path d="M17.8 6.2L19 5" />
</svg>
);
export const IconSearch = (p: P) => (
<svg {...base} {...p}>
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
export const IconHome = (p: P) => (
<svg {...base} {...p}>
<path d="M3 9.5L12 3l9 6.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />
</svg>
);
export const IconServer = (p: P) => (
<svg {...base} {...p}>
<rect x="3" y="3" width="18" height="7" rx="2" />
<rect x="3" y="14" width="18" height="7" rx="2" />
<path d="M7 6.5h.01M7 17.5h.01" />
</svg>
);
export const IconRefresh = (p: P) => (
<svg {...base} {...p}>
<path d="M21 12a9 9 0 1 1-3-6.7L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
export const IconCheck = (p: P) => (
<svg {...base} {...p}>
<path d="M20 6L9 17l-5-5" />
</svg>
);
export const IconUpload = (p: P) => (
<svg {...base} {...p}>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M17 8l-5-5-5 5M12 3v12" />
</svg>
);
export const IconTrash = (p: P) => (
<svg {...base} {...p}>
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
</svg>
);
export const IconPlay = (p: P) => (
<svg {...base} {...p}>
<path d="M6 4l14 8-14 8V4z" />
</svg>
);
export const IconUser = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a6 6 0 0 1 6-6h4a6 6 0 0 1 6 6v1" />
</svg>
);
export const IconChevron = (p: P) => (
<svg {...base} {...p}>
<path d="M9 18l6-6-6-6" />
</svg>
);
export const IconFolder = (p: P) => (
<svg {...base} {...p}>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
);
export const IconSettings = (p: P) => (
<svg {...base} {...p}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
// 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) => (
<svg viewBox="0 0 24 24" fill="none" width={18} height={18} className={p.className}>
<rect x="3" y="3" width="18" height="18" rx="5.5" stroke="currentColor" strokeWidth={2} />
<path d="M10 8.3l5.4 3.7-5.4 3.7z" fill="currentColor" />
</svg>
);
+119
View File
@@ -0,0 +1,119 @@
import { ReactNode } from "react";
export function PageHead({ title, icon, children }: { title: string; icon?: ReactNode; children?: ReactNode }) {
return (
<div className="page-head">
<div className="page-head-row">
{icon && <span className="page-head-icon">{icon}</span>}
<h1>{title}</h1>
</div>
{children && <p>{children}</p>}
</div>
);
}
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 (
<span
className="avatar"
style={{ background: `linear-gradient(155deg, hsl(${hash} 70% 58%), hsl(${(hash + 40) % 360} 65% 45%))` }}
>
{initials}
</span>
);
}
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 (
<div className="stat-card">
<div className="stat-icon">{icon}</div>
<div className="stat-meta">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
export function Empty({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
return (
<div className="empty">
{icon}
<div>{children}</div>
</div>
);
}
export function Loading({ label }: { label?: string }) {
return (
<div className="center-load col" style={{ alignItems: "center", gap: 12 }}>
<span className="spinner lg" />
{label && <span className="dim">{label}</span>}
</div>
);
}
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")}`;
}
+37
View File
@@ -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<Toast[]>([]);
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 (
<ToastContext.Provider value={push}>
{children}
<div className="toast-wrap">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.kind === "info" ? "" : t.kind}`}>
{t.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}
+16
View File
@@ -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(
<React.StrictMode>
<BrowserRouter>
<ToastProvider>
<App />
</ToastProvider>
</BrowserRouter>
</React.StrictMode>
);
+411
View File
@@ -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<string, string> = {
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: <IconImage />, cat: "Emby" },
{ to: "/emby/collections", label: "Collection Art", icon: <IconLayers />, cat: "Emby" },
{ to: "/emby/airing", label: "Airing & New Seasons", icon: <IconCalendar />, cat: "Emby" },
{ to: "/emby/bulk-assign", label: "Bulk Assign", icon: <IconGrid />, cat: "Emby" },
{ to: "/emby/favorites", label: "User Favorites", icon: <IconHeart />, cat: "Emby" },
{ to: "/navidrome/library", label: "Music Library", icon: <IconDisc />, cat: "Navidrome" },
{ to: "/navidrome/covers", label: "Cover Manager", icon: <IconWand />, cat: "Navidrome" },
];
function MiniStat({ icon, value, label }: { icon: ReactNode; value: ReactNode; label: string }) {
return (
<div className="mini-stat">
<div className="stat-icon">{icon}</div>
<div className="stat-meta">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
</div>
);
}
function StatusBadge({ configured, connected }: { configured: boolean; connected: boolean }) {
if (!configured) return <span className="badge">not configured</span>;
return <span className={`badge ${connected ? "badge-ok" : "badge-bad"}`}>{connected ? "connected" : "offline"}</span>;
}
export default function Dashboard({ config, navidromeConnected }: Props) {
const toast = useToast();
const [data, setData] = useState<DashboardData | null>(null);
const [activity, setActivity] = useState<UserActivity[] | null>(null);
const [activitySummary, setActivitySummary] = useState<ActivitySummary | null>(null);
const [formats, setFormats] = useState<FormatData | null>(null);
const [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<DashboardData>("/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<FormatData>("/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 (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Dashboard" icon={<IconHome />}>
A live overview of your media stack Emby library health on the left, your Navidrome music collection on the
right.
</PageHead>
<button className="btn btn-sm" onClick={load} disabled={loading}>
{loading ? <span className="spinner" /> : <IconRefresh />} Refresh
</button>
</div>
{loading && !data ? (
<Loading label="Gathering library stats…" />
) : (
<>
<div className="dash-split">
{/* ── Emby column ── */}
<div className="panel">
<div className="panel-head">
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
<IconEmby />
</div>
<h3 className="grow">Emby</h3>
<StatusBadge configured={!!e?.connected} connected={!!e?.connected} />
<button className="btn btn-sm" onClick={refreshEmby} disabled={embyScanning || !e?.connected}>
{embyScanning ? <span className="spinner" /> : <IconRefresh />} Scan libraries
</button>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
<div className="mini-grid">
<MiniStat icon={<IconPlay />} value={fmtNumber(e?.movies)} label="Movies" />
<MiniStat icon={<IconGrid />} value={fmtNumber(e?.series)} label="Series" />
<MiniStat icon={<IconLayers />} value={fmtNumber(e?.episodes)} label="Episodes" />
<MiniStat icon={<IconLayers />} value={fmtNumber(e?.collections)} label="Collections" />
<MiniStat icon={<IconUser />} value={fmtNumber(e?.users)} label="Users" />
<MiniStat icon={<IconHeart />} value={fmtNumber(e?.favorites_collections)} label="Favorites" />
</div>
<div className="mini-stat">
<div className="stat-icon">
<IconCalendar />
</div>
<div className="stat-meta">
<div className="stat-value" style={{ fontSize: 17 }}>
{timeAgo(e?.last_added)}
</div>
<div className="stat-label">Last item added · {e?.url}</div>
</div>
</div>
</div>
</div>
{/* ── Navidrome column ── */}
<div className="panel">
<div className="panel-head">
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
<IconMusic />
</div>
<h3 className="grow">Navidrome</h3>
<StatusBadge configured={!!n?.configured} connected={!!n?.connected} />
<button className="btn btn-sm" onClick={scanNavidrome} disabled={navScanning || !n?.connected}>
{navScanning ? <span className="spinner" /> : <IconRefresh />} Scan library
</button>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{!n?.connected ? (
<Empty icon={<IconMusic />}>
{n?.configured
? "Navidrome is configured but offline."
: "Navidrome is not configured. Add your server details in Settings."}
</Empty>
) : (
<>
<div className="mini-grid">
<MiniStat icon={<IconUser />} value={fmtNumber(n?.artist_count)} label="Artists" />
<MiniStat icon={<IconDisc />} value={fmtNumber(n?.album_count)} label="Albums" />
<MiniStat icon={<IconMusic />} value={fmtNumber(n?.song_count)} label="Tracks" />
<MiniStat icon={<IconLayers />} value={fmtNumber(n?.genre_count)} label="Genres" />
</div>
<div>
<div className="section-label" style={{ margin: "4px 0 8px" }}>
Audio formats
</div>
{formatsLoading && !formats ? (
<p className="hint row gap-sm">
<span className="spinner" /> Analyzing track formats
</p>
) : !formats || formats.total === 0 ? (
<p className="hint">No format data.</p>
) : (
<>
<div className="stack-bar">
{formats.formats.map((f) => (
<span
key={f.format}
className="stack-seg"
title={`${f.format}: ${f.count}`}
style={{ width: `${(f.count / formats.total) * 100}%`, background: formatColor(f.format) }}
/>
))}
</div>
<div className="legend">
{formats.formats.map((f) => (
<div className="legend-row" key={f.format}>
<span className="legend-dot" style={{ background: formatColor(f.format) }} />
<span className="legend-name">{f.format}</span>
<span className="legend-count">
{fmtNumber(f.count)} · {Math.round((f.count / formats.total) * 100)}%
</span>
</div>
))}
</div>
</>
)}
</div>
<div>
<div className="section-label" style={{ margin: "4px 0 8px" }}>
Top genres
</div>
{genres.length === 0 ? (
<p className="hint">No genre data.</p>
) : (
genres.map((g) => (
<div className="genre-row" key={g.name}>
<span className="genre-name">{g.name}</span>
<span className="genre-bar">
<span className="genre-bar-fill" style={{ width: `${(g.song_count / maxGenre) * 100}%` }} />
</span>
<span className="genre-count">{fmtNumber(g.song_count)}</span>
</div>
))
)}
</div>
</>
)}
</div>
</div>
</div>
{/* ── User activity (full width) ── */}
<div className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<IconUser className="dim" />
<h3 className="grow">User Activity</h3>
{activitySummary && (
<div className="row wrap gap-sm" style={{ justifyContent: "flex-end" }}>
<span className="badge">{activitySummary.user_count} users</span>
<span className="badge">{activitySummary.device_count} devices</span>
{activitySummary.platforms.android > 0 && (
<span className="badge badge-ok">Android {activitySummary.platform_pct.android}%</span>
)}
{activitySummary.platforms.ios > 0 && (
<span className="badge badge-accent">iOS {activitySummary.platform_pct.ios}%</span>
)}
{activitySummary.platforms.web > 0 && (
<span className="badge badge-warn">Web {activitySummary.platform_pct.web}%</span>
)}
{activitySummary.platforms.other > 0 && (
<span className="badge">Other {activitySummary.platform_pct.other}%</span>
)}
</div>
)}
</div>
{!activity ? (
<div className="panel-body">
<Loading />
</div>
) : activity.length === 0 ? (
<Empty icon={<IconUser />}>No Emby users found.</Empty>
) : (
<div style={{ overflowX: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>User</th>
<th>Last login (NZ)</th>
<th>When</th>
<th>IP address</th>
<th>Device</th>
</tr>
</thead>
<tbody>
{activity.map((u) => {
const when = u.last_activity || u.last_login;
return (
<tr key={u.id}>
<td>
<div className="row gap-sm">
<Avatar name={u.name} />
<span className="cell-strong">{u.name}</span>
</div>
</td>
<td className="mono">{formatNZ(u.last_login)}</td>
<td className="cell-sub">{when ? timeAgo(when) : "Never"}</td>
<td className="mono">{u.ip || <span className="dim"></span>}</td>
<td className="cell-sub">
{u.device || "—"}
{u.client ? ` · ${u.client}` : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* ── Quick actions ── */}
<div className="section-label">Tools</div>
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(230px, 1fr))" }}>
{tools.map((t) => (
<Link key={t.to} to={t.to} className="panel tool-card">
<div className="stat-icon" style={{ width: 32, height: 32 }}>
{t.icon}
</div>
<div className="grow">
<div className="media-title">{t.label}</div>
<div className="dim" style={{ fontSize: 11 }}>
{t.cat}
</div>
</div>
<IconChevron className="dim" />
</Link>
))}
</div>
</>
)}
</>
);
}
+117
View File
@@ -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<keyof SettingsValues, { label: string; secret?: boolean; placeholder?: string }> = {
emby_url: { label: "Emby URL", placeholder: "http://10.0.0.2:8096" },
emby_api_key: { label: "Emby API key", secret: true },
navidrome_url: { label: "Navidrome URL", placeholder: "http://10.0.0.2:4533" },
navidrome_user: { label: "Navidrome username" },
navidrome_password: { label: "Navidrome password", secret: true },
music_root: { label: "Music library path", placeholder: "/music" },
};
export default function Settings({ onSaved }: { onSaved?: () => void }) {
const toast = useToast();
const [values, setValues] = useState<SettingsValues | null>(null);
const [saving, setSaving] = useState(false);
const [reveal, setReveal] = useState(false);
useEffect(() => {
apiGet<SettingsValues>("/api/settings")
.then(setValues)
.catch((e) => toast(e.message, "err"));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
function set<K extends keyof SettingsValues>(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 <Loading />;
// Render inline (not as a nested component) so React keeps input identity
// stable across renders — otherwise each keystroke remounts and drops focus.
const groups: { title: string; icon: React.ReactNode; keys: (keyof SettingsValues)[] }[] = [
{ title: "Emby", icon: <IconEmby />, keys: ["emby_url", "emby_api_key"] },
{ title: "Navidrome", icon: <IconMusic />, keys: ["navidrome_url", "navidrome_user", "navidrome_password"] },
{ title: "Music library", icon: <IconFolder />, keys: ["music_root"] },
];
return (
<>
<PageHead title="Settings" icon={<IconSettings />}>
Configure connections without touching environment variables. Saved settings are written to the app's config
file and applied immediately — they override the deploy-time defaults.
</PageHead>
<div className="col" style={{ maxWidth: 640, gap: 16 }}>
{groups.map((g) => (
<div className="panel" key={g.title}>
<div className="panel-head">
<div className="stat-icon" style={{ width: 30, height: 30, borderRadius: 8 }}>
{g.icon}
</div>
<h3>{g.title}</h3>
</div>
<div className="panel-body col" style={{ gap: 14 }}>
{g.keys.map((k) => (
<div className="field" key={k}>
<label className="field-label">{LABELS[k].label}</label>
<input
className="input"
type={LABELS[k].secret && !reveal ? "password" : "text"}
autoComplete="off"
spellCheck={false}
placeholder={LABELS[k].placeholder}
value={values[k]}
onChange={(e) => set(k, e.target.value)}
/>
</div>
))}
</div>
</div>
))}
<div className="row between">
<label className="chip" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={reveal} onChange={(e) => setReveal(e.target.checked)} /> Show secrets
</label>
<button className="btn btn-primary" onClick={save} disabled={saving}>
{saving ? <span className="spinner" /> : <IconCheck />} Save settings
</button>
</div>
<p className="hint">
Secrets are stored in plaintext in the app's config file on the server. Use this on a trusted local network.
</p>
</div>
</>
);
}
+177
View File
@@ -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<Snapshot | null>(null);
const [loading, setLoading] = useState(true);
const [weekOffset, setWeekOffset] = useState(0);
const [eligibleOnly, setEligibleOnly] = useState(false);
const [busy, setBusy] = useState<Record<string, boolean>>({});
const [selected, setSelected] = useState<Set<string>>(new Set());
const load = useCallback(
(refresh = false) => {
setLoading(true);
apiGet<Snapshot>(`/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 (
<>
<PageHead title="Airing & New Seasons">
Series currently airing in your library. Eligible new-season premieres can be stamped with "New Season"
artwork in one click.
</PageHead>
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="row gap-sm wrap">
<div className="seg">
{WEEKS.map((w) => (
<button key={w.off} className={`seg-btn ${weekOffset === w.off ? "active" : ""}`} onClick={() => setWeekOffset(w.off)}>
{w.label}
</button>
))}
</div>
<button className={`chip ${eligibleOnly ? "active" : ""}`} onClick={() => setEligibleOnly((v) => !v)}>
Eligible only
</button>
</div>
<div className="row gap-sm">
{selected.size > 0 && (
<button className="btn btn-primary" onClick={applySelected}>
<IconCheck /> Apply {selected.size} selected
</button>
)}
<button className="btn" onClick={() => load(true)}>
<IconRefresh /> Refresh
</button>
</div>
</div>
{loading ? (
<Loading label="Loading airing snapshot…" />
) : !snap?.items.length ? (
<div className="panel">
<Empty icon={<IconCalendar />}>No airing series found for this week.</Empty>
</div>
) : (
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(230px, 1fr))" }}>
{snap.items.map((item) => (
<div key={item.id} className={`panel ${selected.has(item.id) ? "selected" : ""}`} style={{ overflow: "hidden" }}>
<div style={{ display: "flex", gap: 12, padding: 12 }}>
<img src={item.poster_url} className="result-poster" style={{ width: 56, height: 84 }} alt="" loading="lazy" />
<div className="grow" style={{ minWidth: 0 }}>
<div className="media-title">{item.name}</div>
<div className="row wrap" style={{ gap: 5, marginTop: 5 }}>
<span className={`badge ${item.status.toLowerCase() === "continuing" ? "badge-ok" : ""}`}>{item.status}</span>
{item.eligible_new_season && <span className="badge badge-accent">eligible</span>}
{!item.has_logo && <span className="badge badge-warn">no logo</span>}
</div>
<div className="hint" style={{ marginTop: 6 }}>
{item.selected_week_episode_label || item.next_episode_label || "—"}
{fmtDate(item.selected_week_air_at || item.next_air_at) && (
<div className="dim mono">{fmtDate(item.selected_week_air_at || item.next_air_at)}</div>
)}
</div>
</div>
</div>
<div className="row" style={{ padding: "0 12px 12px", gap: 8 }}>
<label className="chip" style={{ padding: "6px 10px" }}>
<input type="checkbox" checked={selected.has(item.id)} onChange={() => toggle(item.id)} /> Select
</label>
<button
className="btn btn-sm btn-primary grow"
disabled={!item.has_logo || !item.eligible_new_season || busy[item.id]}
onClick={() => applyOne(item)}
>
{busy[item.id] ? <span className="spinner" /> : <IconCheck />} New Season
</button>
</div>
</div>
))}
</div>
)}
</>
);
}
+198
View File
@@ -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<Item[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [selected, setSelected] = useState<Set<string>>(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 (
<>
<PageHead title="Bulk Assign">
Generate and push landscape thumbnails across many titles at once. Eligible titles need an Emby primary, logo
and backdrop.
</PageHead>
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
<div className="row gap-sm wrap">
<div className="seg">
<button className={`seg-btn ${kind === "series" ? "active" : ""}`} onClick={() => setKind("series")}>
Series
</button>
<button className={`seg-btn ${kind === "movies" ? "active" : ""}`} onClick={() => setKind("movies")}>
Movies
</button>
</div>
<form
className="search-inner"
style={{ width: 240 }}
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<IconSearch />
<input className="input" placeholder="Filter…" value={query} onChange={(e) => setQuery(e.target.value)} />
</form>
</div>
<div className="row gap-sm wrap">
<button className="btn" onClick={selectAllEligible}>
Select eligible
</button>
{selected.size > 0 && (
<button className="btn btn-primary" onClick={applySelected} disabled={working}>
{working ? <span className="spinner" /> : <IconCheck />} Apply {selected.size}
</button>
)}
<button className="btn btn-green" onClick={applyAll} disabled={working}>
Apply all eligible
</button>
</div>
</div>
<div className="hint" style={{ marginBottom: 14 }}>
{total} {kind} · {items.filter((i) => i.can_bulk_assign).length} eligible on this page
</div>
{loading ? (
<Loading />
) : !items.length ? (
<div className="panel">
<Empty icon={<IconGrid />}>No titles found.</Empty>
</div>
) : (
<div className="card-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))" }}>
{items.map((it) => (
<div
key={it.id}
className={`media-card ${selected.has(it.id) ? "selected" : ""}`}
onClick={() => 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 ? (
<img className="media-cover poster" src={it.poster_url} loading="lazy" alt="" />
) : (
<div className="media-cover poster" />
)}
<div className="media-body">
<div className="media-title">{it.name}</div>
<div className="row wrap" style={{ gap: 4, marginTop: 5 }}>
<span className={`badge ${it.has_logo ? "badge-ok" : "badge-bad"}`} style={{ padding: "1px 6px" }}>
logo
</span>
<span className={`badge ${it.has_backdrop ? "badge-ok" : "badge-bad"}`} style={{ padding: "1px 6px" }}>
bd
</span>
{selected.has(it.id) && <span className="badge badge-accent" style={{ padding: "1px 6px" }}></span>}
</div>
</div>
</div>
))}
</div>
)}
<div className="panel" style={{ marginTop: 24 }}>
<div className="panel-head">
<h3>Reset studio artwork</h3>
<span className="sub">Delete generated Thumb/Primary so Emby re-downloads originals</span>
</div>
<div className="panel-body row wrap" style={{ gap: 8 }}>
{STUDIOS.map((s) => (
<button key={s} className="btn btn-sm btn-danger" onClick={() => resetStudio(s)} disabled={working}>
<IconRefresh /> {s}
</button>
))}
</div>
</div>
</>
);
}
+229
View File
@@ -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<Collection[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Collection | null>(null);
const [opts, setOpts] = useState<Opts>(DEFAULTS);
const [preview, setPreview] = useState<string | null>(null);
const [rendering, setRendering] = useState(false);
const [applying, setApplying] = useState(false);
const debounceRef = useRef<number>();
const set = <K extends keyof Opts>(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 (
<>
<PageHead title="Collection Art">Generate cover artwork for your Emby collections with custom titling.</PageHead>
<div className="workbench">
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
<div style={{ padding: 14, borderBottom: "1px solid var(--border)" }}>
<form
className="search-inner"
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<IconSearch />
<input className="input" placeholder="Search collections…" value={query} onChange={(e) => setQuery(e.target.value)} />
</form>
</div>
<div style={{ overflowY: "auto", padding: 8 }}>
{loading ? (
<Loading />
) : !list.length ? (
<Empty icon={<IconLayers />}>No collections found.</Empty>
) : (
list.map((c) => (
<div key={c.id} className={`result-item ${selected?.id === c.id ? "active" : ""}`} onClick={() => select(c)}>
{c.poster_url ? <img className="result-poster" src={c.poster_url} loading="lazy" alt="" /> : <div className="result-poster" />}
<div className="grow">
<div className="result-name">{c.name}</div>
<div className="result-sub">{c.child_count} items</div>
</div>
</div>
))
)}
</div>
</div>
<div className="preview-shell">
{!selected ? (
<Empty icon={<IconLayers />}>Select a collection to design its artwork.</Empty>
) : (
<>
<div className="preview-frame" style={{ aspectRatio: opts.target_type === "Primary" ? "2 / 3" : "16 / 9", maxWidth: opts.target_type === "Primary" ? 360 : "100%" }}>
{preview ? <img src={preview} alt="preview" /> : <Loading />}
</div>
<div className="row" style={{ width: "100%" }}>
<button className="btn grow" onClick={generate} disabled={rendering}>
<IconWand /> Regenerate
</button>
<button className="btn btn-primary grow" onClick={apply} disabled={applying || !preview}>
{applying ? <span className="spinner" /> : <IconCheck />} Apply
</button>
</div>
</>
)}
</div>
<div className="panel scroll-col" style={{ maxHeight: "calc(100vh - 200px)" }}>
<div className="panel-head">
<h3>Controls</h3>
</div>
<div className="panel-body col" style={{ gap: 16 }}>
{!selected ? (
<p className="hint">Pick a collection first.</p>
) : (
<>
<div className="field">
<label className="field-label">Target image</label>
<div className="seg">
<button className={`seg-btn ${opts.target_type === "Thumb" ? "active" : ""}`} onClick={() => set("target_type", "Thumb")}>
Thumb
</button>
<button className={`seg-btn ${opts.target_type === "Primary" ? "active" : ""}`} onClick={() => set("target_type", "Primary")}>
Primary
</button>
</div>
</div>
<div className="field">
<label className="field-label">Title text</label>
<input className="input" value={opts.text} onChange={(e) => set("text", e.target.value)} />
</div>
<div className="field">
<label className="field-label">Text align</label>
<div className="seg">
{["left", "center", "right"].map((a) => (
<button key={a} className={`seg-btn ${opts.text_align === a ? "active" : ""}`} onClick={() => set("text_align", a)}>
{a}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">Text position</label>
<div className="seg">
{["top", "center", "bottom"].map((p) => (
<button key={p} className={`seg-btn ${opts.text_position === p ? "active" : ""}`} onClick={() => set("text_position", p)}>
{p}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">
Text scale <span className="mono">{opts.text_scale.toFixed(2)}×</span>
</label>
<input type="range" min={0.65} max={1.8} step={0.05} value={opts.text_scale} onChange={(e) => set("text_scale", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">
Darkness <span className="mono">{Math.round(opts.darkness * 100)}%</span>
</label>
<input type="range" min={0} max={0.85} step={0.05} value={opts.darkness} onChange={(e) => set("darkness", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">Text color</label>
<div className="row">
<input type="color" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
<input className="input grow" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
</div>
</div>
</>
)}
</div>
</div>
</div>
</>
);
}
+217
View File
@@ -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<Collection[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [collectionId, setCollectionId] = useState("");
const [userId, setUserId] = useState("");
const [view, setView] = useState<View | null>(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<View>(`/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 <Loading />;
return (
<>
<PageHead title="User Favorites">
Browse any Emby collection with per-user watched status, prune watched items, and top up with personalized
recommendations. Both actions default to a safe dry run.
</PageHead>
<div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-body row wrap" style={{ gap: 14 }}>
<div className="field grow" style={{ minWidth: 220 }}>
<label className="field-label">Collection</label>
<select className="select" value={collectionId} onChange={(e) => setCollectionId(e.target.value)}>
<option value="">Select a collection</option>
{collections.map((c) => (
<option key={c.collection_id} value={c.collection_id}>
{c.collection_name} {c.is_favorites ? "★" : ""} ({c.item_count ?? "?"})
</option>
))}
</select>
</div>
<div className="field grow" style={{ minWidth: 200 }}>
<label className="field-label">User (for watched status)</label>
<select className="select" value={userId} onChange={(e) => setUserId(e.target.value)}>
<option value="">Select a user</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</div>
</div>
</div>
{viewLoading ? (
<Loading />
) : !view ? (
<div className="panel">
<Empty icon={<IconHeart />}>Pick a collection and user to inspect favorites.</Empty>
</div>
) : (
<>
<div className="row between wrap" style={{ marginBottom: 16, gap: 12 }}>
<div className="row gap-sm wrap">
<span className="badge">{view.summary.current_count} items</span>
<span className="badge badge-ok">{view.summary.watched_count} watched</span>
<span className="badge badge-accent">{view.summary.unwatched_count} unwatched</span>
</div>
<div className="row gap-sm wrap">
<button className="btn btn-sm" onClick={() => cleanup(true)} disabled={busy}>
Preview cleanup
</button>
<button className="btn btn-sm btn-danger" onClick={() => cleanup(false)} disabled={busy}>
<IconTrash /> Remove watched
</button>
<div className="row gap-sm" style={{ marginLeft: 8 }}>
<span className="hint">Target</span>
<input
className="input"
style={{ width: 64 }}
type="number"
min={0}
value={targetSize}
onChange={(e) => setTargetSize(+e.target.value)}
/>
<button className="btn btn-sm" onClick={() => regenerate(true)} disabled={busy}>
Preview recs
</button>
<button className="btn btn-sm btn-green" onClick={() => regenerate(false)} disabled={busy}>
<IconRefresh /> Add recs
</button>
</div>
</div>
</div>
<div className="panel">
<table className="data-table">
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Year</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{view.items.map((i) => (
<tr key={i.id}>
<td className="cell-strong">{i.title}</td>
<td>{i.type}</td>
<td className="mono">{i.year || "—"}</td>
<td>
{i.watched ? <span className="badge badge-ok">watched</span> : <span className="badge">unwatched</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
);
}
+343
View File
@@ -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<SearchItem[]>([]);
const [searching, setSearching] = useState(false);
const [selected, setSelected] = useState<SearchItem | null>(null);
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
const [opts, setOpts] = useState<Options>(DEFAULTS);
const [preview, setPreview] = useState<string | null>(null);
const [rendering, setRendering] = useState(false);
const [applying, setApplying] = useState(false);
const debounceRef = useRef<number>();
const set = <K extends keyof Options>(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<ImageInfo>(`/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 (
<>
<PageHead title="Thumbnail Generator">
Composite a landscape thumbnail from an item's poster, logo and backdrop, then push it back to Emby.
</PageHead>
<div className="workbench">
{/* search column */}
<div className="panel" style={{ display: "flex", flexDirection: "column", maxHeight: "calc(100vh - 200px)" }}>
<div style={{ padding: 14, borderBottom: "1px solid var(--border)" }}>
<form className="search-inner" onSubmit={search}>
<IconSearch />
<input className="input" placeholder="Search movies & series…" value={query} onChange={(e) => setQuery(e.target.value)} />
</form>
</div>
<div style={{ overflowY: "auto", padding: 8 }}>
{searching ? (
<Loading />
) : results.length === 0 ? (
<Empty icon={<IconSearch />}>Search your Emby library to begin.</Empty>
) : (
results.map((r) => (
<div key={r.id} className={`result-item ${selected?.id === r.id ? "active" : ""}`} onClick={() => select(r)}>
<img className="result-poster" src={r.poster_url} loading="lazy" alt="" />
<div className="grow">
<div className="result-name">{r.name}</div>
<div className="result-sub">
<span>{r.year || "—"}</span>
<span>·</span>
<span>{r.type}</span>
{r.has_logo && <span className="badge badge-ok">logo</span>}
</div>
</div>
</div>
))
)}
</div>
</div>
{/* preview column */}
<div className="preview-shell">
{!selected ? (
<Empty icon={<IconImage />}>Select an item to preview a thumbnail.</Empty>
) : (
<>
<div className="preview-frame" style={{ aspectRatio: "16 / 9", position: "relative" }}>
{preview ? <img src={preview} alt="preview" /> : <Loading />}
{rendering && preview && (
<div style={{ position: "absolute", top: 10, right: 10 }}>
<span className="spinner" />
</div>
)}
</div>
<div className="row" style={{ width: "100%" }}>
<button className="btn grow" onClick={generate} disabled={rendering}>
<IconWand /> Regenerate
</button>
<button className="btn btn-primary grow" onClick={apply} disabled={applying || !preview}>
{applying ? <span className="spinner" /> : <IconCheck />} Apply to Emby
</button>
</div>
</>
)}
</div>
{/* controls column */}
<div className="panel scroll-col" style={{ maxHeight: "calc(100vh - 200px)" }}>
<div className="panel-head">
<h3>Controls</h3>
</div>
<div className="panel-body col" style={{ gap: 16 }}>
{!selected ? (
<p className="hint">Pick an item first.</p>
) : (
<>
<div className="field">
<label className="field-label">Title</label>
<input className="input" value={opts.title} onChange={(e) => set("title", e.target.value)} />
</div>
<div className="field">
<label className="field-label">Background</label>
<div className="seg">
<button className={`seg-btn ${opts.bg_mode === "backdrop" ? "active" : ""}`} onClick={() => set("bg_mode", "backdrop")}>
Backdrop
</button>
<button className={`seg-btn ${opts.bg_mode === "upload" ? "active" : ""}`} onClick={() => opts.upload_bg_id && set("bg_mode", "upload")} disabled={!opts.upload_bg_id}>
Upload
</button>
</div>
<label className="btn btn-sm" style={{ marginTop: 4 }}>
<IconUpload /> Upload background
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && onUpload(e.target.files[0])} />
</label>
</div>
{opts.bg_mode === "backdrop" && imageInfo && imageInfo.backdrop_count > 1 && (
<div className="field">
<label className="field-label">
Backdrop <span>{opts.backdrop_index + 1}/{imageInfo.backdrop_count}</span>
</label>
<input
type="range"
min={0}
max={imageInfo.backdrop_count - 1}
value={opts.backdrop_index}
onChange={(e) => set("backdrop_index", +e.target.value)}
/>
</div>
)}
{imageInfo && imageInfo.logo_count > 1 && (
<div className="field">
<label className="field-label">
Logo <span>{opts.logo_index + 1}/{imageInfo.logo_count}</span>
</label>
<input type="range" min={0} max={imageInfo.logo_count - 1} value={opts.logo_index} onChange={(e) => set("logo_index", +e.target.value)} />
</div>
)}
<div className="field">
<label className="field-label">Logo position</label>
<div className="seg">
{ALIGNS.map((a) => (
<button key={a} className={`seg-btn ${opts.logo_align === a ? "active" : ""}`} onClick={() => set("logo_align", a)}>
{a.replace("-", " ")}
</button>
))}
</div>
</div>
<div className="field">
<label className="field-label">
Logo scale <span className="mono">{opts.logo_scale.toFixed(2)}×</span>
</label>
<input type="range" min={0.5} max={2.5} step={0.05} value={opts.logo_scale} onChange={(e) => set("logo_scale", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">
Darkness <span className="mono">{Math.round(opts.darkness * 100)}%</span>
</label>
<input type="range" min={0} max={1} step={0.05} value={opts.darkness} onChange={(e) => set("darkness", +e.target.value)} />
</div>
<div className="field">
<label className="field-label">Text color</label>
<div className="row">
<input type="color" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
<input className="input grow" value={opts.text_color} onChange={(e) => set("text_color", e.target.value)} />
</div>
</div>
<div className="field">
<label className="field-label">Studio logo</label>
<select className="select" value={opts.studio} onChange={(e) => set("studio", e.target.value)}>
{STUDIOS.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
{opts.studio !== "none" && (
<div className="field">
<label className="field-label">Studio position</label>
<div className="seg">
{POSITIONS.map((p) => (
<button key={p} className={`seg-btn ${opts.studio_position === p ? "active" : ""}`} onClick={() => set("studio_position", p)}>
{p.replace("-", " ")}
</button>
))}
</div>
</div>
)}
{selected.type === "Series" && (
<div className="row wrap">
<button className={`chip ${opts.new_episodes_tag ? "active" : ""}`} onClick={() => set("new_episodes_tag", !opts.new_episodes_tag)}>
New episodes
</button>
<button className={`chip ${opts.season_finale_tag ? "active" : ""}`} onClick={() => set("season_finale_tag", !opts.season_finale_tag)}>
Season finale
</button>
</div>
)}
</>
)}
</div>
</div>
</div>
</>
);
}
@@ -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<string, string> = {
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<Overview | null>(null);
const [artists, setArtists] = useState<ArtistRow[]>([]);
const [search, setSearch] = useState("");
const [expanded, setExpanded] = useState<number | null>(null);
const [albums, setAlbums] = useState<Record<number, Album[]>>({});
const [loading, setLoading] = useState(true);
const pollRef = useRef<number>();
const loadOverview = useCallback(() => {
return apiGet<Overview>("/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 (
<>
<div className="row between" style={{ alignItems: "flex-start" }}>
<PageHead title="Music Collection Completeness" icon={<IconDisc />}>
Compares the albums you own (from a database-backed library scan) against MusicBrainz to surface albums you may
be missing. Scanning and metadata lookups run as background jobs this page only reads the database.
</PageHead>
<div className="row gap-sm">
<button className="btn" onClick={startScan} disabled={o?.scan_running}>
{o?.scan_running ? <span className="spinner" /> : <IconRefresh />} Scan library
</button>
<button className="btn btn-primary" onClick={refreshMetadata} disabled={o?.metadata_running}>
{o?.metadata_running ? <span className="spinner" /> : <IconWand />} Refresh metadata
</button>
</div>
</div>
{loading ? (
<Loading />
) : (
<>
<div className="stat-row" style={{ marginBottom: 14 }}>
<StatCard icon={<IconCheck />} value={`${o?.completeness ?? 0}%`} label="Overall completeness" />
<StatCard icon={<IconLayers />} value={o?.owned ?? 0} label="Owned albums" />
<StatCard icon={<IconImage />} value={o?.missing ?? 0} label="Missing albums" />
<StatCard icon={<IconDisc />} value={o?.uncertain ?? 0} label="Uncertain matches" />
</div>
<div className="row wrap gap-sm" style={{ marginBottom: 18 }}>
<span className="badge">{o?.library_artists ?? 0} artists scanned</span>
<span className="badge">{o?.library_albums ?? 0} albums in library</span>
<span className="badge">Last scan: {scanStatus}</span>
<span className="badge">
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"}
</span>
</div>
<form
className="search-inner"
style={{ maxWidth: 360, marginBottom: 16 }}
onSubmit={(e) => {
e.preventDefault();
loadArtists(search);
}}
>
<IconSearch />
<input className="input" placeholder="Search artists…" value={search} onChange={(e) => setSearch(e.target.value)} />
</form>
{artists.length === 0 ? (
<div className="panel">
<Empty icon={<IconDisc />}>
No completeness data yet. Run <strong>Scan library</strong>, then <strong>Refresh metadata</strong> to
compare against MusicBrainz.
</Empty>
</div>
) : (
<div className="panel">
{artists.map((a) => (
<div key={a.id} style={{ borderBottom: "1px solid var(--border)" }}>
<button className="completeness-row" onClick={() => toggleArtist(a.id)}>
<IconChevron className={`nav-caret ${expanded === a.id ? "open" : ""}`} />
<span className="grow" style={{ textAlign: "left", fontWeight: 600 }}>
{a.name}
</span>
<span className="badge badge-ok">{a.owned} owned</span>
{a.missing > 0 && <span className="badge badge-bad">{a.missing} missing</span>}
{a.uncertain > 0 && <span className="badge badge-warn">{a.uncertain} uncertain</span>}
<span className="completeness-pct mono">{a.completeness}%</span>
<span className="bar" style={{ width: 90 }}>
<span className="bar-fill" style={{ width: `${a.completeness}%` }} />
</span>
</button>
{expanded === a.id && (
<div style={{ padding: "0 16px 14px 40px" }}>
{!albums[a.id] ? (
<Loading />
) : (
<table className="data-table">
<thead>
<tr>
<th>Album</th>
<th>Year</th>
<th>Status</th>
<th>Confidence</th>
<th>Source</th>
<th style={{ textAlign: "right" }}>Actions</th>
</tr>
</thead>
<tbody>
{albums[a.id].map((al) => (
<tr key={al.id}>
<td className="cell-strong" title={al.reason}>
{al.title}
{al.manual_override ? <span className="badge" style={{ marginLeft: 8 }}>manual</span> : null}
</td>
<td className="mono">{al.year || "—"}</td>
<td>
<span className={`badge ${STATUS_BADGE[al.status] || ""}`}>{statusLabel(al.status)}</span>
</td>
<td className="mono cell-sub">{al.confidence ? al.confidence.toFixed(2) : "—"}</td>
<td className="cell-sub">{al.source}</td>
<td>
<div className="row gap-sm" style={{ justifyContent: "flex-end" }}>
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "owned")}>
Owned
</button>
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "missing")}>
Missing
</button>
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "ignore")}>
Ignore
</button>
<button className="btn btn-sm" onClick={() => decide(a.id, al.id, "reset")}>
Reset
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
))}
</div>
)}
</>
)}
</>
);
}
@@ -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: <IconImage /> },
{ key: "folder_cleanup", label: "Folder cleanup", desc: "Normalize folders to 'YEAR - Album'", icon: <IconFolder /> },
{ key: "rename", label: "Rename tracks", desc: "Rename audio files to 'NN - Title'", icon: <IconDisc /> },
{ key: "file_cleanup", label: "File cleanup", desc: "Remove non-audio / non-art files", icon: <IconTrash /> },
{ key: "lyrics", label: "Fetch lyrics", desc: "Download .lrc / .txt sidecars from LRCLIB", icon: <IconWand /> },
] as const;
type ModeKey = (typeof MODES)[number]["key"];
export default function CoverManager() {
const toast = useToast();
const [scan, setScan] = useState<Scan | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const [dryRun, setDryRun] = useState(true);
const [modes, setModes] = useState<Record<ModeKey, boolean>>({
covers: true,
folder_cleanup: false,
rename: false,
file_cleanup: false,
lyrics: false,
});
const [actions, setActions] = useState<Action[] | null>(null);
function refresh() {
setLoading(true);
apiGet<Scan>("/api/music/scan")
.then(setScan)
.catch((e) => toast(e.message, "err"))
.finally(() => setLoading(false));
}
useEffect(refresh, []); // eslint-disable-line react-hooks/exhaustive-deps
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 <Loading label="Scanning music library…" />;
if (!scan?.exists) {
return (
<>
<PageHead title="Cover Manager">Maintain your local music library.</PageHead>
<div className="panel">
<Empty icon={<IconFolder />}>
Music root not found: <code>{scan?.root}</code>
<br />
Set <code>MUSIC_ROOT</code> and mount the share into the container.
</Empty>
</div>
</>
);
}
const logClass = (a: Action) =>
({ ok: "log-ok", dry: "log-dry", skip: "log-skip", warn: "log-warn", info: "log-info" }[a.level] || "log-info");
return (
<>
<PageHead title="Cover Manager">
Clean album folders, rename tracks and fetch missing covers across <code>{scan.root}</code>. Runs in dry-run
mode by default nothing changes until you turn that off.
</PageHead>
<div className="stat-row" style={{ marginBottom: 22 }}>
<StatCard icon={<IconDisc />} value={scan.album_count ?? 0} label="Albums" />
<StatCard icon={<IconImage />} value={scan.missing_cover_count ?? 0} label="Missing covers" />
<StatCard icon={<IconFolder />} value={scan.needs_rename_count ?? 0} label="Folders to rename" />
<StatCard icon={<IconTrash />} value={scan.extra_file_count ?? 0} label="Extra files" />
</div>
<div className="workbench" style={{ gridTemplateColumns: "340px 1fr" }}>
<div className="col">
<div className="panel">
<div className="panel-head">
<h3>Maintenance modes</h3>
</div>
<div className="panel-body col" style={{ gap: 10 }}>
{MODES.map((m) => (
<label
key={m.key}
className={`chip ${modes[m.key] ? "active" : ""}`}
style={{ justifyContent: "flex-start", padding: "11px 13px", cursor: "pointer" }}
>
<input
type="checkbox"
checked={modes[m.key]}
onChange={(e) => setModes((s) => ({ ...s, [m.key]: e.target.checked }))}
style={{ marginRight: 4 }}
/>
<span style={{ flexShrink: 0 }}>{m.icon}</span>
<span style={{ textAlign: "left" }}>
<div style={{ fontWeight: 600, color: "var(--text)" }}>{m.label}</div>
<div className="hint">{m.desc}</div>
</span>
</label>
))}
</div>
</div>
<div className="panel">
<div className="panel-body col">
<label className={`chip ${!dryRun ? "" : "active"}`} style={{ justifyContent: "space-between", cursor: "pointer" }}>
<span>
<div style={{ fontWeight: 700, color: dryRun ? "var(--accent-h)" : "var(--red)" }}>
{dryRun ? "Dry run (safe)" : "Apply changes (live)"}
</div>
<div className="hint">{dryRun ? "Preview only — no files touched" : "Will modify files on disk"}</div>
</span>
<input type="checkbox" checked={!dryRun} onChange={(e) => setDryRun(!e.target.checked)} />
</label>
<button className={`btn ${dryRun ? "btn-primary" : "btn-danger"} btn-block`} onClick={run} disabled={running}>
{running ? <span className="spinner" /> : <IconPlay />}
{running ? "Working…" : dryRun ? "Preview changes" : "Apply now"}
</button>
<button className="btn btn-block" onClick={refresh} disabled={running}>
<IconRefresh /> Rescan library
</button>
</div>
</div>
</div>
<div className="col">
{actions && (
<div className="panel">
<div className="panel-head">
<h3>{scan && actions ? "Result" : ""} Action log</h3>
<span className="sub">{actions.length} entries</span>
</div>
<div className="panel-body">
<div className="console">
{actions.length === 0 ? (
<span className="dim">Nothing to do.</span>
) : (
actions.map((a, i) => (
<div className="log-line" key={i}>
<span className={`log-tag ${logClass(a)}`}>{a.level === "dry" ? "plan" : a.level}</span>
<span>{a.message}</span>
</div>
))
)}
</div>
</div>
</div>
)}
<div className="panel">
<div className="panel-head">
<h3>Albums</h3>
<span className="sub">{scan.albums.length}</span>
</div>
<div style={{ maxHeight: 520, overflow: "auto" }}>
<table className="data-table">
<thead>
<tr>
<th>Album</th>
<th>Year</th>
<th>Tracks</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{scan.albums.map((a) => (
<tr key={a.path}>
<td>
<div className="cell-strong">{a.album || a.folder_name}</div>
<div className="cell-sub">{a.artist}</div>
</td>
<td className="mono">{a.year || "—"}</td>
<td className="mono">{a.track_count}</td>
<td>
<div className="row wrap" style={{ gap: 6 }}>
{a.has_cover ? (
<span className="badge badge-ok">cover</span>
) : (
<span className="badge badge-warn">no cover</span>
)}
{a.needs_folder_rename && <span className="badge badge-accent">rename</span>}
{a.extra_file_count > 0 && <span className="badge">{a.extra_file_count} extra</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
</>
);
}
+224
View File
@@ -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: "AZ" },
{ 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<Album[]>([]);
const [loading, setLoading] = useState(true);
const [sort, setSort] = useState("alphabeticalByName");
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<AlbumDetail | null>(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<AlbumDetail>(`/api/navidrome/album/${id}`)
.then(setSelected)
.catch((e) => toast(e.message, "err"))
.finally(() => setDetailLoading(false));
}
if (status && !status.configured) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<div className="panel">
<Empty icon={<IconMusic />}>
Navidrome is not configured. Set <code>NAVIDROME_URL</code>, <code>NAVIDROME_USER</code> and{" "}
<code>NAVIDROME_PASSWORD</code> and restart the app.
</Empty>
</div>
</>
);
}
if (status && status.configured && !status.connected) {
return (
<>
<PageHead title="Music Library">Browse your Navidrome library.</PageHead>
<div className="panel">
<Empty icon={<IconMusic />}>Could not connect to Navidrome. {status.error}</Empty>
</div>
</>
);
}
return (
<>
<PageHead title="Music Library">Browse artists and albums served by your Navidrome instance.</PageHead>
<div className="row between wrap" style={{ marginBottom: 18, gap: 12 }}>
<div className="seg">
{SORTS.map((s) => (
<button
key={s.key}
className={`seg-btn ${sort === s.key && !query ? "active" : ""}`}
onClick={() => {
setQuery("");
setSort(s.key);
}}
>
{s.label}
</button>
))}
</div>
<form
className="search-inner"
style={{ width: 280 }}
onSubmit={(e) => {
e.preventDefault();
load();
}}
>
<IconSearch />
<input
className="input"
placeholder="Search albums…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</form>
</div>
{loading ? (
<Loading label="Loading albums…" />
) : albums.length === 0 ? (
<div className="panel">
<Empty icon={<IconDisc />}>No albums found.</Empty>
</div>
) : (
<div className="card-grid">
{albums.map((a) => (
<div key={a.id} className="media-card" onClick={() => openAlbum(a.id)}>
{a.cover_url ? (
<img className="media-cover" src={`${a.cover_url}?size=300`} loading="lazy" alt={a.name} />
) : (
<div className="media-cover" style={{ display: "grid", placeItems: "center" }}>
<IconDisc className="dim" />
</div>
)}
<div className="media-body">
<div className="media-title">{a.name}</div>
<div className="media-sub">
{a.artist}
{a.year ? ` · ${a.year}` : ""}
</div>
</div>
</div>
))}
</div>
)}
{(selected || detailLoading) && (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(4,7,11,0.7)",
display: "grid",
placeItems: "center",
zIndex: 50,
padding: 24,
}}
onClick={() => setSelected(null)}
>
<div className="panel" style={{ width: 640, maxWidth: "100%", maxHeight: "86vh", overflow: "auto" }} onClick={(e) => e.stopPropagation()}>
{detailLoading || !selected ? (
<Loading />
) : (
<>
<div className="panel-head">
{selected.cover_url && (
<img
src={`${selected.cover_url}?size=120`}
style={{ width: 64, height: 64, borderRadius: 10, objectFit: "cover" }}
alt=""
/>
)}
<div className="grow">
<h3>{selected.name}</h3>
<div className="sub">
{selected.artist}
{selected.year ? ` · ${selected.year}` : ""} · {selected.song_count} tracks ·{" "}
{fmtDuration(selected.duration)}
</div>
</div>
<button className="btn btn-sm" onClick={() => setSelected(null)}>
Close
</button>
</div>
<table className="data-table">
<tbody>
{selected.songs.map((s) => (
<tr key={s.id}>
<td style={{ width: 36 }} className="cell-sub mono">
{s.track ?? <IconPlay />}
</td>
<td className="cell-strong">{s.title}</td>
<td style={{ textAlign: "right", width: 60 }} className="cell-sub mono">
{fmtDuration(s.duration)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
)}
</>
);
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -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"]
}
+1
View File
@@ -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"}
+18
View File
@@ -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",
},
},
});
+777
View File
@@ -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()
+3 -1
View File
@@ -4,4 +4,6 @@ httpx
Pillow Pillow
onnxruntime onnxruntime
python-multipart python-multipart
jinja2 requests
mutagen
musicbrainzngs
+8
View File
@@ -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.
"""
+157
View File
@@ -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)
+149
View File
@@ -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)},
)
+39
View File
@@ -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
+50
View File
@@ -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))
+311
View File
@@ -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,
},
}
+533
View File
@@ -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,
}
+625
View File
@@ -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"}
+187
View File
@@ -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 01 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
+308
View File
@@ -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],
}
+159
View File
@@ -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
+63
View File
@@ -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()
+145
View File
@@ -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)
+799
View File
@@ -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;
}
+14 -7
View File
@@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit</title> <title>EmbyToolkit</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/static/app-theme.css">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #0d0f12; --bg: #0d0f12;
@@ -33,7 +32,7 @@
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: 'DM Sans', sans-serif; font-family: 'Inter', system-ui, sans-serif;
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: 240px 1fr;
height: 100vh; height: 100vh;
@@ -379,7 +378,8 @@
.pager-meta { .pager-meta {
color: var(--text-2); color: var(--text-2);
font-size: 12px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.pager-actions { .pager-actions {
display: flex; display: flex;
@@ -511,10 +511,17 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span> <span>Bulk Assign</span>
</a></li> </a></li>
<li><a class="app-nav-item" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul> </ul>
<div class="app-nav-footer"> <div class="app-nav-foot">
<span class="dot" id="statusDot"></span> <div class="app-nav-status">
<span id="statusText">Checking…</span> <span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div> </div>
</nav> </nav>
+16 -8
View File
@@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit</title> <title>EmbyToolkit</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/static/app-theme.css">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #0d0f12; --bg: #0d0f12;
@@ -31,7 +30,7 @@
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: 'DM Sans', sans-serif; font-family: 'Inter', system-ui, sans-serif;
display: grid; display: grid;
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
height: 100vh; height: 100vh;
@@ -215,7 +214,8 @@
.selection-meta { .selection-meta {
color: var(--text-2); color: var(--text-2);
font-size: 12px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.chip-toggle { .chip-toggle {
min-height: 34px; min-height: 34px;
@@ -390,7 +390,8 @@
.pager-meta { .pager-meta {
color: var(--text-2); color: var(--text-2);
font-size: 12px; font-size: 12px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.pager-actions { .pager-actions {
display: flex; display: flex;
@@ -466,10 +467,17 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span> <span>Bulk Assign</span>
</a></li> </a></li>
<li><a class="app-nav-item" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul> </ul>
<div class="app-nav-footer"> <div class="app-nav-foot">
<span class="dot" id="statusDot"></span> <div class="app-nav-status">
<span id="statusText">Checking…</span> <span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div> </div>
</nav> </nav>
+22 -11
View File
@@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit</title> <title>EmbyToolkit</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/static/app-theme.css">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #0d0f12; --bg: #0d0f12;
@@ -33,7 +32,7 @@
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: 'DM Sans', sans-serif; font-family: 'Inter', system-ui, sans-serif;
display: grid; display: grid;
grid-template-columns: 220px 1fr; grid-template-columns: 220px 1fr;
height: 100vh; height: 100vh;
@@ -172,7 +171,8 @@
.results-page { .results-page {
font-size: 11px; font-size: 11px;
color: var(--text-2); color: var(--text-2);
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.results { .results {
flex: 1; flex: 1;
@@ -313,7 +313,8 @@
background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95)); background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95));
color: var(--text-2); color: var(--text-2);
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
@@ -523,7 +524,8 @@
background: var(--surface2); background: var(--surface2);
color: var(--text-2); color: var(--text-2);
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
white-space: nowrap; white-space: nowrap;
} }
.preview-shell { .preview-shell {
@@ -722,7 +724,8 @@
padding: 2px; padding: 2px;
} }
.picker-row input[type="text"] { .picker-row input[type="text"] {
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.segmented { .segmented {
@@ -786,7 +789,8 @@
text-align: right; text-align: right;
color: var(--text-2); color: var(--text-2);
font-size: 11px; font-size: 11px;
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.actions { .actions {
@@ -924,10 +928,17 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span> <span>Bulk Assign</span>
</a></li> </a></li>
<li><a class="app-nav-item" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul> </ul>
<div class="app-nav-footer"> <div class="app-nav-foot">
<span class="dot" id="statusDot"></span> <div class="app-nav-status">
<span id="statusText">Checking…</span> <span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div> </div>
</nav> </nav>
+659
View File
@@ -0,0 +1,659 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit — User Favourites</title>
<link rel="stylesheet" href="/static/app-theme.css">
<style>
:root {
--bg: #0d0f12;
--surface: #13171f;
--surface2: #1a2030;
--surface3: #222840;
--border: #252d3d;
--border-active: #14b8a6;
--text: #e8ecf4;
--text-2: #8892a8;
--text-3: #4e5a70;
--accent: #14b8a6;
--accent-h: #2dd4bf;
--accent-glow: rgba(20,184,166,0.12);
--green: #34d399;
--green-bg: rgba(52,211,153,0.08);
--red: #f87171;
--r: 10px;
--r-lg: 14px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: 'Inter', system-ui, sans-serif;
display: grid;
grid-template-columns: 240px 1fr;
height: 100vh;
height: 100svh;
overflow: hidden;
}
/* ── Mobile top bar ── */
.app-topbar {
display: none;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 30;
}
.app-topbar-brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: -0.03em; }
.icon-btn {
width: 38px; height: 38px;
display: inline-grid; place-items: center;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.icon-btn:hover { background: var(--surface3); }
.nav-scrim {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
z-index: 40; opacity: 0;
transition: opacity 0.18s ease;
}
.nav-scrim.show { opacity: 1; }
/* ── App Nav Sidebar ── */
.app-nav { display: flex; flex-direction: column; background: var(--surface); border-right: 1px solid var(--border); overflow: hidden; }
.app-nav-brand { padding: 18px 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.app-nav-logo { width: 32px; height: 32px; background: var(--accent); border-radius: 8px; display: grid; place-items: center; color: #fff; flex-shrink: 0; }
.app-nav-name { font-size: 15px; font-weight: 700; letter-spacing: -0.03em; }
.app-nav-items { list-style: none; padding: 8px; flex: 1; overflow-y: auto; margin: 0; }
.app-nav-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--text-2); text-decoration: none; font-size: 13px; font-weight: 500; transition: background 0.12s, color 0.12s; margin-bottom: 1px; }
.app-nav-item:hover { background: var(--surface2); color: var(--text); }
.app-nav-item.active { background: var(--accent); color: #fff; }
.app-nav-footer { padding: 14px 16px; border-top: 1px solid var(--border); display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text-2); flex-shrink: 0; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); }
.dot.off { background: var(--red); box-shadow: 0 0 6px var(--red); }
/* ── Page ── */
.page { padding: 24px clamp(16px, 2.4vw, 36px) 32px; overflow-y: auto; }
.page-inner { width: 100%; max-width: 1680px; margin: 0 auto; }
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 18px; }
.hero-copy h2 { margin: 0 0 8px; font-size: clamp(24px, 3vw, 34px); line-height: 1; letter-spacing: -0.04em; }
.hero-copy p { margin: 0; max-width: 820px; color: var(--text-2); font-size: 14px; line-height: 1.5; }
/* ── Sections ── */
.section { margin-bottom: 18px; }
.section-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-3); margin: 0 0 10px 2px; }
.user-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.user-chip {
display: inline-flex; align-items: center; gap: 9px;
padding: 8px 14px 8px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text-2);
font-family: inherit; font-size: 13px; font-weight: 600;
cursor: pointer;
transition: background 0.14s, border-color 0.14s, color 0.14s;
}
.user-chip:hover { background: var(--surface3); color: var(--text); }
.user-chip .avatar { width: 26px; height: 26px; font-size: 10px; }
.user-chip .count { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.user-chip.active { background: var(--accent-glow); border-color: var(--accent); color: var(--text); }
.user-chip .pl-ico { display: inline-grid; place-items: center; color: var(--text-3); }
.user-chip.active .pl-ico { color: var(--accent-h); }
.chip-badge {
font-size: 9px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 2px 7px; border-radius: 999px;
background: var(--accent-glow); color: var(--accent-h); border: 1px solid rgba(54,214,224,0.3);
}
.actions-note {
margin-top: 10px; font-size: 12px; color: #f6d98c;
background: rgba(243,201,105,0.08); border: 1px solid rgba(243,201,105,0.25);
border-radius: 10px; padding: 8px 12px;
}
.actions-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(26,32,48,0.95), rgba(19,23,31,0.95));
}
.actions-group { display: flex; align-items: center; gap: 8px; }
.actions-sep { width: 1px; align-self: stretch; background: var(--border); margin: 2px 4px; }
.actions-spacer { flex: 1; }
.target-field { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-2); }
.target-field input {
width: 64px; padding: 7px 9px; border-radius: 9px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-family: inherit; font-size: 13px; font-variant-numeric: tabular-nums; text-align: center;
}
.btn {
min-height: 36px; padding: 0 14px; border-radius: 10px;
border: 1px solid var(--border); background: var(--surface2); color: var(--text);
font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 7px;
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
}
.btn:hover:not(:disabled) { background: var(--surface3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #04181b; }
.btn-danger { color: #ffb4b2; border-color: rgba(240,114,111,0.35); background: rgba(240,114,111,0.12); }
.btn-green { background: var(--green); border-color: var(--green); color: #08120e; }
.stat-row { margin-bottom: 18px; }
.panel {
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(19,23,31,0.98), rgba(13,15,18,0.98));
overflow: hidden;
}
.panel-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 13px 16px; border-bottom: 1px solid var(--border);
}
.panel-head h3 { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
.panel-head .muted { font-size: 12px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.table-scroll { overflow-x: auto; }
.item-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.preview-card {
margin-top: 14px;
border: 1px solid var(--border);
border-radius: var(--r-lg);
background: var(--surface);
overflow: hidden;
}
.preview-head {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.preview-head h4 { margin: 0; font-size: 13px; font-weight: 700; }
.preview-flag {
font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
padding: 3px 9px; border-radius: 999px;
}
.preview-flag.dry { background: var(--amber-bg, rgba(243,201,105,0.12)); border: 1px solid rgba(243,201,105,0.3); color: #f6d98c; }
.preview-flag.applied { background: var(--green-bg); border: 1px solid rgba(70,217,154,0.3); color: #b7f0d4; }
.preview-body { padding: 6px 8px 10px; max-height: 320px; overflow-y: auto; }
.preview-list { list-style: none; margin: 0; padding: 0; }
.preview-list li {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 8px; font-size: 13px;
}
.preview-list li:hover { background: var(--surface2); }
.preview-list .pl-title { flex: 1; min-width: 0; color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preview-list .pl-score { font-size: 11px; color: var(--accent-h); font-variant-numeric: tabular-nums; }
.preview-list .pl-id { font-size: 11px; color: var(--text-3); font-variant-numeric: tabular-nums; }
.preview-empty { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; }
.empty {
padding: 54px 20px; border: 1px dashed var(--border); border-radius: var(--r-lg);
text-align: center; color: var(--text-3); font-size: 14px;
}
.toast {
position: fixed; bottom: 20px; right: 20px;
padding: 11px 18px; border-radius: 12px;
font-size: 13px; font-weight: 500; z-index: 200;
transform: translateY(80px); opacity: 0;
transition: all 0.25s cubic-bezier(0.4,0,0.2,1); pointer-events: none;
}
.toast.show { transform: translateY(0); opacity: 1; }
.spinner { width: 30px; height: 30px; border: 2.5px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 980px) {
body { grid-template-columns: 1fr; }
.app-topbar { display: flex; }
.nav-scrim.show { display: block; }
.app-nav {
position: fixed; top: 0; bottom: 0; left: 0; width: 264px; z-index: 50;
transform: translateX(-100%); transition: transform 0.2s ease;
}
.app-nav.open { transform: translateX(0); }
}
</style>
</head>
<body>
<header class="app-topbar">
<button class="icon-btn" id="navToggle" aria-label="Open navigation">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="app-topbar-brand"><span>EmbyToolkit</span></div>
</header>
<div class="nav-scrim" id="navScrim"></div>
<nav class="app-nav" id="appNav">
<div class="app-nav-brand">
<div class="app-nav-logo">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.29 7 12 12 20.71 7"/><line x1="12" y1="22" x2="12" y2="12"/></svg>
</div>
<span class="app-nav-name">EmbyToolkit</span>
</div>
<ul class="app-nav-items">
<li><a class="app-nav-item" href="/">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>
<span>Generator</span>
</a></li>
<li><a class="app-nav-item" href="/collections">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
<span>Collections</span>
</a></li>
<li><a class="app-nav-item" href="/airing">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="20" height="15" rx="2"/><polyline points="17 2 12 7 7 2"/></svg>
<span>Airing</span>
</a></li>
<li><a class="app-nav-item" href="/bulk-assign">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span>
</a></li>
<li><a class="app-nav-item active" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul>
<div class="app-nav-foot">
<div class="app-nav-status">
<span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div>
</nav>
<main class="page">
<div class="page-inner">
<section class="hero">
<div class="hero-copy">
<h2>User Favourites</h2>
<p>Browse any Emby collection and view it as a given user. For a user's own <strong>"{Name} Favorites"</strong> 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.</p>
</div>
</section>
<section class="section">
<p class="section-label">Collection</p>
<div class="user-picker" id="collectionPicker">
<div style="color:var(--text-3);font-size:13px;padding:6px 2px">Loading collections…</div>
</div>
</section>
<section class="section">
<p class="section-label">Watched status for</p>
<div class="user-picker" id="userPicker"></div>
</section>
<div id="workspace" style="display:none">
<section class="section stat-row" id="statRow">
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statCurrent"></span><span class="stat-label">Current collection count</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statWatched"></span><span class="stat-label">Watched items found</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statRecommended"></span><span class="stat-label">Items recommended</span></div>
</div>
<div class="stat-card">
<div class="stat-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></div>
<div class="stat-meta"><span class="stat-value" id="statFinal"></span><span class="stat-label">Final count after apply</span></div>
</div>
</section>
<section class="section">
<div class="actions-bar">
<div class="actions-group">
<button class="btn" id="btnPreviewCleanup">Preview cleanup</button>
<button class="btn btn-danger" id="btnApplyCleanup" disabled>Apply cleanup</button>
</div>
<div class="actions-sep"></div>
<div class="actions-group">
<label class="target-field">Target size
<input type="number" id="targetSize" min="1" max="200" value="25">
</label>
<button class="btn" id="btnPreviewRegen">Preview regenerate</button>
<button class="btn btn-green" id="btnApplyRegen" disabled>Apply regenerate</button>
</div>
<div class="actions-spacer"></div>
<button class="btn" id="btnRefresh">Refresh</button>
</div>
<div class="actions-note" id="actionsNote" style="display:none"></div>
<div id="actionResult"></div>
</section>
<section class="section">
<div class="panel">
<div class="panel-head">
<h3 id="playlistTitle">Collection</h3>
<span class="muted" id="playlistMeta"></span>
</div>
<div class="table-scroll">
<table class="data-table">
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Year</th>
<th>Runtime</th>
<th>Watched</th>
<th>Emby item id</th>
</tr>
</thead>
<tbody id="playlistBody"></tbody>
</table>
</div>
<div id="playlistEmpty" class="preview-empty" style="display:none">This collection has no items.</div>
</div>
</section>
</div>
<div id="noUsers" class="empty" style="display:none">
No collections found. Create a collection named "<strong>{User} Favorites</strong>" in Emby (for example "Matt Favorites") and refresh.
</div>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
const $ = s => document.querySelector(s);
const state = { collections: [], users: [], selectedCollectionId: null, selectedUserId: null, view: null };
/* ── nav (mobile) ── */
const appNav = $('#appNav'), navScrim = $('#navScrim'), navToggle = $('#navToggle');
function closeNav() { appNav.classList.remove('open'); navScrim.classList.remove('show'); }
if (navToggle) navToggle.addEventListener('click', () => { appNav.classList.add('open'); navScrim.classList.add('show'); });
if (navScrim) navScrim.addEventListener('click', closeNav);
/* ── helpers ── */
function toast(msg, ok = true) {
const t = $('#toast');
t.textContent = msg;
t.style.background = ok ? 'rgba(70,217,154,0.12)' : 'rgba(240,114,111,0.12)';
t.style.border = '1px solid ' + (ok ? 'rgba(70,217,154,0.4)' : 'rgba(240,114,111,0.4)');
t.style.color = ok ? '#b7f0d4' : '#ffb4b2';
t.classList.add('show');
clearTimeout(toast._t);
toast._t = setTimeout(() => t.classList.remove('show'), 3200);
}
function initials(name) { return (name || '?').trim().slice(0, 2).toUpperCase(); }
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
function fmtRuntime(min) { if (!min) return '—'; const h = Math.floor(min / 60), m = min % 60; return h ? `${h}h ${m}m` : `${m}m`; }
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
return r.json();
}
async function postJSON(url, body) {
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!r.ok) { const d = await r.json().catch(() => ({})); throw new Error(d.detail || `Request failed (${r.status})`); }
return r.json();
}
/* ── data ── */
const ICON_FAV = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>';
const ICON_COL = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>';
async function loadOverview() {
try {
const data = await getJSON('/api/favorites/collections');
state.collections = data.collections || [];
state.users = data.users || [];
if (!state.collections.length) {
$('#collectionPicker').innerHTML = '<div style="color:var(--text-3);font-size:13px;padding:6px 2px">No collections found in Emby.</div>';
$('#noUsers').style.display = 'block';
return;
}
$('#noUsers').style.display = 'none';
const def = state.collections.find(c => c.is_favorites) || state.collections[0];
renderCollections();
renderUsers();
selectCollection(def.collection_id);
} catch (e) {
$('#collectionPicker').innerHTML = `<div style="color:#ffb4b2;font-size:13px">${escapeHtml(e.message)}</div>`;
}
}
function currentCollection() { return state.collections.find(c => c.collection_id === state.selectedCollectionId); }
function actionsReady() { return !!(state.selectedUserId && state.view && state.view.actions_enabled); }
function renderCollections() {
const picker = $('#collectionPicker');
picker.innerHTML = state.collections.map(c => `
<button class="user-chip${c.collection_id === state.selectedCollectionId ? ' active' : ''}" data-id="${escapeHtml(c.collection_id)}">
<span class="pl-ico">${c.is_favorites ? ICON_FAV : ICON_COL}</span>
<span>${escapeHtml(c.collection_name)}</span>
${c.is_favorites ? '<span class="chip-badge">Favourites</span>' : ''}
${c.item_count != null ? `<span class="count">${c.item_count}</span>` : ''}
</button>`).join('');
picker.querySelectorAll('.user-chip').forEach(btn =>
btn.addEventListener('click', () => selectCollection(btn.dataset.id)));
}
function renderUsers() {
const picker = $('#userPicker');
picker.innerHTML = state.users.map(u => `
<button class="user-chip${u.id === state.selectedUserId ? ' active' : ''}" data-id="${escapeHtml(u.id)}">
<span class="avatar">${escapeHtml(initials(u.name))}</span>
<span>${escapeHtml(u.name)}</span>
</button>`).join('');
picker.querySelectorAll('.user-chip').forEach(btn =>
btn.addEventListener('click', () => selectUser(btn.dataset.id)));
}
function selectCollection(id) {
state.selectedCollectionId = id;
const col = currentCollection();
if (col && col.owner_user_id) state.selectedUserId = col.owner_user_id;
else if (!state.selectedUserId && state.users.length) state.selectedUserId = state.users[0].id;
renderCollections();
renderUsers();
$('#actionResult').innerHTML = '';
loadView();
}
function selectUser(id) {
state.selectedUserId = id;
renderUsers();
$('#actionResult').innerHTML = '';
loadView();
}
async function loadView() {
if (!state.selectedCollectionId || !state.selectedUserId) return;
$('#workspace').style.display = 'block';
$('#playlistBody').innerHTML = `<tr><td colspan="6"><div class="spinner"></div></td></tr>`;
try {
const view = await getJSON(`/api/favorites/collection/${state.selectedCollectionId}?user_id=${encodeURIComponent(state.selectedUserId)}`);
state.view = view;
renderItems();
renderSummary({ current: view.summary.current_count, watched: view.summary.watched_count, recommended: '—', final: '—' });
gateActions(view);
} catch (e) {
$('#playlistBody').innerHTML = `<tr><td colspan="6" style="color:#ffb4b2">${escapeHtml(e.message)}</td></tr>`;
toast(e.message, false);
}
}
function gateActions(view) {
const on = !!view.actions_enabled;
$('#btnPreviewCleanup').disabled = !on;
$('#btnPreviewRegen').disabled = !on;
if (!on) { $('#btnApplyCleanup').disabled = true; $('#btnApplyRegen').disabled = true; }
const note = $('#actionsNote');
if (on) { note.style.display = 'none'; note.textContent = ''; }
else { note.style.display = 'block'; note.textContent = view.actions_reason || 'Cleanup and regenerate are unavailable for this collection.'; }
}
function renderItems() {
const v = state.view;
$('#playlistTitle').textContent = v.collection_name;
$('#playlistMeta').textContent = `${v.summary.current_count} items · ${v.summary.watched_count} watched · as ${v.user_name}`;
const body = $('#playlistBody');
const empty = $('#playlistEmpty');
if (!v.items.length) {
body.innerHTML = '';
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
body.innerHTML = v.items.map(it => `
<tr>
<td class="cell-strong">${escapeHtml(it.title)}${it.series_name ? `<div class="cell-sub">${escapeHtml(it.series_name)}</div>` : ''}</td>
<td>${escapeHtml(it.type || it.media_type || '—')}</td>
<td class="cell-num">${it.year || '—'}</td>
<td class="cell-num">${fmtRuntime(it.runtime_minutes)}</td>
<td>${it.watched
? '<span class="badge badge-watched">Watched</span>'
: '<span class="badge">Unwatched</span>'}</td>
<td><span class="item-id">${escapeHtml(it.id)}</span></td>
</tr>`).join('');
}
async function refreshCounts() {
try {
const data = await getJSON('/api/favorites/collections');
state.collections = data.collections || [];
renderCollections();
} catch { /* keep stale counts on failure */ }
}
function renderSummary({ current, watched, recommended, final }) {
$('#statCurrent').textContent = current;
$('#statWatched').textContent = watched;
$('#statRecommended').textContent = recommended;
$('#statFinal').textContent = final;
}
/* ── action result panel ── */
function renderActionResult(kind, payload) {
const dry = payload.dry_run;
const isCleanup = kind === 'cleanup';
const list = isCleanup ? (payload.removed || []) : (payload.recommended || []);
const verb = isCleanup ? (dry ? 'would be removed' : 'removed') : (dry ? 'would be added' : 'added');
const heading = isCleanup ? 'Watched-item cleanup' : 'Recommended replacements';
const rows = list.length ? `<ul class="preview-list">${list.map(i => `
<li>
<span class="pl-title">${escapeHtml(i.title)}</span>
${i.score != null ? `<span class="pl-score">score ${i.score}</span>` : ''}
<span class="pl-id">${escapeHtml(i.item_id)}</span>
</li>`).join('')}</ul>`
: `<div class="preview-empty">${payload.message ? escapeHtml(payload.message) : `Nothing ${verb}.`}</div>`;
$('#actionResult').innerHTML = `
<div class="preview-card">
<div class="preview-head">
<h4>${heading}</h4>
<span class="preview-flag ${dry ? 'dry' : 'applied'}">${dry ? 'Dry run · preview' : 'Applied'}</span>
<span style="margin-left:auto;font-size:12px;color:var(--text-3)">${list.length} ${list.length === 1 ? 'item' : 'items'} ${verb}</span>
</div>
<div class="preview-body">${rows}</div>
</div>`;
}
/* ── cleanup ── */
async function previewCleanup() {
if (!actionsReady()) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: true });
renderActionResult('cleanup', res);
renderSummary({
current: res.summary.current_count, watched: res.summary.watched_count,
recommended: $('#statRecommended').textContent,
final: res.summary.current_count - res.summary.watched_count,
});
$('#btnApplyCleanup').disabled = res.summary.watched_count === 0;
toast(`${res.watched_found} watched item${res.watched_found === 1 ? '' : 's'} found`);
} catch (e) { toast(e.message, false); }
}
async function applyCleanup() {
if (!actionsReady()) return;
const who = (state.view && state.view.user_name) || 'this user';
const where = (state.view && state.view.collection_name) || 'this collection';
if (!confirm(`Remove items ${who} has watched from "${where}"? Collections are shared, so removed items leave the collection for everyone. This cannot be undone.`)) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/cleanup`, { userId: state.selectedUserId, dryRun: false });
renderActionResult('cleanup', res);
toast(`Removed ${res.summary.removed_count} watched item${res.summary.removed_count === 1 ? '' : 's'}`);
$('#btnApplyCleanup').disabled = true;
await refreshCounts();
await loadView();
} catch (e) { toast(e.message, false); }
}
/* ── regenerate ── */
function targetSize() { return Math.max(1, parseInt($('#targetSize').value, 10) || 25); }
async function previewRegen() {
if (!actionsReady()) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: true, targetSize: targetSize() });
renderActionResult('regenerate', res);
renderSummary({
current: res.summary.current_count, watched: $('#statWatched').textContent,
recommended: res.summary.recommended_count, final: res.summary.final_count,
});
$('#btnApplyRegen').disabled = res.summary.recommended_count === 0;
toast(res.message ? res.message : `${res.summary.recommended_count} recommendation${res.summary.recommended_count === 1 ? '' : 's'} ready`, !res.message || res.summary.recommended_count > 0);
} catch (e) { toast(e.message, false); }
}
async function applyRegen() {
if (!actionsReady()) return;
const where = (state.view && state.view.collection_name) || 'this collection';
if (!confirm(`Add recommendations to "${where}" (target ${targetSize()} items)? Collections are shared with all users.`)) return;
try {
const res = await postJSON(`/api/favorites/collection/${state.selectedCollectionId}/regenerate`, { userId: state.selectedUserId, dryRun: false, targetSize: targetSize() });
renderActionResult('regenerate', res);
toast(`Added ${res.summary.added_count} item${res.summary.added_count === 1 ? '' : 's'}`);
$('#btnApplyRegen').disabled = true;
await refreshCounts();
await loadView();
} catch (e) { toast(e.message, false); }
}
$('#btnPreviewCleanup').addEventListener('click', previewCleanup);
$('#btnApplyCleanup').addEventListener('click', applyCleanup);
$('#btnPreviewRegen').addEventListener('click', previewRegen);
$('#btnApplyRegen').addEventListener('click', applyRegen);
$('#btnRefresh').addEventListener('click', loadView);
/* ── status ── */
(async () => {
try {
const config = await (await fetch('/api/config')).json();
if (config.connected) { $('#statusText').textContent = 'Connected to Emby'; }
else { $('#statusDot').classList.add('off'); $('#statusText').textContent = 'No API key'; }
} catch {
$('#statusDot').classList.add('off'); $('#statusText').textContent = 'Connection error';
}
})();
loadOverview();
</script>
</body>
</html>
+23 -12
View File
@@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EmbyToolkit</title> <title>EmbyToolkit</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="/static/app-theme.css">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #0d0f12; --bg: #0d0f12;
@@ -30,7 +29,7 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
font-family: 'DM Sans', sans-serif; font-family: 'Inter', system-ui, sans-serif;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
display: grid; display: grid;
@@ -225,7 +224,8 @@
.results-page { .results-page {
font-size: 11px; font-size: 11px;
color: var(--text-2); color: var(--text-2);
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
} }
.results-footer { .results-footer {
justify-content: flex-end; justify-content: flex-end;
@@ -332,7 +332,8 @@
text-transform: uppercase; text-transform: uppercase;
} }
.primary-preview-note { .primary-preview-note {
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
letter-spacing: 0; letter-spacing: 0;
text-transform: none; text-transform: none;
font-size: 11px; font-size: 11px;
@@ -483,7 +484,8 @@
} }
.thumb-preview-meta { .thumb-preview-meta {
color: rgba(255,255,255,0.88); color: rgba(255,255,255,0.88);
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
font-size: 11px; font-size: 11px;
white-space: nowrap; white-space: nowrap;
} }
@@ -655,7 +657,7 @@
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 7px; border-radius: 7px;
color: var(--text); color: var(--text);
font-family: 'JetBrains Mono', monospace; font-size: 12px; font-family: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
outline: none; outline: none;
} }
.color-wrap input[type="text"]:focus { border-color: var(--border-active); } .color-wrap input[type="text"]:focus { border-color: var(--border-active); }
@@ -688,7 +690,8 @@
} }
.slider-val { .slider-val {
font-size: 11px; color: var(--text-2); font-size: 11px; color: var(--text-2);
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
min-width: 28px; text-align: right; min-width: 28px; text-align: right;
} }
.asset-picker { .asset-picker {
@@ -716,7 +719,8 @@
min-width: 40px; min-width: 40px;
font-size: 11px; font-size: 11px;
color: var(--text-2); color: var(--text-2);
font-family: 'JetBrains Mono', monospace; font-family: inherit;
font-variant-numeric: tabular-nums;
text-align: center; text-align: center;
} }
.logo-swatch { .logo-swatch {
@@ -959,10 +963,17 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></svg>
<span>Bulk Assign</span> <span>Bulk Assign</span>
</a></li> </a></li>
<li><a class="app-nav-item" href="/favorites">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
<span>User Favourites</span>
</a></li>
</ul> </ul>
<div class="app-nav-footer"> <div class="app-nav-foot">
<span class="dot" id="statusDot"></span> <div class="app-nav-status">
<span id="statusText">Checking…</span> <span class="dot" id="statusDot"></span>
<span id="statusText">Checking…</span>
</div>
<div class="app-nav-version">EmbyToolkit · v1.0</div>
</div> </div>
</nav> </nav>
+396
View File
@@ -0,0 +1,396 @@
"""Tests for the User Favourites feature (Emby Collections / BoxSets).
Uses a FakeEmbyClient (no network, no app.py import) and plain ``asyncio.run`` so
no pytest-asyncio plugin is required. Run from the repo root: ``python -m pytest``.
"""
import asyncio
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from services import emby_collections, emby_users, emby_watch_history, favorites
from services import recommendations as rec
def run(coro):
return asyncio.run(coro)
# ── Item builders ────────────────────────────────────────────────────────────
def movie(item_id, name, *, genres=None, year=2014, studio="Acme",
director="Jane Doe", actor="Bob Roe", ticks=72_000_000_000, rating=7.0):
return {
"Id": item_id, "Name": name, "Type": "Movie", "MediaType": "Video",
"ProductionYear": year, "RunTimeTicks": ticks, "CommunityRating": rating,
"Genres": genres or ["Action"],
"Studios": [{"Name": studio}] if studio else [],
"People": ([{"Name": director, "Type": "Director"}] if director else [])
+ ([{"Name": actor, "Type": "Actor"}] if actor else []),
}
# ── Fake Emby client ─────────────────────────────────────────────────────────
class FakeEmbyClient:
def __init__(self, users=None, collections=None, collection_items=None,
watched_by_user=None, watched_history=None, candidates=None):
self.users = users or []
self.collections = collections or []
self.collection_items = collection_items or {} # cid -> [raw items]
self.watched_by_user = watched_by_user or {} # uid -> set(item_id)
self.watched_history = watched_history or {} # uid -> [raw items]
self.candidates = candidates or {} # uid -> [raw items]
self.calls = [] # recorded writes
async def get(self, path, params=None):
params = params or {}
if path == "/Users":
return list(self.users)
if path.startswith("/Users/") and path.endswith("/Items"):
uid = path.split("/")[2]
if "ParentId" in params: # collection children
cid = params["ParentId"]
watched = self.watched_by_user.get(uid, set())
items = []
for raw in self.collection_items.get(cid, []):
copy = dict(raw)
copy["UserData"] = {"Played": raw["Id"] in watched}
items.append(copy)
return {"Items": items}
if "Ids" in params: # is_item_watched
wanted = params["Ids"].split(",")
watched = self.watched_by_user.get(uid, set())
return {"Items": [{"Id": i, "UserData": {"Played": i in watched}} for i in wanted]}
if params.get("Filters") == "IsUnplayed": # candidate pool
return {"Items": list(self.candidates.get(uid, []))}
raise AssertionError(f"unexpected get {path} {params}")
async def get_all(self, path, params=None):
params = params or {}
if path == "/Items" and params.get("IncludeItemTypes") == "BoxSet":
return list(self.collections)
if path.startswith("/Users/") and path.endswith("/Items"):
uid = path.split("/")[2]
if params.get("Filters") == "IsPlayed":
return list(self.watched_history.get(uid, []))
raise AssertionError(f"unexpected get_all {path} {params}")
async def post(self, path, params=None, **kwargs):
self.calls.append(("post", path, params or {}))
cid = path.split("/")[2]
for item_id in (params or {}).get("Ids", "").split(","):
if item_id:
self.collection_items.setdefault(cid, []).append(
{"Id": item_id, "Name": item_id, "Type": "Movie"})
return {"ok": True}
async def delete(self, path, params=None, **kwargs):
self.calls.append(("delete", path, params or {}))
cid = path.split("/")[2]
remove = set((params or {}).get("Ids", "").split(","))
self.collection_items[cid] = [
i for i in self.collection_items.get(cid, []) if i["Id"] not in remove
]
return {"ok": True}
@property
def writes(self):
return [c for c in self.calls if c[0] in ("post", "delete")]
def base_client():
"""Two users, each with a favourites collection; Matt and Dave have watched
different items to prove user-specific behaviour. Plus a themed collection
that is not a favourites collection."""
return FakeEmbyClient(
users=[{"Id": "u-matt", "Name": "Matt"}, {"Id": "u-dave", "Name": "Dave"}],
collections=[
{"Id": "c-matt", "Name": "Matt Favorites", "ChildCount": 3},
{"Id": "c-dave", "Name": "Dave Favorites", "ChildCount": 1},
{"Id": "c-misc", "Name": "Marvel Universe", "ChildCount": 5},
],
collection_items={
"c-matt": [movie("m1", "Alpha"), movie("m2", "Bravo"), movie("m3", "Charlie")],
},
# Matt watched m1; Dave watched m2 (same items, different user)
watched_by_user={"u-matt": {"m1"}, "u-dave": {"m2"}},
)
# ── 1. Detecting favourites collections by name ──────────────────────────────
@pytest.mark.parametrize("name,owner", [
("Matt Favorites", "Matt"),
("Dave Favorites", "Dave"),
("Anna Maria Favorites", "Anna Maria"),
("favorites", None),
("Favorites", None),
("Marvel Universe", None),
("Favorites of Matt", None),
("", None),
(None, None),
])
def test_parse_favorites_owner(name, owner):
assert emby_collections.parse_favorites_owner(name) == owner
def test_list_favorites_users_only_matches_real_users():
users = run(favorites.list_favorites_users(base_client()))
assert [u["user_name"] for u in users] == ["Dave", "Matt"] # "Marvel Universe" excluded
def test_find_all_collections_includes_non_favorites():
cols = run(emby_collections.find_all_collections(base_client()))
by_name = {c["collection_name"]: c for c in cols}
assert set(by_name) == {"Matt Favorites", "Dave Favorites", "Marvel Universe"}
assert by_name["Matt Favorites"]["is_favorites"] is True
assert by_name["Marvel Universe"]["is_favorites"] is False
assert by_name["Marvel Universe"]["owner_name"] is None
def test_list_collections_overview_maps_owner_user_id():
overview = run(favorites.list_collections_overview(base_client()))
by_name = {c["collection_name"]: c for c in overview["collections"]}
assert by_name["Matt Favorites"]["owner_user_id"] == "u-matt"
assert by_name["Marvel Universe"]["owner_user_id"] is None
assert {u["name"] for u in overview["users"]} == {"Matt", "Dave"}
# ── 2. Listing collection items, user-specific watched status ────────────────
def test_list_collection_items_for_user():
items = run(emby_collections.list_collection_items(base_client(), "c-matt", "u-matt"))
assert [i["title"] for i in items] == ["Alpha", "Bravo", "Charlie"]
assert items[0]["runtime_minutes"] == 120
def test_watched_status_is_user_specific():
client = base_client()
matt = run(emby_collections.list_collection_items(client, "c-matt", "u-matt"))
dave = run(emby_collections.list_collection_items(client, "c-matt", "u-dave"))
assert [i["watched"] for i in matt] == [True, False, False] # Matt watched m1
assert [i["watched"] for i in dave] == [False, True, False] # Dave watched m2
def test_is_item_watched_user_specific():
client = base_client()
assert run(emby_watch_history.is_item_watched(client, "u-matt", "m1")) is True
assert run(emby_watch_history.is_item_watched(client, "u-dave", "m1")) is False
# ── 3. Browse any collection; actions gated to favourites owner ──────────────
def test_collection_view_actions_enabled_for_any_collection():
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-matt"))
assert view["actions_enabled"] is True
assert [i["watched"] for i in view["items"]] == [True, False, False]
def test_collection_view_watched_follows_selected_user():
view = run(favorites.get_collection_items_view(base_client(), "c-matt", "u-dave"))
assert view["actions_enabled"] is True # available regardless of owner
assert [i["watched"] for i in view["items"]] == [False, True, False]
def test_collection_view_actions_enabled_for_non_favorites_collection():
client = base_client()
client.collection_items["c-misc"] = [movie("x1", "Iron Man")]
view = run(favorites.get_collection_items_view(client, "c-misc", "u-matt"))
assert view["is_favorites"] is False
assert view["actions_enabled"] is True
def test_collection_view_missing_collection_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.get_collection_items_view(base_client(), "c-nope", "u-matt"))
assert exc.value.status == 404
# ── 4. Cleanup: dry-run vs apply ─────────────────────────────────────────────
def test_cleanup_dry_run_does_not_modify_emby():
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=True))
assert res["dry_run"] is True and res["applied"] is False
assert res["watched_found"] == 1
assert [r["item_id"] for r in res["removed"]] == ["m1"]
assert client.writes == []
assert len(client.collection_items["c-matt"]) == 3
def test_apply_cleanup_removes_only_watched_items():
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
assert res["applied"] is True
assert res["summary"]["removed_count"] == 1
assert res["summary"]["final_count"] == 2
deletes = [c for c in client.writes if c[0] == "delete"]
assert len(deletes) == 1
assert deletes[0][1] == "/Collections/c-matt/Items"
assert deletes[0][2]["Ids"] == "m1"
assert {i["Id"] for i in client.collection_items["c-matt"]} == {"m2", "m3"}
assert res["log"][0]["item_id"] == "m1"
assert res["log"][0]["reason"] == "watched-by-user"
assert res["log"][0]["user"] == "Matt"
assert res["log"][0]["collection"] == "Matt Favorites"
def test_cleanup_does_not_cross_users():
"""Dave watched m2; cleaning Matt's collection must not remove m2."""
client = base_client()
res = run(favorites.cleanup_watched(client, "c-matt", "u-matt", dry_run=False))
assert "m2" not in {r["item_id"] for r in res["removed"]}
assert "m2" in {i["Id"] for i in client.collection_items["c-matt"]}
def test_cleanup_works_on_non_favorites_collection():
"""Cleanup is available for any collection, using the selected user's
watched status."""
client = base_client()
client.collection_items["c-misc"] = [movie("x1", "Iron Man"), movie("x2", "Iron Man 2")]
client.watched_by_user["u-matt"] = {"x1"}
res = run(favorites.cleanup_watched(client, "c-misc", "u-matt", dry_run=False))
assert res["applied"] is True
assert [r["item_id"] for r in res["removed"]] == ["x1"]
assert {i["Id"] for i in client.collection_items["c-misc"]} == {"x2"}
# ── 5. Recommendation scoring (pure) ─────────────────────────────────────────
def test_build_profile_and_score_candidate():
watched = [emby_collections.normalize_item(movie(
"w1", "Seed", genres=["Action", "Sci-Fi"], year=2014,
studio="Acme", director="Jane Doe", actor="Bob Roe"))]
profile = rec.build_profile(watched)
assert profile.genres == {"Action", "Sci-Fi"}
assert profile.decades == {2010}
candidate = emby_collections.normalize_item(movie(
"c1", "Match", genres=["Action", "Sci-Fi", "Drama"], year=2013,
studio="Acme", director="Jane Doe", actor="Bob Roe"))
# 2 genres*5 + director3 + actor2 + studio2 + decade1 + mediatype1 = 19
assert rec.score_candidate(candidate, profile) == 19
def test_score_series_franchise_bonus():
profile = rec.TasteProfile(series={"The Saga"}, media_types={"Video"})
cand = {"series_name": "The Saga", "media_type": "Video", "genres": [], "year": None}
assert rec.score_candidate(cand, profile) == rec.SCORE_SERIES + rec.SCORE_MEDIA_TYPE
def test_rank_orders_by_score_then_rating_then_title():
profile = rec.TasteProfile(genres={"Action"}, media_types={"Video"},
studios={"Acme"}, directors={"Jane Doe"})
low = emby_collections.normalize_item(movie("low", "Zeta", genres=["Action"],
studio=None, director=None, actor=None, rating=9.0))
high = emby_collections.normalize_item(movie("high", "Alpha", genres=["Action"],
studio="Acme", director="Jane Doe", actor=None, rating=1.0))
ranked = rec.rank_candidates([low, high], profile)
assert [r["id"] for r in ranked] == ["high", "low"]
def test_rank_drops_zero_score_candidates():
profile = rec.TasteProfile(genres={"Action"})
unrelated = {"id": "x", "genres": ["Cooking"], "media_type": "Audio", "year": 1980, "title": "X"}
assert rec.rank_candidates([unrelated], profile) == []
# ── 6. Regenerate: exclusions ────────────────────────────────────────────────
def regen_client():
client = base_client()
client.watched_history["u-matt"] = [
movie("w1", "Seed One", genres=["Action", "Sci-Fi"], year=2014),
movie("w2", "Seed Two", genres=["Action"], year=2016),
]
client.candidates["u-matt"] = [
movie("c1", "Good One", genres=["Action", "Sci-Fi"], year=2015),
movie("c2", "Good Two", genres=["Action"], year=2012),
movie("m2", "Already In Collection", genres=["Action"], year=2015), # in c-matt
movie("w1", "Already Watched", genres=["Action"], year=2014), # watched
]
return client
def test_regenerate_excludes_watched_and_existing_collection_items():
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=True, target_size=25))
ids = {r["item_id"] for r in res["recommended"]}
assert "m2" not in ids # already in collection
assert "w1" not in ids # already watched
assert {"c1", "c2"} <= ids
def test_regenerate_dry_run_makes_no_writes():
client = regen_client()
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
assert res["applied"] is False
assert client.writes == []
def test_regenerate_apply_adds_up_to_target():
client = regen_client()
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=False, target_size=4))
# collection had 3, target 4 -> add exactly 1
assert res["summary"]["added_count"] == 1
assert res["summary"]["final_count"] == 4
posts = [c for c in client.writes if c[0] == "post"]
assert len(posts) == 1
assert posts[0][1] == "/Collections/c-matt/Items"
assert res["recommended"][0]["item_id"] == "c1" # highest score first
def test_regenerate_never_readds_watched():
res = run(favorites.regenerate(regen_client(), "c-matt", "u-matt", dry_run=False, target_size=25))
added_ids = {r["item_id"] for r in res["recommended"]}
assert "w1" not in added_ids and "w2" not in added_ids
# ── 7. Empty history ─────────────────────────────────────────────────────────
def test_regenerate_with_empty_history_recommends_nothing():
client = base_client()
client.watched_history["u-matt"] = []
res = run(favorites.regenerate(client, "c-matt", "u-matt", dry_run=True, target_size=25))
assert res["recommended"] == []
assert "No watch history" in res["message"]
assert client.writes == []
def test_empty_profile_build_candidates_returns_empty():
profile = rec.build_profile([])
assert profile.is_empty
assert run(rec.build_candidates(base_client(), "u-matt", profile, set())) == []
# ── 8. Missing users / collections / API failures ────────────────────────────
def test_missing_user_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.cleanup_watched(base_client(), "c-matt", "u-ghost"))
assert exc.value.status == 404
def test_missing_collection_raises_404():
with pytest.raises(favorites.FavoritesError) as exc:
run(favorites.cleanup_watched(base_client(), "c-nope", "u-matt"))
assert exc.value.status == 404
def test_emby_api_failure_propagates():
class BoomClient(FakeEmbyClient):
async def get_all(self, path, params=None):
raise RuntimeError("Emby exploded")
client = BoomClient(users=[{"Id": "u-matt", "Name": "Matt"}])
with pytest.raises(RuntimeError):
run(favorites.list_collections_overview(client))
def test_regenerate_rejects_negative_target():
with pytest.raises(favorites.FavoritesError):
run(favorites.regenerate(base_client(), "c-matt", "u-matt", dry_run=True, target_size=-1))
+229
View File
@@ -0,0 +1,229 @@
"""Tests for the Music Collection Completeness feature.
Covers normalisation, fuzzy matching, scan upserts, deleted-file handling,
completeness statuses, and that manual ignore decisions survive a recompute.
Scanning uses a fake tag reader so we don't need real audio files with embedded
metadata the scanner's database behaviour is what's under test.
"""
from pathlib import Path
import pytest
from services import db
from services import music_library as ml
from services import text_normalize as tn
@pytest.fixture
def temp_db(tmp_path, monkeypatch):
monkeypatch.setattr(db, "DB_PATH", tmp_path / "test.db")
db.init_db()
yield
def _fake_tags(path: Path) -> dict:
return {
"artist": path.parent.parent.name,
"album": path.parent.name,
"title": path.stem,
"track_number": 1,
"disc_number": 1,
"year": 1997,
"artist_mbid": None,
"album_mbid": None,
"track_mbid": None,
}
def _make_track(root: Path, artist: str, album: str, name: str) -> Path:
folder = root / artist / album
folder.mkdir(parents=True, exist_ok=True)
f = folder / name
f.write_bytes(b"\x00" * 64)
return f
# ── normalisation ─────────────────────────────────────────────────────────────
def test_normalize_strips_editions_but_keeps_original():
assert tn.normalize_title("OK Computer (Deluxe Edition)") == tn.normalize_title("OK Computer")
assert tn.normalize_title("The Bends - 2009 Remaster") == "the bends"
assert tn.normalize_title("In Rainbows [Remastered]") == "in rainbows"
def test_normalize_artist_drops_leading_the():
assert tn.normalize_artist("The Beatles") == "beatles"
assert tn.normalize_artist("AC/DC") == "ac dc"
def test_is_various_artists():
assert tn.is_various_artists("Various Artists")
assert tn.is_various_artists("VA")
assert not tn.is_various_artists("Radiohead")
# ── fuzzy matching / classification ───────────────────────────────────────────
def _local(title, year=1997, mbid=None, _id=1):
return {"id": _id, "title": title, "title_normalized": tn.normalize_title(title), "year": year, "mbid": mbid}
def test_classify_mbid_match():
locals_ = [_local("Whatever", mbid="mbid-123")]
status, conf, reason, lid = tn.classify_release("Different Title", 2000, "mbid-123", locals_)
assert status == tn.OWNED and conf == 1.0 and lid == 1
def test_classify_title_and_year():
locals_ = [_local("OK Computer", 1997)]
status, *_ = tn.classify_release("OK Computer", 1997, None, locals_)
assert status == tn.OWNED
# within a year still counts as owned
status2, *_ = tn.classify_release("OK Computer", 1998, None, locals_)
assert status2 == tn.OWNED
def test_classify_fuzzy_and_missing():
locals_ = [_local("OK Computer", 1997)]
status, conf, *_ = tn.classify_release("OK Komputer", None, None, locals_)
assert status == tn.PROBABLY_OWNED
status2, *_ = tn.classify_release("Kid A", 2000, None, locals_)
assert status2 == tn.MISSING
# ── scan upserts + deleted handling ───────────────────────────────────────────
def test_scan_upserts_and_skips_unchanged(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
_make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3")
_make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3")
_make_track(root, "Portishead", "Dummy", "01 - Mysterons.flac")
result = ml.run_scan(root)
assert result["status"] == "completed"
with db.connect() as conn:
artists = conn.execute("SELECT COUNT(*) c FROM library_artists WHERE is_active=1").fetchone()["c"]
albums = conn.execute("SELECT COUNT(*) c FROM library_albums WHERE is_active=1").fetchone()["c"]
tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"]
assert (artists, albums, tracks) == (2, 2, 3)
# Re-scan unchanged: counts stable, no duplicate rows.
ml.run_scan(root)
with db.connect() as conn:
tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks").fetchone()["c"]
assert tracks == 3
def test_deleted_file_marked_inactive(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
f1 = _make_track(root, "Radiohead", "OK Computer", "01 - Airbag.mp3")
_make_track(root, "Radiohead", "OK Computer", "02 - Paranoid Android.mp3")
ml.run_scan(root)
f1.unlink()
ml.run_scan(root)
with db.connect() as conn:
gone = conn.execute("SELECT is_active FROM library_tracks WHERE file_path=?", (str(f1),)).fetchone()
active_tracks = conn.execute("SELECT COUNT(*) c FROM library_tracks WHERE is_active=1").fetchone()["c"]
# album still active because one track remains
album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"]
assert gone["is_active"] == 0
assert active_tracks == 1
assert album_active == 1
def test_deleted_album_deactivates_album_and_artist(temp_db, tmp_path, monkeypatch):
monkeypatch.setattr(ml, "_read_tags", _fake_tags)
root = tmp_path / "music"
f = _make_track(root, "Solo", "Only Album", "01 - Track.mp3")
ml.run_scan(root)
f.unlink()
ml.run_scan(root)
with db.connect() as conn:
album_active = conn.execute("SELECT is_active FROM library_albums LIMIT 1").fetchone()["is_active"]
artist_active = conn.execute("SELECT is_active FROM library_artists LIMIT 1").fetchone()["is_active"]
assert album_active == 0
assert artist_active == 0
# ── completeness statuses + manual decisions ──────────────────────────────────
def _seed_artist_with_release(year_local=1997):
"""Insert one artist, one local album (OK Computer), and three external
release groups (owned, missing, live-excluded). Returns artist id."""
with db.connect() as conn:
cur = conn.execute(
"INSERT INTO library_artists(name, name_normalized, is_active, created_at, updated_at) VALUES('Radiohead','radiohead',1,'','')"
)
artist_id = cur.lastrowid
conn.execute(
"INSERT INTO library_albums(artist_id, title, title_normalized, year, is_active, created_at, updated_at) VALUES(?,?,?,?,1,'','')",
(artist_id, "OK Computer", tn.normalize_title("OK Computer"), year_local),
)
for mbid, title, yr, prim, sec in [
("rg-ok", "OK Computer", 1997, "Album", "[]"),
("rg-kida", "Kid A", 2000, "Album", "[]"),
("rg-live", "I Might Be Wrong: Live Recordings", 2001, "Album", '["Live"]'),
]:
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(?,?,?,?,?,?,?,'')",
(artist_id, mbid, title, tn.normalize_title(title), yr, prim, sec),
)
ml.recompute_completeness(conn, artist_id)
return artist_id
def test_completeness_statuses(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
rows = {
r["title"]: r
for r in conn.execute(
"SELECT title, status FROM collection_completeness WHERE artist_id=?", (artist_id,)
).fetchall()
}
assert rows["OK Computer"]["status"] == tn.OWNED
assert rows["Kid A"]["status"] == tn.MISSING
# The live album is filtered out — no completeness row created.
assert "I Might Be Wrong: Live Recordings" not in rows
def test_manual_ignore_survives_recompute(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
kid = conn.execute(
"SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,)
).fetchone()
ml.set_album_decision(kid["id"], "ignore")
# A metadata refresh recomputes — the manual decision must persist.
with db.connect() as conn:
ml.recompute_completeness(conn, artist_id)
row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone()
assert row["status"] == "ignored"
assert row["manual_override"] == 1
def test_reset_decision_recomputes(temp_db):
artist_id = _seed_artist_with_release()
with db.connect() as conn:
kid = conn.execute(
"SELECT id FROM collection_completeness WHERE artist_id=? AND title='Kid A'", (artist_id,)
).fetchone()
ml.set_album_decision(kid["id"], "owned")
ml.set_album_decision(kid["id"], "reset")
with db.connect() as conn:
row = conn.execute("SELECT status, manual_override FROM collection_completeness WHERE id=?", (kid["id"],)).fetchone()
assert row["manual_override"] == 0
assert row["status"] == tn.MISSING # Kid A is not in the local library