Homelabtoolkit v1
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.pytest_cache/
|
||||||
|
cache/
|
||||||
|
output/
|
||||||
|
logs/
|
||||||
|
.app.out.log
|
||||||
|
.app.err.log
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
templates/
|
||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
```
|
||||||
|
|||||||
@@ -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 && 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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"name": "embycovers",
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Generated
+1774
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> / {" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { apiGet } from "../../api";
|
||||||
|
import { PageHead, Empty, Loading, fmtDuration } from "../../components/ui";
|
||||||
|
import { IconDisc, IconMusic, IconSearch, IconPlay } from "../../components/icons";
|
||||||
|
import { useToast } from "../../lib/toast";
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
artist: string;
|
||||||
|
year?: number;
|
||||||
|
song_count: number;
|
||||||
|
duration: number;
|
||||||
|
cover_url: string | null;
|
||||||
|
}
|
||||||
|
interface Song {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
track?: number;
|
||||||
|
duration: number;
|
||||||
|
artist: string;
|
||||||
|
}
|
||||||
|
interface AlbumDetail extends Album {
|
||||||
|
songs: Song[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ key: "alphabeticalByName", label: "A–Z" },
|
||||||
|
{ key: "newest", label: "Newest" },
|
||||||
|
{ key: "recent", label: "Recently Played" },
|
||||||
|
{ key: "frequent", label: "Most Played" },
|
||||||
|
{ key: "random", label: "Random" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Library() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [status, setStatus] = useState<{ connected: boolean; configured: boolean; error?: string } | null>(null);
|
||||||
|
const [albums, setAlbums] = useState<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
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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
@@ -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
@@ -4,4 +4,6 @@ httpx
|
|||||||
Pillow
|
Pillow
|
||||||
onnxruntime
|
onnxruntime
|
||||||
python-multipart
|
python-multipart
|
||||||
jinja2
|
requests
|
||||||
|
mutagen
|
||||||
|
musicbrainzngs
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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)},
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""MusicBrainz client: the default free metadata source.
|
||||||
|
|
||||||
|
Deliberately small and polite:
|
||||||
|
* one global throttle so we never exceed ~1 request/second (MB's published limit);
|
||||||
|
* a descriptive User-Agent (MB rejects anonymous clients);
|
||||||
|
* retry/backoff on 503 and network errors;
|
||||||
|
* responses cached in the ``mb_cache`` table so completeness never hits the API
|
||||||
|
during a normal page render.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
logger = logging.getLogger("homelabtoolkit.musicbrainz")
|
||||||
|
|
||||||
|
MB_BASE = "https://musicbrainz.org/ws/2"
|
||||||
|
USER_AGENT = os.environ.get(
|
||||||
|
"MUSICBRAINZ_USER_AGENT",
|
||||||
|
"HomelabToolkit/1.0 ( https://github.com/homelabtoolkit )",
|
||||||
|
)
|
||||||
|
MIN_INTERVAL_SECONDS = 1.1
|
||||||
|
CACHE_MAX_AGE_DAYS = 30
|
||||||
|
|
||||||
|
# Release-group filtering. Primary type must be Album; any of these secondary
|
||||||
|
# types excludes it (live albums, compilations, soundtracks, remixes, etc.).
|
||||||
|
# Kept as module constants so the filter can be relaxed later in one place.
|
||||||
|
INCLUDED_PRIMARY_TYPES = {"album"}
|
||||||
|
EXCLUDED_SECONDARY_TYPES = {
|
||||||
|
"live",
|
||||||
|
"compilation",
|
||||||
|
"soundtrack",
|
||||||
|
"remix",
|
||||||
|
"dj-mix",
|
||||||
|
"mixtape/street",
|
||||||
|
"demo",
|
||||||
|
"interview",
|
||||||
|
"audiobook",
|
||||||
|
"audio drama",
|
||||||
|
"spokenword",
|
||||||
|
}
|
||||||
|
|
||||||
|
_throttle_lock = threading.Lock()
|
||||||
|
_last_request_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _throttle() -> None:
|
||||||
|
global _last_request_at
|
||||||
|
with _throttle_lock:
|
||||||
|
wait = MIN_INTERVAL_SECONDS - (time.monotonic() - _last_request_at)
|
||||||
|
if wait > 0:
|
||||||
|
time.sleep(wait)
|
||||||
|
_last_request_at = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_get(cache_key: str, max_age_days: int = CACHE_MAX_AGE_DAYS):
|
||||||
|
cutoff = time.time() - max_age_days * 86400
|
||||||
|
with db.connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT payload, fetched_at FROM mb_cache WHERE cache_key=?", (cache_key,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
# fetched_at is ISO; treat anything older than the cutoff as a miss.
|
||||||
|
try:
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
|
fetched = _dt.datetime.fromisoformat((row["fetched_at"] or "").replace("Z", "+00:00"))
|
||||||
|
if fetched.timestamp() < cutoff:
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return json.loads(row["payload"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_put(cache_key: str, payload) -> None:
|
||||||
|
with db.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO mb_cache(cache_key, payload, fetched_at) VALUES(?,?,?) "
|
||||||
|
"ON CONFLICT(cache_key) DO UPDATE SET payload=excluded.payload, fetched_at=excluded.fetched_at",
|
||||||
|
(cache_key, json.dumps(payload), db.now_iso()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _request(path: str, params: dict, cache_key: str, *, use_cache: bool = True):
|
||||||
|
if use_cache:
|
||||||
|
cached = _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
||||||
|
query = {**params, "fmt": "json"}
|
||||||
|
for attempt in range(4):
|
||||||
|
_throttle()
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{MB_BASE}/{path}", params=query, headers=headers, timeout=25)
|
||||||
|
if resp.status_code == 503:
|
||||||
|
time.sleep(2.0 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
_cache_put(cache_key, data)
|
||||||
|
return data
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.warning("MusicBrainz request failed (%s attempt %d): %s", path, attempt + 1, exc)
|
||||||
|
time.sleep(1.5 * (attempt + 1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def search_artist(name: str) -> dict | None:
|
||||||
|
"""Best artist match for a name, with a 0–1 confidence score."""
|
||||||
|
cache_key = f"artist_search::{name.lower()}"
|
||||||
|
data = _request("artist", {"query": name, "limit": 5}, cache_key)
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
artists = data.get("artists") or []
|
||||||
|
if not artists:
|
||||||
|
return None
|
||||||
|
best = artists[0]
|
||||||
|
return {
|
||||||
|
"mbid": best.get("id"),
|
||||||
|
"name": best.get("name") or "",
|
||||||
|
"confidence": round(int(best.get("score", 0)) / 100.0, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_release_groups(artist_mbid: str) -> list[dict]:
|
||||||
|
"""All release groups for an artist (paged). Filtering happens later so the
|
||||||
|
completeness filters can change without re-fetching."""
|
||||||
|
results: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
limit = 100
|
||||||
|
while True:
|
||||||
|
cache_key = f"release_groups::{artist_mbid}::{offset}"
|
||||||
|
data = _request(
|
||||||
|
"release-group",
|
||||||
|
{"artist": artist_mbid, "type": "album", "limit": limit, "offset": offset},
|
||||||
|
cache_key,
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
batch = data.get("release-groups") or []
|
||||||
|
for rg in batch:
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"mbid": rg.get("id"),
|
||||||
|
"title": rg.get("title") or "",
|
||||||
|
"first_release_year": _year(rg.get("first-release-date")),
|
||||||
|
"primary_type": (rg.get("primary-type") or "").strip(),
|
||||||
|
"secondary_types": [s.strip() for s in (rg.get("secondary-types") or [])],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
total = int(data.get("release-group-count", len(results)))
|
||||||
|
offset += limit
|
||||||
|
if offset >= total or not batch:
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def is_official_album(primary_type: str | None, secondary_types: list[str] | None) -> bool:
|
||||||
|
"""Apply the default album filter (excludes EP/single/live/comp/etc.)."""
|
||||||
|
if (primary_type or "").strip().lower() not in INCLUDED_PRIMARY_TYPES:
|
||||||
|
return False
|
||||||
|
for secondary in secondary_types or []:
|
||||||
|
if secondary.strip().lower() in EXCLUDED_SECONDARY_TYPES:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _year(date_str: str | None) -> int:
|
||||||
|
if not date_str:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(str(date_str)[:4])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
@@ -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],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user