mirror of
https://github.com/myronblair/kino-app
synced 2026-07-29 05:54:01 -05:00
Compare commits
36 Commits
211d4c23d5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 671074764e | |||
| 628a729afd | |||
| 4397617cf1 | |||
| 119dd20743 | |||
| eed071dd8f | |||
| 8fa1291e0f | |||
| 71e731124a | |||
| 6438407f5b | |||
| e54803abb0 | |||
| 9742ebf50c | |||
| b74d8a2652 | |||
| cf8c7890be | |||
| 43340c0f49 | |||
| ef5c13d587 | |||
| 5fc37e46e4 | |||
| 5ac78654d4 | |||
| 9af505b45c | |||
| a313008440 | |||
| 4e72f568f3 | |||
| c174ac60b1 | |||
| 3c47894628 | |||
| 3d2b7a4122 | |||
| 1891fd0b7c | |||
| 64d8945460 | |||
| cf31a14ae1 | |||
| 514cbad4ab | |||
| f7626ced2e | |||
| 5b191da471 | |||
| 0f6f03509a | |||
| 4d77a36f99 | |||
| 19374b694e | |||
| 5b881a902d | |||
| 003512c01f | |||
| e27bb37f7b | |||
| d4dffdb6fb | |||
| 3e30f1491c |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1",
|
||||
"job_id": "08d9a7a1-2a0c-4502-9939-fc5d643c04c4",
|
||||
"created_at": "2026-07-23T22:57:32.873830+00:00Z"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
[user]
|
||||
email = github@emergent.sh
|
||||
name = emergent-agent-e1
|
||||
@@ -61,7 +61,6 @@ logs/
|
||||
|
||||
# --- Test reports / agent runtime ---
|
||||
test_reports/
|
||||
.emergent/
|
||||
tests/__pycache__/
|
||||
|
||||
# --- Misc ---
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Kino — Deployment on a Proxmox LAMP host
|
||||
# StreamHoard — Deployment on a Proxmox LAMP host
|
||||
|
||||
Kino is FastAPI + MongoDB + React. It runs happily alongside your existing
|
||||
StreamHoard is FastAPI + MongoDB + React. It runs happily alongside your existing
|
||||
LAMP stack (Apache/MySQL/PHP) because we run everything in Docker containers
|
||||
on their own network. Apache keeps port 80/443, MySQL is untouched, Kino
|
||||
on their own network. Apache keeps port 80/443, MySQL is untouched, StreamHoard
|
||||
uses whatever port you pick.
|
||||
|
||||
---
|
||||
@@ -54,7 +54,7 @@ Log in with the admin credentials from `.env`.
|
||||
|
||||
## Running behind your existing Apache (optional, recommended)
|
||||
|
||||
If you already have Apache on port 80/443 with virtual hosts, add a Kino vhost
|
||||
If you already have Apache on port 80/443 with virtual hosts, add a StreamHoard vhost
|
||||
that reverse-proxies to the container. This gives you `https://kino.example.com`
|
||||
instead of `http://ip:8080`.
|
||||
|
||||
@@ -130,7 +130,7 @@ To reset everything: `docker compose down -v` (deletes the mongo-data volume).
|
||||
|
||||
## Coexistence with your LAMP stack
|
||||
|
||||
| Kino uses | LAMP uses | Conflict? |
|
||||
| StreamHoard uses | LAMP uses | Conflict? |
|
||||
| ------------------------ | ----------------------- | --------- |
|
||||
| Port `KINO_HTTP_PORT` | Apache 80/443 | No — different ports |
|
||||
| MongoDB (internal only) | MySQL | No — different DBs, Mongo not exposed to host |
|
||||
@@ -144,16 +144,16 @@ same box.
|
||||
|
||||
## Radarr integration
|
||||
|
||||
If Radarr is running on the same Proxmox host (native or container), point Kino
|
||||
If Radarr is running on the same Proxmox host (native or container), point StreamHoard
|
||||
at it via **Admin → Settings → Radarr**:
|
||||
|
||||
- **URL**: `http://<host-ip>:7878` (use the host IP, not `localhost` — containers
|
||||
can't reach the host's `localhost` unless you add `host.docker.internal`)
|
||||
- **API key**: from Radarr → Settings → General → Security
|
||||
|
||||
**Important**: For Kino to actually play the imported movies, the path Radarr
|
||||
**Important**: For StreamHoard to actually play the imported movies, the path Radarr
|
||||
reports (e.g. `/movies/Inception (2010)/Inception.mkv`) must be **readable from
|
||||
inside the Kino backend container**. Mount Radarr's media path into Kino:
|
||||
inside the StreamHoard backend container**. Mount Radarr's media path into StreamHoard:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — extend the backend volumes
|
||||
@@ -163,7 +163,7 @@ backend:
|
||||
- /mnt/radarr-movies:/mnt/radarr-movies:ro # <— add this
|
||||
```
|
||||
|
||||
Radarr's file paths must be identical inside both Radarr and Kino containers
|
||||
Radarr's file paths must be identical inside both Radarr and StreamHoard containers
|
||||
(easiest: match the paths exactly).
|
||||
|
||||
---
|
||||
@@ -172,14 +172,14 @@ Radarr's file paths must be identical inside both Radarr and Kino containers
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
|
||||
# {"app":"Kino","status":"ok"}
|
||||
# {"app":"StreamHoard","status":"ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firewalling
|
||||
|
||||
Kino has no built-in rate limiting or IP allowlist. Since you confirmed
|
||||
StreamHoard has no built-in rate limiting or IP allowlist. Since you confirmed
|
||||
**internal network only**, keep it that way:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Kino — Personal Media Server (Netflix Clone)
|
||||
# StreamHoard — Personal Media Server (Netflix Clone)
|
||||
|
||||
A self-hosted Netflix-style streaming app for movies you legally own.
|
||||
Built with FastAPI + React + MongoDB.
|
||||
@@ -41,7 +41,7 @@ See **[DEPLOY.md](./DEPLOY.md)** for LAMP-coexistence, Apache reverse proxy, Rad
|
||||
*(Legal alternative to auto-downloading — auto-download of copyrighted material is not implemented.)*
|
||||
|
||||
## Default admin
|
||||
- Email: `admin@kino.local`
|
||||
- Email: `admin@streamhoard.local`
|
||||
- Password: `kino-admin-2026`
|
||||
|
||||
Change `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `backend/.env` before first start
|
||||
@@ -118,7 +118,7 @@ Movie media files should live on your Proxmox storage, not Git.
|
||||
# Login
|
||||
curl -X POST $URL/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"admin@kino.local","password":"kino-admin-2026"}'
|
||||
-d '{"email":"admin@streamhoard.local","password":"kino-admin-2026"}'
|
||||
|
||||
# List movies
|
||||
curl $URL/api/movies
|
||||
@@ -130,7 +130,7 @@ curl $URL/api/stream/<movie_id>?auth=<token>
|
||||
## Why no auto-download?
|
||||
|
||||
Auto-downloading copyrighted movies from the internet is illegal in most
|
||||
jurisdictions (DMCA, EU copyright directive, etc.). Kino is built as a
|
||||
jurisdictions (DMCA, EU copyright directive, etc.). StreamHoard is built as a
|
||||
**personal media server** for content you legally own or that is in the
|
||||
public domain (Internet Archive, Blender Open Movies, etc.).
|
||||
|
||||
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
# Kino backend — FastAPI + ffmpeg
|
||||
FROM python:3.11-slim
|
||||
|
||||
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit)
|
||||
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit);
|
||||
# libchromaprint-tools provides `fpcalc`, used for AcoustID audio fingerprinting.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
libchromaprint-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -0,0 +1,83 @@
|
||||
"""AcoustID audio fingerprint identification — free, but requires:
|
||||
1. A free API key from https://acoustid.org/api-key (one signup, no cost)
|
||||
2. The `fpcalc` binary (Chromaprint) to compute a fingerprint from the actual audio file
|
||||
|
||||
Unlike musicbrainz.py/itunes.py (which match on artist+album text), this identifies a track
|
||||
from its audio content — useful for files with missing/wrong tags where there's nothing
|
||||
usable to text-search on.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kino.acoustid")
|
||||
|
||||
ACOUSTID_BASE = "https://api.acoustid.org/v2/lookup"
|
||||
|
||||
|
||||
def fingerprint_file(path: Path) -> Optional[Dict]:
|
||||
"""Runs fpcalc on the file; returns {"duration": float, "fingerprint": str} or None."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["fpcalc", "-json", str(path)],
|
||||
capture_output=True, timeout=60, text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
data = json.loads(proc.stdout)
|
||||
if not data.get("fingerprint"):
|
||||
return None
|
||||
return {"duration": data.get("duration"), "fingerprint": data["fingerprint"]}
|
||||
except FileNotFoundError:
|
||||
logger.warning("fpcalc not installed — AcoustID identification unavailable")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def lookup_recording(api_key: str, duration: float, fingerprint: str) -> Optional[Dict]:
|
||||
"""Identifies a recording from its fingerprint. Returns best-match title/artist/album."""
|
||||
try:
|
||||
r = requests.get(ACOUSTID_BASE, params={
|
||||
"client": api_key,
|
||||
"duration": int(duration or 0),
|
||||
"fingerprint": fingerprint,
|
||||
"meta": "recordings+releasegroups",
|
||||
}, timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json() or {}
|
||||
except Exception:
|
||||
return None
|
||||
if data.get("status") != "ok":
|
||||
return None
|
||||
results = sorted(data.get("results") or [], key=lambda x: x.get("score", 0), reverse=True)
|
||||
for res in results:
|
||||
recordings = res.get("recordings") or []
|
||||
if not recordings:
|
||||
continue
|
||||
rec = recordings[0]
|
||||
releasegroups = rec.get("releasegroups") or []
|
||||
artist = (rec.get("artists") or [{}])[0].get("name")
|
||||
title = rec.get("title")
|
||||
if not (artist and title):
|
||||
continue
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": releasegroups[0].get("title") if releasegroups else None,
|
||||
"mbid": rec.get("id"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def identify_track(api_key: str, path: Path) -> Optional[Dict]:
|
||||
"""Fingerprint + lookup a single file. Runs the blocking pieces off the event loop."""
|
||||
fp = await asyncio.to_thread(fingerprint_file, path)
|
||||
if not fp:
|
||||
return None
|
||||
return await asyncio.to_thread(lookup_recording, api_key, fp["duration"], fp["fingerprint"])
|
||||
@@ -0,0 +1,32 @@
|
||||
"""iTunes Search API client — free, no API key, no auth, no rate-limit registration.
|
||||
Used as a fallback when MusicBrainz + Cover Art Archive has no match/art for a release.
|
||||
"""
|
||||
import requests
|
||||
from typing import Optional, Dict
|
||||
|
||||
ITUNES_BASE = "https://itunes.apple.com/search"
|
||||
|
||||
|
||||
def lookup_album(artist: str, album: str) -> Optional[Dict]:
|
||||
"""Best-effort match for an artist+album against the iTunes Store catalog."""
|
||||
try:
|
||||
r = requests.get(ITUNES_BASE, params={
|
||||
"term": f"{artist} {album}", "media": "music", "entity": "album", "limit": 1,
|
||||
}, timeout=15)
|
||||
r.raise_for_status()
|
||||
results = (r.json() or {}).get("results") or []
|
||||
except Exception:
|
||||
return None
|
||||
if not results:
|
||||
return None
|
||||
res = results[0]
|
||||
art = res.get("artworkUrl100")
|
||||
if art:
|
||||
# iTunes serves a small default; the same CDN path accepts arbitrary sizes.
|
||||
art = art.replace("100x100bb", "1200x1200bb")
|
||||
return {
|
||||
"title": res.get("collectionName") or album,
|
||||
"artist": res.get("artistName") or artist,
|
||||
"year": (res.get("releaseDate") or "")[:4] or None,
|
||||
"art_url": art,
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Library integrity scan — missing files, orphaned records/disk usage, stuck jobs, duplicates.
|
||||
Read-only by design: scan() only reports findings; server.py's /library/cleanup/fix endpoint
|
||||
performs the actual deletion/reset the admin picked, one category at a time.
|
||||
"""
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import asyncio
|
||||
|
||||
# Movie/episode/track files often live on an NFS/CIFS-mounted NAS, where every is_file() stat
|
||||
# is a real network round trip. Checking hundreds of them one at a time (even off the event
|
||||
# loop) can take minutes; a bounded worker pool checks them concurrently instead.
|
||||
_STAT_WORKERS = 24
|
||||
|
||||
|
||||
def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path:
|
||||
if storage_type in ("radarr", "sonarr", "scan"):
|
||||
return Path(storage_path)
|
||||
return videos_dir / storage_path
|
||||
|
||||
|
||||
def _scan_filesystem(movies: List[Dict], episodes: List[Dict], tracks: List[Dict],
|
||||
videos_dir: Path, hls_dir: Path, live_ids: set) -> Dict[str, List[Dict]]:
|
||||
"""Every filesystem stat call in the whole scan lives here, run off the event loop via
|
||||
asyncio.to_thread, with the individual stat calls themselves parallelized across a small
|
||||
worker pool — sequential one-at-a-time NAS round trips were taking minutes for a library
|
||||
of any real size."""
|
||||
out: Dict[str, List[Dict]] = {"missing_files": [], "orphaned_hls": []}
|
||||
|
||||
candidates = [] # (finding_dict, path_to_check)
|
||||
for m in movies:
|
||||
if m.get("storage_type") not in ("local", "radarr", "scan") or not m.get("storage_path"):
|
||||
continue
|
||||
candidates.append((
|
||||
{"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]},
|
||||
_file_path(m["storage_type"], m["storage_path"], videos_dir),
|
||||
))
|
||||
for e in episodes:
|
||||
if e.get("_orphaned"):
|
||||
continue # already reported as orphaned_episodes; don't also report a missing file
|
||||
if e.get("storage_type") not in ("local", "sonarr", "scan") or not e.get("storage_path"):
|
||||
continue
|
||||
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
|
||||
candidates.append((
|
||||
{"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]},
|
||||
_file_path(e["storage_type"], e["storage_path"], videos_dir),
|
||||
))
|
||||
for t in tracks:
|
||||
candidates.append((
|
||||
{"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')} — {t.get('title','Untitled')}", "detail": t["storage_path"]},
|
||||
Path(t["storage_path"]),
|
||||
))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=_STAT_WORKERS) as pool:
|
||||
exists = pool.map(lambda pair: pair[1].is_file(), candidates)
|
||||
for (finding, _path), is_present in zip(candidates, exists):
|
||||
if not is_present:
|
||||
out["missing_files"].append(finding)
|
||||
|
||||
if hls_dir.is_dir():
|
||||
for d in hls_dir.iterdir():
|
||||
if d.is_dir() and d.name not in live_ids:
|
||||
size = sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
|
||||
out["orphaned_hls"].append({"id": d.name, "content_type": "hls_dir", "label": d.name, "detail": f"{round(size / 1_000_000)} MB reclaimable"})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def scan(db, videos_dir: Path, hls_dir: Path) -> Dict[str, List[Dict]]:
|
||||
findings: Dict[str, List[Dict]] = {
|
||||
"missing_files": [], "orphaned_episodes": [], "orphaned_hls": [],
|
||||
"stuck_jobs": [], "duplicate_paths": [], "orphaned_subtitles": [], "dangling_refs": [],
|
||||
}
|
||||
|
||||
movies = await db.movies.find({}, {"_id": 0, "id": 1, "title": 1, "storage_type": 1, "storage_path": 1}).to_list(10000)
|
||||
episodes = await db.episodes.find({}, {"_id": 0, "id": 1, "title": 1, "show_id": 1, "season_number": 1, "episode_number": 1, "storage_type": 1, "storage_path": 1}).to_list(20000)
|
||||
tracks = await db.tracks.find({}, {"_id": 0, "id": 1, "title": 1, "artist": 1, "storage_path": 1}).to_list(20000)
|
||||
show_ids = set(await db.shows.distinct("id"))
|
||||
|
||||
for e in episodes:
|
||||
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
|
||||
if e.get("show_id") not in show_ids:
|
||||
findings["orphaned_episodes"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": f"show_id {e.get('show_id')} not found"})
|
||||
e["_orphaned"] = True
|
||||
|
||||
live_ids = {m["id"] for m in movies} | {e["id"] for e in episodes}
|
||||
fs_findings = await asyncio.to_thread(_scan_filesystem, movies, episodes, tracks, videos_dir, hls_dir, live_ids)
|
||||
findings["missing_files"] = fs_findings["missing_files"]
|
||||
findings["orphaned_hls"] = fs_findings["orphaned_hls"]
|
||||
|
||||
# --- Stuck transcode jobs (running with no progress for 2+ hours — likely dead from a restart) ---
|
||||
now = datetime.now(timezone.utc)
|
||||
running = await db.transcode_queue.find({"status": "running"}, {"_id": 0}).to_list(500)
|
||||
for j in running:
|
||||
started = j.get("started_at")
|
||||
if not started:
|
||||
continue
|
||||
try:
|
||||
age_hours = (now - datetime.fromisoformat(started)).total_seconds() / 3600
|
||||
except ValueError:
|
||||
continue
|
||||
if age_hours > 2:
|
||||
findings["stuck_jobs"].append({"id": j["id"], "content_type": "job", "label": j.get("movie_title") or j["movie_id"], "detail": f"running {round(age_hours, 1)}h with no completion"})
|
||||
|
||||
# --- Duplicate storage_path within the same collection (same file imported twice) ---
|
||||
for coll_name, coll, label_fn in (
|
||||
("movies", db.movies, lambda d: d.get("title", "Untitled")),
|
||||
("episodes", db.episodes, lambda d: d.get("title") or d.get("id")),
|
||||
("tracks", db.tracks, lambda d: d.get("title", "Untitled")),
|
||||
):
|
||||
docs = await coll.find({"storage_path": {"$ne": None}}, {"_id": 0}).sort("created_at", 1).to_list(20000)
|
||||
seen = {}
|
||||
for d in docs:
|
||||
key = d.get("storage_path")
|
||||
if not key:
|
||||
continue
|
||||
if key in seen:
|
||||
findings["duplicate_paths"].append({"id": d["id"], "content_type": coll_name[:-1], "label": label_fn(d), "detail": f"duplicate of {seen[key]}"})
|
||||
else:
|
||||
seen[key] = d["id"]
|
||||
|
||||
# --- Orphaned subtitles (reference a movie/episode that no longer exists) ---
|
||||
movie_ids = {m["id"] for m in movies}
|
||||
episode_ids = {e["id"] for e in episodes}
|
||||
subs = await db.subtitles.find({}, {"_id": 0}).to_list(5000)
|
||||
for s in subs:
|
||||
mid, eid = s.get("movie_id") or "", s.get("episode_id") or ""
|
||||
if mid and mid not in movie_ids:
|
||||
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"movie_id {mid} not found"})
|
||||
elif eid and eid not in episode_ids:
|
||||
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"episode_id {eid} not found"})
|
||||
|
||||
# --- Dangling watchlist/progress rows (reference a deleted movie) ---
|
||||
for coll_name, coll in (("watchlist", db.watchlist), ("progress", db.progress)):
|
||||
rows = await coll.find({}, {"_id": 0}).to_list(20000)
|
||||
for r in rows:
|
||||
if r.get("movie_id") and r["movie_id"] not in movie_ids:
|
||||
findings["dangling_refs"].append({"id": r["id"] if "id" in r else f"{r['profile_id']}:{r['movie_id']}", "content_type": coll_name, "label": r["movie_id"], "detail": f"references deleted movie {r['movie_id']}", "profile_id": r.get("profile_id"), "movie_id": r.get("movie_id")})
|
||||
|
||||
# --- Dangling episode_progress rows (reference a deleted episode) ---
|
||||
ep_rows = await db.episode_progress.find({}, {"_id": 0}).to_list(20000)
|
||||
for r in ep_rows:
|
||||
if r.get("episode_id") and r["episode_id"] not in episode_ids:
|
||||
findings["dangling_refs"].append({"id": f"{r['profile_id']}:{r['episode_id']}", "content_type": "episode_progress", "label": r["episode_id"], "detail": f"references deleted episode {r['episode_id']}", "profile_id": r.get("profile_id"), "episode_id": r.get("episode_id")})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
async def _delete_content(db, content_type: str, item_id: str) -> None:
|
||||
"""Deletes a movie/episode/track and every row that references it, so a Fix never
|
||||
leaves orphans behind for the next scan to catch on a second pass."""
|
||||
if content_type == "movie":
|
||||
await db.movies.delete_one({"id": item_id})
|
||||
await db.watchlist.delete_many({"movie_id": item_id})
|
||||
await db.progress.delete_many({"movie_id": item_id})
|
||||
await db.subtitles.delete_many({"movie_id": item_id})
|
||||
elif content_type == "episode":
|
||||
await db.episodes.delete_one({"id": item_id})
|
||||
await db.episode_progress.delete_many({"episode_id": item_id})
|
||||
await db.subtitles.delete_many({"episode_id": item_id})
|
||||
elif content_type == "track":
|
||||
await db.tracks.delete_one({"id": item_id})
|
||||
else:
|
||||
raise ValueError(f"Unknown content_type: {content_type}")
|
||||
|
||||
|
||||
async def fix_one(db, videos_dir: Path, hls_dir: Path, category: str, item: Dict) -> None:
|
||||
content_type = item.get("content_type")
|
||||
item_id = item.get("id")
|
||||
|
||||
if category == "missing_files":
|
||||
await _delete_content(db, content_type, item_id)
|
||||
|
||||
elif category == "orphaned_episodes":
|
||||
await _delete_content(db, "episode", item_id)
|
||||
|
||||
elif category == "orphaned_hls":
|
||||
shutil.rmtree(hls_dir / item_id, ignore_errors=True)
|
||||
|
||||
elif category == "stuck_jobs":
|
||||
await db.transcode_queue.update_one({"id": item_id}, {"$set": {
|
||||
"status": "failed", "error": "Reset by library cleanup (stuck with no progress)",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
}})
|
||||
|
||||
elif category == "duplicate_paths":
|
||||
await _delete_content(db, content_type, item_id)
|
||||
|
||||
elif category == "orphaned_subtitles":
|
||||
sub = await db.subtitles.find_one({"id": item_id}, {"_id": 0})
|
||||
if sub and sub.get("storage_path"):
|
||||
try:
|
||||
(Path(str(videos_dir.parent / "subtitles")) / sub["storage_path"]).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
await db.subtitles.delete_one({"id": item_id})
|
||||
|
||||
elif category == "dangling_refs":
|
||||
if content_type == "episode_progress":
|
||||
await db.episode_progress.delete_one({"profile_id": item.get("profile_id"), "episode_id": item.get("episode_id")})
|
||||
else:
|
||||
coll = {"watchlist": db.watchlist, "progress": db.progress}[content_type]
|
||||
await coll.delete_one({"profile_id": item.get("profile_id"), "movie_id": item.get("movie_id")})
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown cleanup category: {category}")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Filesystem scanner for content already sitting on the NAS mounts —
|
||||
covers movies/tv placed outside Radarr/Sonarr, and the music library
|
||||
(nothing else in StreamHoard touches music). Read-only: never writes files,
|
||||
just walks paths and reports what it finds. Callers (server.py) cross-check
|
||||
results against the DB to decide what's new vs. already imported.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
MOVIE_EXTS = {".mp4", ".mkv", ".avi", ".m4v", ".mov"}
|
||||
AUDIO_EXTS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".ogg"}
|
||||
|
||||
MOVIES_DIR = Path("/mnt/nas/video/movies")
|
||||
TV_DIR = Path("/mnt/nas/video/tv")
|
||||
MUSIC_DIR = Path("/mnt/nas/music/Music")
|
||||
|
||||
_YEAR_RE = re.compile(r"[\[\(]\s*(\d{4})\s*[\]\)]")
|
||||
_SEASON_DIR_RE = re.compile(r"season\s*(\d+)", re.IGNORECASE)
|
||||
_EPISODE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,3})")
|
||||
_TRACK_NUM_RE = re.compile(r"^(\d{1,3})[\s._-]+(.*)$")
|
||||
|
||||
|
||||
def _clean_title(name: str) -> str:
|
||||
name = _YEAR_RE.sub("", name)
|
||||
return re.sub(r"\s+", " ", name).strip(" -_.")
|
||||
|
||||
|
||||
def _parse_year(name: str) -> Optional[int]:
|
||||
m = _YEAR_RE.search(name)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _largest_video_file(files: List[Path]) -> Optional[Path]:
|
||||
vids = [f for f in files if f.suffix.lower() in MOVIE_EXTS]
|
||||
if not vids:
|
||||
return None
|
||||
return max(vids, key=lambda f: f.stat().st_size)
|
||||
|
||||
|
||||
def scan_movies() -> List[Dict]:
|
||||
"""Each movies/ entry is either a standalone video file or a folder containing one."""
|
||||
if not MOVIES_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for entry in sorted(MOVIES_DIR.iterdir()):
|
||||
if entry.name.startswith("@") or entry.name.startswith("#"):
|
||||
continue # Synology internal dirs (@eaDir, #recycle)
|
||||
if entry.is_file():
|
||||
if entry.suffix.lower() not in MOVIE_EXTS:
|
||||
continue
|
||||
title_raw = entry.stem
|
||||
file_path = entry
|
||||
elif entry.is_dir():
|
||||
try:
|
||||
files = [f for f in entry.rglob("*") if f.is_file()]
|
||||
except OSError:
|
||||
continue
|
||||
file_path = _largest_video_file(files)
|
||||
if not file_path:
|
||||
continue
|
||||
title_raw = entry.name
|
||||
else:
|
||||
continue
|
||||
out.append({
|
||||
"title": _clean_title(title_raw) or title_raw,
|
||||
"year": _parse_year(title_raw),
|
||||
"storage_path": str(file_path),
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def scan_tv() -> List[Dict]:
|
||||
"""tv/<Show>/Season N/<episode files>. Groups by show, one entry per episode."""
|
||||
if not TV_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for show_dir in sorted(TV_DIR.iterdir()):
|
||||
if not show_dir.is_dir() or show_dir.name.startswith("@") or show_dir.name.startswith("#"):
|
||||
continue
|
||||
show_title = _clean_title(show_dir.name) or show_dir.name
|
||||
for season_dir in sorted(show_dir.iterdir()):
|
||||
if not season_dir.is_dir():
|
||||
continue
|
||||
m = _SEASON_DIR_RE.search(season_dir.name)
|
||||
season_number = int(m.group(1)) if m else 1
|
||||
for f in sorted(season_dir.iterdir()):
|
||||
if not f.is_file() or f.suffix.lower() not in MOVIE_EXTS:
|
||||
continue
|
||||
em = _EPISODE_RE.search(f.stem)
|
||||
if em:
|
||||
season_number = int(em.group(1))
|
||||
episode_number = int(em.group(2))
|
||||
title = _clean_title(f.stem[em.end():]) or f"Episode {episode_number}"
|
||||
else:
|
||||
episode_number = None
|
||||
title = _clean_title(f.stem)
|
||||
out.append({
|
||||
"show_title": show_title,
|
||||
"season_number": season_number,
|
||||
"episode_number": episode_number,
|
||||
"title": title,
|
||||
"storage_path": str(f),
|
||||
"size_bytes": f.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _read_tags(path: Path) -> Dict:
|
||||
try:
|
||||
tags = MutagenFile(path, easy=True)
|
||||
except Exception:
|
||||
tags = None
|
||||
duration = 0.0
|
||||
artist = album = title = track_number = None
|
||||
if tags is not None:
|
||||
if getattr(tags, "info", None) is not None:
|
||||
duration = getattr(tags.info, "length", 0.0) or 0.0
|
||||
artist = (tags.get("artist") or [None])[0]
|
||||
album = (tags.get("album") or [None])[0]
|
||||
title = (tags.get("title") or [None])[0]
|
||||
tn = (tags.get("tracknumber") or [None])[0]
|
||||
if tn:
|
||||
try:
|
||||
track_number = int(str(tn).split("/")[0])
|
||||
except ValueError:
|
||||
track_number = None
|
||||
return {"duration": duration, "artist": artist, "album": album, "title": title, "track_number": track_number}
|
||||
|
||||
|
||||
def scan_music() -> List[Dict]:
|
||||
"""Music/<Artist>/<Album>/<NN Title.ext>. Filename/path-derived only — no file reads, so this
|
||||
stays fast even over a network share with a large library. Embedded tags (better titles, real
|
||||
duration) are read later, only for the tracks actually selected for import — see tags_for_file().
|
||||
"""
|
||||
if not MUSIC_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for artist_dir in sorted(MUSIC_DIR.iterdir()):
|
||||
if not artist_dir.is_dir() or artist_dir.name.startswith("@") or artist_dir.name.startswith("#"):
|
||||
continue
|
||||
for album_dir in sorted(artist_dir.iterdir()):
|
||||
if not album_dir.is_dir():
|
||||
continue
|
||||
for f in sorted(album_dir.iterdir()):
|
||||
if not f.is_file() or f.suffix.lower() not in AUDIO_EXTS:
|
||||
continue
|
||||
track_number = None
|
||||
title = f.stem
|
||||
fm = _TRACK_NUM_RE.match(f.stem)
|
||||
if fm:
|
||||
track_number = int(fm.group(1))
|
||||
title = fm.group(2)
|
||||
out.append({
|
||||
"artist": artist_dir.name,
|
||||
"album": album_dir.name,
|
||||
"title": _clean_title(title) or title,
|
||||
"track_number": track_number,
|
||||
"duration_seconds": 0,
|
||||
"storage_path": str(f),
|
||||
"size_bytes": f.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def tags_for_file(path_str: str) -> Dict:
|
||||
"""Embedded-tag lookup for a single file — used at import time, not during scan."""
|
||||
return _read_tags(Path(path_str))
|
||||
+61
-9
@@ -20,6 +20,11 @@ class UserLogin(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
@@ -29,15 +34,21 @@ class UserPublic(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
# ---------- Profiles ("Who's Watching") ----------
|
||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
||||
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
avatar_color: str = "#D9381E"
|
||||
is_kids: bool = False
|
||||
max_rating: str = "NR" # one of RATING_ORDER
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_admin: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- Profile (1:1 with account) ----------
|
||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
@@ -108,6 +119,29 @@ class Movie(MovieBase):
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Music ----------
|
||||
class TrackUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
album_art_url: Optional[str] = None
|
||||
|
||||
|
||||
class Track(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
title: str
|
||||
artist: str = "Unknown Artist"
|
||||
album: str = "Unknown Album"
|
||||
album_art_url: str = ""
|
||||
track_number: Optional[int] = None
|
||||
duration_seconds: float = 0
|
||||
storage_type: str = "scan" # scan is currently the only source for tracks
|
||||
storage_path: str
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Subtitles ----------
|
||||
class Subtitle(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -157,6 +191,10 @@ class RequestUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class RequestApprove(BaseModel):
|
||||
content_type: str # "movie" | "tv"
|
||||
|
||||
|
||||
class MovieRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
@@ -165,7 +203,12 @@ class MovieRequest(BaseModel):
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
notes: str = ""
|
||||
status: str = "pending"
|
||||
status: str = "pending" # pending | approved | fulfilled | rejected
|
||||
content_type: Optional[str] = None # movie | tv, set once approved
|
||||
radarr_id: Optional[int] = None
|
||||
sonarr_id: Optional[int] = None
|
||||
matched_title: Optional[str] = None
|
||||
matched_year: Optional[int] = None
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
@@ -182,6 +225,9 @@ class AppSettings(BaseModel):
|
||||
trakt_client_secret: str = ""
|
||||
auto_transcode: str = "off"
|
||||
queue_paused: bool = False
|
||||
music_itunes_fallback: bool = True
|
||||
music_acoustid_enabled: bool = False
|
||||
acoustid_api_key: str = ""
|
||||
|
||||
|
||||
class AppSettingsPublic(BaseModel):
|
||||
@@ -200,6 +246,10 @@ class AppSettingsPublic(BaseModel):
|
||||
trakt_client_secret: str = ""
|
||||
auto_transcode: str = "off"
|
||||
queue_paused: bool = False
|
||||
music_itunes_fallback: bool = True
|
||||
music_acoustid_enabled: bool = False
|
||||
acoustid_api_key: str = ""
|
||||
acoustid_configured: bool = False
|
||||
|
||||
|
||||
# ---------- Shows / Episodes (Sonarr) ----------
|
||||
@@ -323,6 +373,8 @@ class TranscodeJob(BaseModel):
|
||||
status: str = "pending"
|
||||
error: str = ""
|
||||
triggered_by: str = "manual"
|
||||
progress: float = 0.0
|
||||
superseded_by: Optional[str] = None
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""MusicBrainz + Cover Art Archive clients — both free, no API key.
|
||||
MusicBrainz rate limit is 1 req/sec for unauthenticated clients; callers must pace requests.
|
||||
"""
|
||||
import requests
|
||||
from typing import Optional, Dict
|
||||
|
||||
_HEADERS = {"User-Agent": "StreamHoard/1.0 (self-hosted media library)"}
|
||||
MB_BASE = "https://musicbrainz.org/ws/2"
|
||||
COVER_ART_BASE = "https://coverartarchive.org"
|
||||
|
||||
|
||||
def lookup_release(artist: str, album: str) -> Optional[Dict]:
|
||||
"""Best-effort match for an artist+album against MusicBrainz's release index."""
|
||||
query = f'release:"{album}" AND artist:"{artist}"'
|
||||
try:
|
||||
r = requests.get(f"{MB_BASE}/release/", params={"query": query, "fmt": "json", "limit": 1},
|
||||
headers=_HEADERS, timeout=15)
|
||||
r.raise_for_status()
|
||||
releases = (r.json() or {}).get("releases") or []
|
||||
except Exception:
|
||||
return None
|
||||
if not releases:
|
||||
return None
|
||||
rel = releases[0]
|
||||
return {
|
||||
"mbid": rel.get("id"),
|
||||
"title": rel.get("title") or album,
|
||||
"artist": (rel.get("artist-credit") or [{}])[0].get("name") or artist,
|
||||
"year": (rel.get("date") or "")[:4] or None,
|
||||
}
|
||||
|
||||
|
||||
def cover_art_url(mbid: str) -> Optional[str]:
|
||||
"""Returns the front cover URL if the Cover Art Archive has one for this release, else None."""
|
||||
url = f"{COVER_ART_BASE}/release/{mbid}/front"
|
||||
try:
|
||||
r = requests.head(url, headers=_HEADERS, timeout=10, allow_redirects=True)
|
||||
if r.status_code == 200:
|
||||
return r.url
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
@@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
|
||||
return out
|
||||
|
||||
|
||||
def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||
"""Radarr's own title lookup (proxies TMDB) — used to resolve a free-text request to a real movie."""
|
||||
base_url = base_url.rstrip("/")
|
||||
r = requests.get(f"{base_url}/api/v3/movie/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for m in r.json() or []:
|
||||
poster = ""
|
||||
for img in m.get("images", []) or []:
|
||||
if img.get("coverType") == "poster":
|
||||
poster = img.get("remoteUrl") or img.get("url") or ""
|
||||
break
|
||||
out.append({
|
||||
"tmdb_id": m.get("tmdbId"), "title": m.get("title") or "",
|
||||
"year": m.get("year"), "overview": m.get("overview") or "", "poster_url": poster,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
|
||||
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
|
||||
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill."""
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||
if not profiles or not roots:
|
||||
raise RuntimeError("Radarr has no quality profile or root folder configured")
|
||||
payload = {
|
||||
"title": title, "tmdbId": tmdb_id, "year": year or 0,
|
||||
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||
"monitored": True, "addOptions": {"searchForMovie": True},
|
||||
}
|
||||
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
|
||||
r.raise_for_status()
|
||||
added = r.json()
|
||||
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||
|
||||
|
||||
def movie_status(base_url: str, api_key: str, radarr_id: int) -> Dict:
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
m = requests.get(f"{base_url}/api/v3/movie/{radarr_id}", headers=h, timeout=15).json()
|
||||
if m.get("hasFile"):
|
||||
return {"state": "downloaded"}
|
||||
q = requests.get(f"{base_url}/api/v3/queue", params={"movieIds": radarr_id}, headers=h, timeout=15).json()
|
||||
items = q.get("records") if isinstance(q, dict) else q
|
||||
if items:
|
||||
it = items[0]
|
||||
size = it.get("size") or 0
|
||||
sizeleft = it.get("sizeleft") or 0
|
||||
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||
return {"state": "searching"}
|
||||
|
||||
|
||||
def test_connection(base_url: str, api_key: str) -> bool:
|
||||
base_url = base_url.rstrip("/")
|
||||
try:
|
||||
|
||||
@@ -24,4 +24,5 @@ numpy>=1.26.0
|
||||
python-multipart>=0.0.9
|
||||
jq>=1.6.0
|
||||
typer>=0.9.0
|
||||
emergentintegrations==0.1.0
|
||||
docker>=7.1.0
|
||||
mutagen>=1.47.0
|
||||
|
||||
+681
-80
@@ -5,7 +5,7 @@ import mimetypes
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
from typing import List, Optional, Dict
|
||||
|
||||
from fastapi import FastAPI, APIRouter, HTTPException, Depends, UploadFile, File, Form, Header, Query, Request, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse, FileResponse, Response
|
||||
@@ -15,26 +15,34 @@ from starlette.middleware.cors import CORSMiddleware
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
|
||||
from models import (
|
||||
UserCreate, UserLogin, UserPublic, TokenResponse,
|
||||
Profile, ProfileCreate, ProfileUpdate, RATING_ORDER,
|
||||
UserCreate, UserLogin, UserPublic, TokenResponse, PasswordChange,
|
||||
AdminUserCreate, AdminUserUpdate,
|
||||
Profile, ProfileUpdate, RATING_ORDER,
|
||||
Movie, MovieCreate, MovieUpdate,
|
||||
Subtitle,
|
||||
WatchlistItem, ProgressUpsert,
|
||||
RequestCreate, RequestUpdate, MovieRequest,
|
||||
RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
|
||||
AppSettings, AppSettingsPublic,
|
||||
TMDBSearchResult, RadarrMovieDTO,
|
||||
TranscodeJob, QueuePauseToggle,
|
||||
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
|
||||
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
|
||||
Track, TrackUpdate,
|
||||
)
|
||||
from auth import hash_password, verify_password, create_token, decode_token
|
||||
from seed import SAMPLE_MOVIES
|
||||
import tmdb as tmdb_client
|
||||
import radarr as radarr_client
|
||||
import sonarr as sonarr_client
|
||||
import services as services_client
|
||||
import library_scan
|
||||
import library_cleanup
|
||||
import musicbrainz as musicbrainz_client
|
||||
import itunes as itunes_client
|
||||
import acoustid as acoustid_client
|
||||
import trakt as trakt_client
|
||||
import opensubtitles as opensubs_client
|
||||
from transcode import transcode_quick, transcode_abr, srt_to_vtt
|
||||
from transcode import transcode_quick, transcode_abr, transcode_audio_fix, srt_to_vtt
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).parent
|
||||
@@ -57,10 +65,17 @@ for d in (VIDEOS_DIR, POSTERS_DIR, SUBS_DIR, HLS_DIR):
|
||||
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
app = FastAPI(title="Kino")
|
||||
app = FastAPI(title="StreamHoard")
|
||||
api = APIRouter(prefix="/api")
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
APP_VERSION = (ROOT_DIR / "VERSION").read_text().strip() if (ROOT_DIR / "VERSION").is_file() else "0.0.0"
|
||||
|
||||
|
||||
@api.get("/version")
|
||||
async def get_version():
|
||||
return {"version": APP_VERSION}
|
||||
|
||||
|
||||
# ---------- Auth deps ----------
|
||||
async def get_current_user(creds: Optional[HTTPAuthorizationCredentials] = Depends(bearer)) -> dict:
|
||||
@@ -114,7 +129,7 @@ async def on_startup():
|
||||
await db.profiles.create_index("user_id")
|
||||
await db.subtitles.create_index("movie_id")
|
||||
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@kino.local")
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@streamhoard.local")
|
||||
if not await db.users.find_one({"email": admin_email}):
|
||||
admin_user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
@@ -235,25 +250,88 @@ async def me(user: dict = Depends(get_current_user)):
|
||||
return _user_public(user)
|
||||
|
||||
|
||||
# ============ PROFILES ============
|
||||
@api.get("/profiles", response_model=List[Profile])
|
||||
async def list_profiles(user: dict = Depends(get_current_user)):
|
||||
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(20)
|
||||
@api.post("/auth/change-password")
|
||||
async def change_password(payload: PasswordChange, user: dict = Depends(get_current_user)):
|
||||
full_user = await db.users.find_one({"id": user["id"]})
|
||||
if not full_user or not verify_password(payload.current_password, full_user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="Current password is incorrect")
|
||||
if len(payload.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="New password must be at least 8 characters")
|
||||
await db.users.update_one(
|
||||
{"id": user["id"]}, {"$set": {"password_hash": hash_password(payload.new_password)}}
|
||||
)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ USERS (admin) ============
|
||||
@api.get("/admin/users", response_model=List[UserPublic])
|
||||
async def admin_list_users(user: dict = Depends(require_admin)):
|
||||
docs = await db.users.find({}, {"_id": 0, "password_hash": 0}).sort("created_at", 1).to_list(500)
|
||||
return docs
|
||||
|
||||
|
||||
@api.post("/profiles", response_model=Profile)
|
||||
async def create_profile(payload: ProfileCreate, user: dict = Depends(get_current_user)):
|
||||
count = await db.profiles.count_documents({"user_id": user["id"]})
|
||||
if count >= 5:
|
||||
raise HTTPException(status_code=400, detail="Max 5 profiles per account")
|
||||
prof = Profile(user_id=user["id"], **payload.model_dump()).model_dump()
|
||||
await db.profiles.insert_one(prof)
|
||||
return _strip(prof)
|
||||
@api.post("/admin/users", response_model=UserPublic)
|
||||
async def admin_create_user(payload: AdminUserCreate, user: dict = Depends(require_admin)):
|
||||
if await db.users.find_one({"email": payload.email.lower()}):
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
new_user = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"email": payload.email.lower(),
|
||||
"name": payload.name,
|
||||
"password_hash": hash_password(payload.password),
|
||||
"is_admin": payload.is_admin,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
await db.users.insert_one(new_user)
|
||||
await db.profiles.insert_one(Profile(user_id=new_user["id"], name=new_user["name"]).model_dump())
|
||||
return _user_public(new_user)
|
||||
|
||||
|
||||
@api.patch("/admin/users/{user_id}", response_model=UserPublic)
|
||||
async def admin_update_user(user_id: str, payload: AdminUserUpdate, user: dict = Depends(require_admin)):
|
||||
target = await db.users.find_one({"id": user_id})
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
updates = {}
|
||||
if payload.name is not None:
|
||||
updates["name"] = payload.name
|
||||
if payload.is_admin is not None:
|
||||
if user_id == user["id"] and not payload.is_admin:
|
||||
raise HTTPException(status_code=400, detail="Cannot remove your own admin access")
|
||||
updates["is_admin"] = payload.is_admin
|
||||
if payload.password:
|
||||
if len(payload.password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
updates["password_hash"] = hash_password(payload.password)
|
||||
if updates:
|
||||
await db.users.update_one({"id": user_id}, {"$set": updates})
|
||||
target = await db.users.find_one({"id": user_id})
|
||||
return _user_public(target)
|
||||
|
||||
|
||||
@api.delete("/admin/users/{user_id}")
|
||||
async def admin_delete_user(user_id: str, user: dict = Depends(require_admin)):
|
||||
if user_id == user["id"]:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete your own account")
|
||||
target = await db.users.find_one({"id": user_id})
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.users.delete_one({"id": user_id})
|
||||
await db.profiles.delete_many({"user_id": user_id})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ PROFILE (1:1 with account — no separate sub-profile management) ============
|
||||
@api.get("/profiles", response_model=List[Profile])
|
||||
async def list_profiles(user: dict = Depends(get_current_user)):
|
||||
"""Always exactly one profile per account; get_active_profile auto-creates it if missing."""
|
||||
docs = await db.profiles.find({"user_id": user["id"]}, {"_id": 0}).sort("created_at", 1).to_list(1)
|
||||
return docs
|
||||
|
||||
|
||||
@api.patch("/profiles/{pid}", response_model=Profile)
|
||||
async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(get_current_user)):
|
||||
"""Edit your own single profile (avatar color, kids mode, rating cap) — not a multi-profile switcher."""
|
||||
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
@@ -267,18 +345,6 @@ async def update_profile(pid: str, payload: ProfileUpdate, user: dict = Depends(
|
||||
return res
|
||||
|
||||
|
||||
@api.delete("/profiles/{pid}")
|
||||
async def delete_profile(pid: str, user: dict = Depends(get_current_user)):
|
||||
if await db.profiles.count_documents({"user_id": user["id"]}) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the only profile")
|
||||
res = await db.profiles.delete_one({"id": pid, "user_id": user["id"]})
|
||||
if res.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
await db.watchlist.delete_many({"profile_id": pid})
|
||||
await db.progress.delete_many({"profile_id": pid})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ MOVIES ============
|
||||
def _movie_filter_for_profile(profile: dict) -> dict:
|
||||
cap = profile.get("max_rating", "NR")
|
||||
@@ -436,11 +502,11 @@ async def stream_movie(
|
||||
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
|
||||
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
|
||||
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Movie has no local file; use video_url directly")
|
||||
|
||||
if movie.get("storage_type") == "radarr":
|
||||
file_path = Path(movie["storage_path"]) # absolute path on Radarr-mounted storage
|
||||
if movie.get("storage_type") in ("radarr", "scan"):
|
||||
file_path = Path(movie["storage_path"]) # absolute path on NAS-mounted storage
|
||||
else:
|
||||
file_path = VIDEOS_DIR / movie["storage_path"]
|
||||
if not file_path.is_file():
|
||||
@@ -502,7 +568,7 @@ async def _process_job(job: dict):
|
||||
{"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
|
||||
)
|
||||
return
|
||||
if doc.get("storage_type") not in ("local", "radarr", "sonarr") or not doc.get("storage_path"):
|
||||
if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
|
||||
await db.transcode_queue.update_one(
|
||||
{"id": job["id"]},
|
||||
{"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
|
||||
@@ -510,7 +576,7 @@ async def _process_job(job: dict):
|
||||
return
|
||||
|
||||
source = (
|
||||
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr")
|
||||
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr", "scan")
|
||||
else VIDEOS_DIR / doc["storage_path"]
|
||||
)
|
||||
out_dir = HLS_DIR / job["movie_id"]
|
||||
@@ -519,15 +585,19 @@ async def _process_job(job: dict):
|
||||
|
||||
last_error = {"msg": ""}
|
||||
|
||||
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
|
||||
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None, progress: Optional[float] = None):
|
||||
update = {"hls_status": status}
|
||||
if status == "done" and entry: update["hls_path"] = entry
|
||||
await coll.update_one({"id": job["movie_id"]}, {"$set": update})
|
||||
if error: last_error["msg"] = error
|
||||
if progress is not None:
|
||||
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"progress": progress}})
|
||||
|
||||
try:
|
||||
if job["quality"] == "abr":
|
||||
await transcode_abr(source, out_dir, cb)
|
||||
elif job["quality"] == "audio":
|
||||
await transcode_audio_fix(source, out_dir, cb)
|
||||
else:
|
||||
await transcode_quick(source, out_dir, cb)
|
||||
except Exception as e:
|
||||
@@ -591,14 +661,14 @@ async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str =
|
||||
|
||||
@api.post("/movies/{movie_id}/transcode")
|
||||
async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
|
||||
"""Body: {"quality": "quick"|"abr"} — default "quick". Adds to background queue."""
|
||||
"""Body: {"quality": "quick"|"abr"|"audio"} — default "quick". Adds to background queue."""
|
||||
quality = (payload or {}).get("quality", "quick")
|
||||
if quality not in ("quick", "abr"):
|
||||
raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'")
|
||||
if quality not in ("quick", "abr", "audio"):
|
||||
raise HTTPException(status_code=400, detail="quality must be 'quick', 'abr', or 'audio'")
|
||||
movie = await db.movies.find_one({"id": movie_id}, {"_id": 0})
|
||||
if not movie: raise HTTPException(status_code=404, detail="Movie not found")
|
||||
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
|
||||
if movie.get("storage_type") not in ("local", "radarr", "scan") or not movie.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Only local/radarr/scanned movies can be transcoded")
|
||||
if movie.get("hls_status") in ("running", "pending"):
|
||||
raise HTTPException(status_code=409, detail="Already transcoding or queued")
|
||||
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
|
||||
@@ -615,10 +685,18 @@ async def list_queue(status: Optional[str] = None, user: dict = Depends(require_
|
||||
return rows
|
||||
|
||||
|
||||
@api.get("/transcode/queue/running", response_model=Optional[TranscodeJob])
|
||||
async def get_running_job(user: dict = Depends(require_admin)):
|
||||
"""The currently-running job, independent of the paginated/sorted list above — with a
|
||||
large backlog the FIFO worker often processes a job much older than the newest 500 rows,
|
||||
which would otherwise never surface in the main list's created_at-desc window."""
|
||||
return await db.transcode_queue.find_one({"status": "running"}, {"_id": 0})
|
||||
|
||||
|
||||
@api.get("/transcode/queue/stats")
|
||||
async def queue_stats(user: dict = Depends(require_admin)):
|
||||
pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]
|
||||
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0}
|
||||
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0, "superseded": 0}
|
||||
async for row in db.transcode_queue.aggregate(pipeline):
|
||||
out[row["_id"]] = row["n"]
|
||||
s = await get_settings()
|
||||
@@ -635,8 +713,9 @@ async def cancel_job(job_id: str, user: dict = Depends(require_admin)):
|
||||
raise HTTPException(status_code=400, detail="Cannot cancel a running job")
|
||||
await db.transcode_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
|
||||
if job["status"] == "pending":
|
||||
# Reset movie hls_status AND clear stale hls_path so the row no longer shows as transcoded
|
||||
await db.movies.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
|
||||
# Reset hls_status AND clear stale hls_path so the row no longer shows as transcoded
|
||||
coll = db.movies if job.get("content_type", "movie") == "movie" else db.episodes
|
||||
await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -646,10 +725,50 @@ async def retry_job(job_id: str, user: dict = Depends(require_admin)):
|
||||
if not job: raise HTTPException(status_code=404, detail="Job not found")
|
||||
if job["status"] not in ("failed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
|
||||
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"))
|
||||
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
|
||||
if new_job["id"] != job["id"]:
|
||||
# Keep the original row for the audit trail — mark it superseded rather than deleting,
|
||||
# so cancelled/failed history never silently disappears from the list.
|
||||
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
|
||||
return {"ok": True, "job_id": new_job["id"]}
|
||||
|
||||
|
||||
@api.post("/transcode/queue/retry-failed")
|
||||
async def retry_all_failed(user: dict = Depends(require_admin)):
|
||||
jobs = await db.transcode_queue.find({"status": {"$in": ["failed", "cancelled"]}}, {"_id": 0}).to_list(None)
|
||||
retried = 0
|
||||
for job in jobs:
|
||||
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"), content_type=job.get("content_type", "movie"))
|
||||
if new_job["id"] != job["id"]:
|
||||
await db.transcode_queue.update_one({"id": job["id"]}, {"$set": {"status": "superseded", "superseded_by": new_job["id"]}})
|
||||
retried += 1
|
||||
return {"ok": True, "retried": retried}
|
||||
|
||||
|
||||
@api.post("/transcode/queue/fix-audio-all")
|
||||
async def fix_audio_all(user: dict = Depends(require_admin)):
|
||||
"""Replaces every still-pending job (not yet started) with a fast audio-only fix job for
|
||||
the same content — for libraries where the source audio is AC3/DTS/EAC3 (unplayable in any
|
||||
browser), this gets working sound out much sooner than waiting on the full ABR backlog to
|
||||
catch up, since only the audio track gets re-encoded rather than the whole video ladder.
|
||||
Jobs already running are left alone; queue a full ABR pass later for real multi-bitrate
|
||||
quality once there's no urgency."""
|
||||
pending_jobs = await db.transcode_queue.find({"status": "pending"}, {"_id": 0}).to_list(5000)
|
||||
cancelled = 0
|
||||
for j in pending_jobs:
|
||||
await db.transcode_queue.update_one({"id": j["id"]}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
|
||||
cancelled += 1
|
||||
|
||||
queued = 0
|
||||
for coll, content_type in ((db.movies, "movie"), (db.episodes, "episode")):
|
||||
async for doc in coll.find({"hls_status": {"$ne": "done"}}, {"_id": 0, "id": 1, "storage_type": 1, "storage_path": 1}):
|
||||
if doc.get("storage_type") not in ("local", "radarr", "sonarr", "scan") or not doc.get("storage_path"):
|
||||
continue
|
||||
await _enqueue_transcode(doc["id"], "audio", triggered_by="auto", content_type=content_type)
|
||||
queued += 1
|
||||
return {"ok": True, "cancelled": cancelled, "queued": queued}
|
||||
|
||||
|
||||
@api.post("/transcode/queue/clear")
|
||||
async def clear_finished(user: dict = Depends(require_admin)):
|
||||
res = await db.transcode_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled"]}})
|
||||
@@ -863,6 +982,85 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
|
||||
return res
|
||||
|
||||
|
||||
@api.post("/requests/{request_id}/approve", response_model=MovieRequest)
|
||||
async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)):
|
||||
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate
|
||||
search enabled, and marks the request approved. Content actually lands via the normal
|
||||
Radarr/Sonarr download pipeline — this only kicks that off."""
|
||||
if payload.content_type not in ("movie", "tv"):
|
||||
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
|
||||
# Atomically claim the request so two concurrent approve calls (double-click, two admins)
|
||||
# can't both pass a check-then-act race and each add the content to Radarr/Sonarr.
|
||||
req = await db.requests.find_one_and_update(
|
||||
{"id": request_id, "status": "pending"}, {"$set": {"status": "approving"}},
|
||||
projection={"_id": 0}, return_document=False,
|
||||
)
|
||||
if not req:
|
||||
existing = await db.requests.find_one({"id": request_id}, {"_id": 0, "status": 1})
|
||||
if not existing: raise HTTPException(status_code=404, detail="Request not found")
|
||||
raise HTTPException(status_code=409, detail=f"Request is already {existing['status']}")
|
||||
s = await get_settings()
|
||||
|
||||
try:
|
||||
if payload.content_type == "movie":
|
||||
if not s.get("radarr_url") or not s.get("radarr_api_key"):
|
||||
raise HTTPException(status_code=400, detail="Radarr not configured")
|
||||
try:
|
||||
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
|
||||
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="No match found in Radarr")
|
||||
try:
|
||||
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
|
||||
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
|
||||
"matched_title": added.get("title"), "matched_year": added.get("year")}
|
||||
else:
|
||||
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
|
||||
raise HTTPException(status_code=400, detail="Sonarr not configured")
|
||||
try:
|
||||
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
|
||||
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="No match found in Sonarr")
|
||||
try:
|
||||
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
|
||||
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
|
||||
"matched_title": added.get("title"), "matched_year": added.get("year")}
|
||||
except HTTPException:
|
||||
# Release the claim so a fixable failure (e.g. Radarr temporarily down) can be retried,
|
||||
# instead of leaving the request stuck in "approving" forever.
|
||||
await db.requests.update_one({"id": request_id}, {"$set": {"status": "pending"}})
|
||||
raise
|
||||
|
||||
res = await db.requests.find_one_and_update(
|
||||
{"id": request_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
return res
|
||||
|
||||
|
||||
@api.get("/requests/{request_id}/status")
|
||||
async def request_download_status(request_id: str, user: dict = Depends(require_admin)):
|
||||
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
|
||||
if not req: raise HTTPException(status_code=404, detail="Request not found")
|
||||
s = await get_settings()
|
||||
try:
|
||||
if req.get("radarr_id") and s.get("radarr_url") and s.get("radarr_api_key"):
|
||||
return await asyncio.to_thread(radarr_client.movie_status, s["radarr_url"], s["radarr_api_key"], req["radarr_id"])
|
||||
if req.get("sonarr_id") and s.get("sonarr_url") and s.get("sonarr_api_key"):
|
||||
return await asyncio.to_thread(sonarr_client.series_status, s["sonarr_url"], s["sonarr_api_key"], req["sonarr_id"])
|
||||
except Exception as e:
|
||||
return {"state": "unknown", "error": str(e)}
|
||||
return {"state": req.get("status", "pending")}
|
||||
|
||||
|
||||
# ============ SETTINGS (admin) ============
|
||||
@api.get("/settings", response_model=AppSettingsPublic)
|
||||
async def read_settings(user: dict = Depends(require_admin)):
|
||||
@@ -883,6 +1081,10 @@ async def read_settings(user: dict = Depends(require_admin)):
|
||||
trakt_client_secret=s.get("trakt_client_secret", ""),
|
||||
auto_transcode=s.get("auto_transcode", "off"),
|
||||
queue_paused=bool(s.get("queue_paused", False)),
|
||||
music_itunes_fallback=bool(s.get("music_itunes_fallback", True)),
|
||||
music_acoustid_enabled=bool(s.get("music_acoustid_enabled", False)),
|
||||
acoustid_api_key=s.get("acoustid_api_key", ""),
|
||||
acoustid_configured=bool(s.get("acoustid_api_key")),
|
||||
)
|
||||
|
||||
|
||||
@@ -907,6 +1109,10 @@ async def update_settings(payload: AppSettings, user: dict = Depends(require_adm
|
||||
trakt_client_secret=doc.get("trakt_client_secret", ""),
|
||||
auto_transcode=doc.get("auto_transcode", "off"),
|
||||
queue_paused=bool(doc.get("queue_paused", False)),
|
||||
music_itunes_fallback=bool(doc.get("music_itunes_fallback", True)),
|
||||
music_acoustid_enabled=bool(doc.get("music_acoustid_enabled", False)),
|
||||
acoustid_api_key=doc.get("acoustid_api_key", ""),
|
||||
acoustid_configured=bool(doc.get("acoustid_api_key")),
|
||||
)
|
||||
|
||||
|
||||
@@ -1075,12 +1281,12 @@ async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_ad
|
||||
async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin)):
|
||||
ids = payload.get("episode_ids") or []
|
||||
quality = payload.get("quality", "abr")
|
||||
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
|
||||
if quality not in ("quick", "abr", "audio"): raise HTTPException(status_code=400, detail="Invalid quality")
|
||||
queued = 0
|
||||
for eid in ids:
|
||||
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
|
||||
if not ep or ep.get("hls_status") in ("running", "pending"): continue
|
||||
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue
|
||||
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"): continue
|
||||
await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode")
|
||||
queued += 1
|
||||
return {"ok": True, "queued": queued}
|
||||
@@ -1109,11 +1315,11 @@ async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)):
|
||||
@api.post("/episodes/{episode_id}/transcode")
|
||||
async def transcode_episode(episode_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
|
||||
quality = (payload or {}).get("quality", "abr")
|
||||
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
|
||||
if quality not in ("quick", "abr", "audio"): raise HTTPException(status_code=400, detail="Invalid quality")
|
||||
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
|
||||
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
|
||||
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded")
|
||||
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Only local/sonarr/scanned episodes can be transcoded")
|
||||
if ep.get("hls_status") in ("running", "pending"):
|
||||
raise HTTPException(status_code=409, detail="Already transcoding or queued")
|
||||
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
|
||||
@@ -1131,9 +1337,9 @@ async def stream_episode(
|
||||
decode_token(token)
|
||||
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
|
||||
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
|
||||
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
|
||||
if ep.get("storage_type") not in ("local", "sonarr", "scan") or not ep.get("storage_path"):
|
||||
raise HTTPException(status_code=400, detail="Episode has no local file")
|
||||
file_path = Path(ep["storage_path"]) if ep["storage_type"] == "sonarr" else VIDEOS_DIR / ep["storage_path"]
|
||||
file_path = Path(ep["storage_path"]) if ep["storage_type"] in ("sonarr", "scan") else VIDEOS_DIR / ep["storage_path"]
|
||||
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing")
|
||||
file_size = file_path.stat().st_size
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or "video/mp4"
|
||||
@@ -1237,6 +1443,7 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
|
||||
all_series = await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"])
|
||||
by_id = {x["sonarr_id"]: x for x in all_series}
|
||||
created_shows = 0; created_eps = 0
|
||||
auto = s.get("auto_transcode", "off")
|
||||
for sid in sonarr_ids:
|
||||
m = by_id.get(int(sid))
|
||||
if not m: continue
|
||||
@@ -1269,6 +1476,8 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
|
||||
sonarr_episode_id=ep["sonarr_episode_id"],
|
||||
).model_dump()
|
||||
await db.episodes.insert_one(edoc); created_eps += 1
|
||||
if auto in ("quick", "abr"):
|
||||
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode")
|
||||
# Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers)
|
||||
show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1})
|
||||
if show_doc and show_doc.get("tmdb_id"):
|
||||
@@ -1283,6 +1492,357 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
|
||||
return {"ok": True, "shows_imported": created_shows, "episodes_imported": created_eps}
|
||||
|
||||
|
||||
# ============ LIBRARY SCAN (filesystem — separate from Radarr/Sonarr) ============
|
||||
@api.get("/scan/movies")
|
||||
async def scan_movies(user: dict = Depends(require_admin)):
|
||||
found = await asyncio.to_thread(library_scan.scan_movies)
|
||||
imported_paths = set(await db.movies.distinct("storage_path", {"storage_type": {"$in": ["radarr", "scan", "local"]}}))
|
||||
for item in found:
|
||||
item["already_imported"] = item["storage_path"] in imported_paths
|
||||
return found
|
||||
|
||||
|
||||
@api.get("/scan/tv")
|
||||
async def scan_tv(user: dict = Depends(require_admin)):
|
||||
found = await asyncio.to_thread(library_scan.scan_tv)
|
||||
imported_paths = set(await db.episodes.distinct("storage_path", {"storage_type": {"$in": ["sonarr", "scan", "local"]}}))
|
||||
for item in found:
|
||||
item["already_imported"] = item["storage_path"] in imported_paths
|
||||
return found
|
||||
|
||||
|
||||
@api.get("/scan/music")
|
||||
async def scan_music(user: dict = Depends(require_admin)):
|
||||
found = await asyncio.to_thread(library_scan.scan_music)
|
||||
imported_paths = set(await db.tracks.distinct("storage_path"))
|
||||
for item in found:
|
||||
item["already_imported"] = item["storage_path"] in imported_paths
|
||||
return found
|
||||
|
||||
|
||||
@api.post("/scan/import")
|
||||
async def scan_import(payload: dict, user: dict = Depends(require_admin)):
|
||||
"""Body: {kind: "movies"|"tv"|"music", items: [<scan result dicts as returned by GET /scan/*>]}"""
|
||||
kind = payload.get("kind")
|
||||
items = payload.get("items") or []
|
||||
if kind not in ("movies", "tv", "music"):
|
||||
raise HTTPException(status_code=400, detail="kind must be movies, tv, or music")
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="No items provided")
|
||||
|
||||
s = await get_settings()
|
||||
auto = s.get("auto_transcode", "off")
|
||||
created = 0
|
||||
|
||||
if kind == "movies":
|
||||
for it in items:
|
||||
if await db.movies.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
|
||||
continue
|
||||
movie = Movie(
|
||||
title=it.get("title") or "Untitled", year=it.get("year") or 2024,
|
||||
storage_type="scan", storage_path=it["storage_path"],
|
||||
).model_dump()
|
||||
await db.movies.insert_one(movie)
|
||||
if auto in ("quick", "abr"):
|
||||
await _enqueue_transcode(movie["id"], auto, triggered_by="auto")
|
||||
created += 1
|
||||
|
||||
elif kind == "tv":
|
||||
shows_by_title: Dict[str, str] = {}
|
||||
for it in items:
|
||||
if await db.episodes.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
|
||||
continue
|
||||
show_title = it.get("show_title") or "Untitled"
|
||||
show_id = shows_by_title.get(show_title)
|
||||
if not show_id:
|
||||
existing = await db.shows.find_one({"title": show_title}, {"_id": 0})
|
||||
if existing:
|
||||
show_id = existing["id"]
|
||||
else:
|
||||
show_doc = Show(title=show_title, year=2024, rating="NR").model_dump()
|
||||
await db.shows.insert_one(show_doc)
|
||||
show_id = show_doc["id"]
|
||||
shows_by_title[show_title] = show_id
|
||||
edoc = Episode(
|
||||
show_id=show_id, season_number=it.get("season_number") or 1,
|
||||
episode_number=it.get("episode_number") or 0,
|
||||
title=it.get("title") or "", storage_type="scan", storage_path=it["storage_path"],
|
||||
).model_dump()
|
||||
await db.episodes.insert_one(edoc)
|
||||
if auto in ("quick", "abr"):
|
||||
await _enqueue_transcode(edoc["id"], auto, triggered_by="auto", content_type="episode")
|
||||
created += 1
|
||||
for show_id in set(shows_by_title.values()):
|
||||
seasons = await db.episodes.distinct("season_number", {"show_id": show_id})
|
||||
ecount = await db.episodes.count_documents({"show_id": show_id})
|
||||
await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}})
|
||||
|
||||
elif kind == "music":
|
||||
for it in items:
|
||||
if await db.tracks.find_one({"storage_path": it["storage_path"]}, {"_id": 1}):
|
||||
continue
|
||||
tags = await asyncio.to_thread(library_scan.tags_for_file, it["storage_path"])
|
||||
track = Track(
|
||||
title=tags.get("title") or it.get("title") or "Untitled",
|
||||
artist=tags.get("artist") or it.get("artist") or "Unknown Artist",
|
||||
album=tags.get("album") or it.get("album") or "Unknown Album",
|
||||
track_number=tags.get("track_number") or it.get("track_number"),
|
||||
duration_seconds=round(tags.get("duration") or 0, 1), storage_path=it["storage_path"],
|
||||
).model_dump()
|
||||
await db.tracks.insert_one(track)
|
||||
created += 1
|
||||
|
||||
return {"ok": True, "imported": created}
|
||||
|
||||
|
||||
# ============ LIBRARY CLEANUP (admin) ============
|
||||
_cleanup_scan_status = {"running": False, "findings": None, "error": "", "scan_id": 0}
|
||||
|
||||
|
||||
async def _run_cleanup_scan(scan_id: int):
|
||||
try:
|
||||
findings = await library_cleanup.scan(db, VIDEOS_DIR, HLS_DIR)
|
||||
if _cleanup_scan_status["scan_id"] == scan_id:
|
||||
_cleanup_scan_status["findings"] = findings
|
||||
except Exception as e:
|
||||
if _cleanup_scan_status["scan_id"] == scan_id:
|
||||
_cleanup_scan_status["error"] = str(e)
|
||||
finally:
|
||||
if _cleanup_scan_status["scan_id"] == scan_id:
|
||||
_cleanup_scan_status["running"] = False
|
||||
|
||||
|
||||
@api.post("/library/cleanup/scan/start")
|
||||
async def library_cleanup_scan_start(user: dict = Depends(require_admin)):
|
||||
if _cleanup_scan_status["running"]:
|
||||
raise HTTPException(status_code=409, detail="Scan already running")
|
||||
_cleanup_scan_status["scan_id"] += 1
|
||||
_cleanup_scan_status.update(running=True, findings=None, error="")
|
||||
asyncio.create_task(_run_cleanup_scan(_cleanup_scan_status["scan_id"]))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/library/cleanup/scan/status")
|
||||
async def library_cleanup_scan_status(user: dict = Depends(require_admin)):
|
||||
return _cleanup_scan_status
|
||||
|
||||
|
||||
@api.post("/library/cleanup/scan/stop")
|
||||
async def library_cleanup_scan_stop(user: dict = Depends(require_admin)):
|
||||
# The already-dispatched file checks finish naturally in the background (there's no clean
|
||||
# way to interrupt a ThreadPoolExecutor mid-flight), but bumping scan_id means whenever they
|
||||
# do finish, _run_cleanup_scan sees a stale id and discards the result instead of surfacing it.
|
||||
_cleanup_scan_status["scan_id"] += 1
|
||||
_cleanup_scan_status["running"] = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.post("/library/cleanup/fix")
|
||||
async def library_cleanup_fix(payload: dict, user: dict = Depends(require_admin)):
|
||||
"""Body: {category: str, items: [<finding dicts as returned by GET /library/cleanup/scan>]}"""
|
||||
category = payload.get("category")
|
||||
items = payload.get("items") or []
|
||||
if not items:
|
||||
raise HTTPException(status_code=400, detail="No items provided")
|
||||
fixed = 0
|
||||
for item in items:
|
||||
try:
|
||||
await library_cleanup.fix_one(db, VIDEOS_DIR, HLS_DIR, category, item)
|
||||
fixed += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Library cleanup fix failed for {category}/{item.get('id')}: {e}")
|
||||
return {"ok": True, "fixed": fixed}
|
||||
|
||||
|
||||
# ============ MUSIC ============
|
||||
@api.get("/tracks", response_model=List[Track])
|
||||
async def list_tracks(user: dict = Depends(get_current_user)):
|
||||
docs = await db.tracks.find({}, {"_id": 0}).sort([("artist", 1), ("album", 1), ("track_number", 1)]).to_list(10000)
|
||||
return docs
|
||||
|
||||
|
||||
@api.patch("/tracks/{track_id}", response_model=Track)
|
||||
async def update_track(track_id: str, payload: TrackUpdate, user: dict = Depends(require_admin)):
|
||||
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
|
||||
if not updates:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
res = await db.tracks.find_one_and_update(
|
||||
{"id": track_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
|
||||
)
|
||||
if not res:
|
||||
raise HTTPException(status_code=404, detail="Track not found")
|
||||
return res
|
||||
|
||||
|
||||
@api.delete("/tracks/{track_id}")
|
||||
async def delete_track(track_id: str, user: dict = Depends(require_admin)):
|
||||
res = await db.tracks.delete_one({"id": track_id})
|
||||
if res.deleted_count == 0:
|
||||
raise HTTPException(status_code=404, detail="Track not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
_music_enrich_status = {"running": False, "total": 0, "done": 0, "updated": 0, "error": ""}
|
||||
|
||||
|
||||
async def _run_music_enrich():
|
||||
_music_enrich_status.update(running=True, total=0, done=0, updated=0, error="")
|
||||
try:
|
||||
settings = await get_settings()
|
||||
itunes_fallback = settings.get("music_itunes_fallback", True)
|
||||
pairs = await db.tracks.aggregate([
|
||||
{"$match": {"album_art_url": {"$in": [None, ""]}}},
|
||||
{"$group": {"_id": {"artist": "$artist", "album": "$album"}}},
|
||||
]).to_list(5000)
|
||||
_music_enrich_status["total"] = len(pairs)
|
||||
for p in pairs:
|
||||
artist = p["_id"]["artist"]; album = p["_id"]["album"]
|
||||
try:
|
||||
art_url = None
|
||||
release = await asyncio.to_thread(musicbrainz_client.lookup_release, artist, album)
|
||||
if release and release.get("mbid"):
|
||||
art_url = await asyncio.to_thread(musicbrainz_client.cover_art_url, release["mbid"])
|
||||
if not art_url and itunes_fallback:
|
||||
# MusicBrainz has no release, or CAA has no art for it — iTunes needs no
|
||||
# key/rate-limit pacing, so it's a free second attempt before giving up.
|
||||
itunes_match = await asyncio.to_thread(itunes_client.lookup_album, artist, album)
|
||||
if itunes_match and itunes_match.get("art_url"):
|
||||
art_url = itunes_match["art_url"]
|
||||
if art_url:
|
||||
res = await db.tracks.update_many(
|
||||
{"artist": artist, "album": album, "album_art_url": {"$in": [None, ""]}},
|
||||
{"$set": {"album_art_url": art_url}},
|
||||
)
|
||||
_music_enrich_status["updated"] += res.modified_count
|
||||
except Exception as e:
|
||||
logger.warning(f"Music enrich failed for {artist} - {album}: {e}")
|
||||
_music_enrich_status["done"] += 1
|
||||
await asyncio.sleep(1.1) # MusicBrainz rate limit: 1 req/sec, unauthenticated
|
||||
except Exception as e:
|
||||
_music_enrich_status["error"] = str(e)
|
||||
finally:
|
||||
_music_enrich_status["running"] = False
|
||||
|
||||
|
||||
_acoustid_status = {"running": False, "total": 0, "done": 0, "identified": 0, "error": ""}
|
||||
|
||||
|
||||
async def _run_acoustid_identify():
|
||||
"""Fingerprints tracks with missing/placeholder artist or album and identifies them via
|
||||
AcoustID — for files with no usable tags to text-search on. Filling in artist/album lets
|
||||
the normal MusicBrainz/iTunes art enrichment pick them up afterward."""
|
||||
_acoustid_status.update(running=True, total=0, done=0, identified=0, error="")
|
||||
try:
|
||||
settings = await get_settings()
|
||||
api_key = settings.get("acoustid_api_key", "")
|
||||
if not api_key:
|
||||
_acoustid_status["error"] = "No AcoustID API key configured"
|
||||
return
|
||||
tracks = await db.tracks.find(
|
||||
{"$or": [{"artist": "Unknown Artist"}, {"album": "Unknown Album"}]},
|
||||
{"_id": 0},
|
||||
).to_list(5000)
|
||||
_acoustid_status["total"] = len(tracks)
|
||||
for t in tracks:
|
||||
try:
|
||||
path = Path(t["storage_path"])
|
||||
match = await acoustid_client.identify_track(api_key, path)
|
||||
if match:
|
||||
update = {}
|
||||
if match.get("title") and t.get("artist") == "Unknown Artist":
|
||||
update["title"] = match["title"]
|
||||
if match.get("artist"):
|
||||
update["artist"] = match["artist"]
|
||||
if match.get("album"):
|
||||
update["album"] = match["album"]
|
||||
if update:
|
||||
await db.tracks.update_one({"id": t["id"]}, {"$set": update})
|
||||
_acoustid_status["identified"] += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"AcoustID identify failed for {t.get('storage_path')}: {e}")
|
||||
_acoustid_status["done"] += 1
|
||||
await asyncio.sleep(0.35) # AcoustID's documented rate limit is 3 req/sec
|
||||
except Exception as e:
|
||||
_acoustid_status["error"] = str(e)
|
||||
finally:
|
||||
_acoustid_status["running"] = False
|
||||
|
||||
|
||||
@api.post("/music/identify")
|
||||
async def start_acoustid_identify(user: dict = Depends(require_admin)):
|
||||
if _acoustid_status["running"]:
|
||||
raise HTTPException(status_code=409, detail="Identification already running")
|
||||
asyncio.create_task(_run_acoustid_identify())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/music/identify/status")
|
||||
async def acoustid_identify_status(user: dict = Depends(require_admin)):
|
||||
return _acoustid_status
|
||||
|
||||
|
||||
@api.post("/music/enrich")
|
||||
async def start_music_enrich(user: dict = Depends(require_admin)):
|
||||
if _music_enrich_status["running"]:
|
||||
raise HTTPException(status_code=409, detail="Enrichment already running")
|
||||
asyncio.create_task(_run_music_enrich())
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/music/enrich/status")
|
||||
async def music_enrich_status(user: dict = Depends(require_admin)):
|
||||
return _music_enrich_status
|
||||
|
||||
|
||||
@api.get("/stream/track/{track_id}")
|
||||
async def stream_track(
|
||||
track_id: str, request: Request,
|
||||
auth: Optional[str] = Query(None),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth
|
||||
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
decode_token(token)
|
||||
track = await db.tracks.find_one({"id": track_id}, {"_id": 0})
|
||||
if not track: raise HTTPException(status_code=404, detail="Track not found")
|
||||
file_path = Path(track["storage_path"])
|
||||
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing on disk")
|
||||
|
||||
file_size = file_path.stat().st_size
|
||||
_audio_mime_fallback = {".m4a": "audio/mp4", ".flac": "audio/flac", ".ogg": "audio/ogg", ".wav": "audio/wav", ".aac": "audio/aac"}
|
||||
content_type = mimetypes.guess_type(str(file_path))[0] or _audio_mime_fallback.get(file_path.suffix.lower(), "audio/mpeg")
|
||||
range_header = request.headers.get("range") or request.headers.get("Range")
|
||||
if range_header and range_header.startswith("bytes="):
|
||||
try:
|
||||
start_str, end_str = range_header.replace("bytes=", "").split("-")
|
||||
start = int(start_str) if start_str else 0
|
||||
end = int(end_str) if end_str else file_size - 1
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=416, detail="Invalid range")
|
||||
if start >= file_size:
|
||||
raise HTTPException(status_code=416, detail="Range out of bounds")
|
||||
end = min(end, file_size - 1)
|
||||
length = end - start + 1
|
||||
|
||||
def iter_file():
|
||||
with file_path.open("rb") as f:
|
||||
f.seek(start)
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
chunk = f.read(min(CHUNK_SIZE, remaining))
|
||||
if not chunk: break
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={
|
||||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": str(length),
|
||||
"Content-Type": content_type,
|
||||
})
|
||||
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
|
||||
|
||||
|
||||
# ============ TRAKT ============
|
||||
async def _trakt_get_token(user_id: str) -> Optional[dict]:
|
||||
return await db.trakt_tokens.find_one({"user_id": user_id}, {"_id": 0})
|
||||
@@ -1414,37 +1974,64 @@ async def opensubs_download(payload: dict, user: dict = Depends(require_admin)):
|
||||
|
||||
|
||||
# ============ BULK TMDB ENRICHMENT ============
|
||||
_movie_enrich_status = {"running": False, "total": 0, "done": 0, "enriched": 0, "skipped": 0, "failed": 0, "error": ""}
|
||||
|
||||
|
||||
async def _run_movie_enrich(key: str):
|
||||
_movie_enrich_status.update(running=True, total=0, done=0, enriched=0, skipped=0, failed=0, error="")
|
||||
try:
|
||||
movies = await db.movies.find({}, {"_id": 0}).to_list(20000)
|
||||
_movie_enrich_status["total"] = len(movies)
|
||||
for m in movies:
|
||||
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
|
||||
if not needs:
|
||||
_movie_enrich_status["skipped"] += 1
|
||||
_movie_enrich_status["done"] += 1
|
||||
continue
|
||||
try:
|
||||
if m.get("tmdb_id"):
|
||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
|
||||
else:
|
||||
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
|
||||
if not results:
|
||||
_movie_enrich_status["failed"] += 1
|
||||
_movie_enrich_status["done"] += 1
|
||||
continue
|
||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
|
||||
updates = {}
|
||||
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
|
||||
if data.get(field) and not m.get(field): updates[field] = data[field]
|
||||
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
|
||||
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
|
||||
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
|
||||
if updates:
|
||||
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
|
||||
_movie_enrich_status["enriched"] += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
|
||||
_movie_enrich_status["failed"] += 1
|
||||
_movie_enrich_status["done"] += 1
|
||||
except Exception as e:
|
||||
_movie_enrich_status["error"] = str(e)
|
||||
finally:
|
||||
_movie_enrich_status["running"] = False
|
||||
|
||||
|
||||
@api.post("/tmdb/enrich-all")
|
||||
async def tmdb_enrich_all(user: dict = Depends(require_admin)):
|
||||
"""Sweep movies with missing metadata and fill from TMDB (by title match)."""
|
||||
"""Kicks off a background sweep of movies with missing metadata, filled from TMDB (by title match)."""
|
||||
if _movie_enrich_status["running"]:
|
||||
raise HTTPException(status_code=409, detail="Enrichment already running")
|
||||
s = await get_settings()
|
||||
key = s.get("tmdb_api_key", "")
|
||||
if not key: raise HTTPException(status_code=400, detail="TMDB not configured")
|
||||
enriched = 0; skipped = 0; failed = 0
|
||||
async for m in db.movies.find({}, {"_id": 0}):
|
||||
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
|
||||
if not needs:
|
||||
skipped += 1; continue
|
||||
try:
|
||||
if m.get("tmdb_id"):
|
||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
|
||||
else:
|
||||
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
|
||||
if not results: failed += 1; continue
|
||||
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
|
||||
updates = {}
|
||||
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
|
||||
if data.get(field) and not m.get(field): updates[field] = data[field]
|
||||
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
|
||||
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
|
||||
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
|
||||
if updates:
|
||||
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
|
||||
enriched += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
|
||||
failed += 1
|
||||
return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed}
|
||||
asyncio.create_task(_run_movie_enrich(key))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api.get("/tmdb/enrich-all/status")
|
||||
async def tmdb_enrich_all_status(user: dict = Depends(require_admin)):
|
||||
return _movie_enrich_status
|
||||
|
||||
|
||||
async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int],
|
||||
@@ -1479,10 +2066,24 @@ async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Opti
|
||||
logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}")
|
||||
|
||||
|
||||
# ============ SERVICES (admin) ============
|
||||
@api.get("/services")
|
||||
async def services_status(user: dict = Depends(require_admin)):
|
||||
return await asyncio.to_thread(services_client.list_service_status)
|
||||
|
||||
|
||||
@api.post("/services/{key}/restart")
|
||||
async def services_restart(key: str, user: dict = Depends(require_admin)):
|
||||
ok = await asyncio.to_thread(services_client.restart_service, key)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Unknown service")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ HEALTH ============
|
||||
@api.get("/")
|
||||
async def root():
|
||||
return {"app": "Kino", "status": "ok"}
|
||||
return {"app": "StreamHoard", "status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Docker service status/restart for the internal StreamHoard stack."""
|
||||
import docker
|
||||
|
||||
SERVICES = [
|
||||
{"key": "gluetun", "label": "VPN (gluetun)", "container": "streamhoard-gluetun"},
|
||||
{"key": "sonarr", "label": "Sonarr", "container": "streamhoard-sonarr"},
|
||||
{"key": "radarr", "label": "Radarr", "container": "streamhoard-radarr"},
|
||||
{"key": "prowlarr", "label": "Prowlarr", "container": "streamhoard-prowlarr"},
|
||||
{"key": "qbittorrent", "label": "qBittorrent", "container": "streamhoard-qbittorrent"},
|
||||
{"key": "mongo", "label": "Database", "container": "streamhoard-mongo"},
|
||||
{"key": "portainer", "label": "Portainer", "container": "streamhoard-portainer"},
|
||||
]
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _get_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
||||
return _client
|
||||
|
||||
|
||||
def list_service_status():
|
||||
client = _get_client()
|
||||
out = []
|
||||
for svc in SERVICES:
|
||||
try:
|
||||
c = client.containers.get(svc["container"])
|
||||
running = c.status == "running"
|
||||
except docker.errors.NotFound:
|
||||
running = False
|
||||
except Exception:
|
||||
running = False
|
||||
out.append({"key": svc["key"], "label": svc["label"], "running": running})
|
||||
return out
|
||||
|
||||
|
||||
def restart_service(key: str) -> bool:
|
||||
svc = next((s for s in SERVICES if s["key"] == key), None)
|
||||
if not svc:
|
||||
return False
|
||||
client = _get_client()
|
||||
c = client.containers.get(svc["container"])
|
||||
c.restart(timeout=15)
|
||||
return True
|
||||
@@ -14,6 +14,59 @@ def _poster(images):
|
||||
return ""
|
||||
|
||||
|
||||
def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||
"""Sonarr's own title lookup (proxies TVDB) — used to resolve a free-text request to a real series."""
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for s in r.json() or []:
|
||||
out.append({
|
||||
"tvdb_id": s.get("tvdbId"), "tmdb_id": s.get("tmdbId"), "title": s.get("title") or "",
|
||||
"year": s.get("year"), "overview": s.get("overview") or "", "poster_url": _poster(s.get("images")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
|
||||
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled."""
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||
if not profiles or not roots:
|
||||
raise RuntimeError("Sonarr has no quality profile or root folder configured")
|
||||
payload = {
|
||||
"title": title, "tvdbId": tvdb_id, "year": year or 0,
|
||||
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||
"seasonFolder": True, "monitored": True,
|
||||
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
|
||||
}
|
||||
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
|
||||
r.raise_for_status()
|
||||
added = r.json()
|
||||
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||
|
||||
|
||||
def series_status(base_url: str, api_key: str, sonarr_id: int) -> Dict:
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
s = requests.get(f"{base_url}/api/v3/series/{sonarr_id}", headers=h, timeout=15).json()
|
||||
stats = s.get("statistics") or {}
|
||||
if stats.get("episodeFileCount", 0) > 0 and stats.get("episodeFileCount") >= stats.get("episodeCount", 1):
|
||||
return {"state": "downloaded"}
|
||||
q = requests.get(f"{base_url}/api/v3/queue", params={"seriesId": sonarr_id, "includeSeries": False}, headers=h, timeout=15).json()
|
||||
items = q.get("records") if isinstance(q, dict) else q
|
||||
if items:
|
||||
it = items[0]
|
||||
size = it.get("size") or 0
|
||||
sizeleft = it.get("sizeleft") or 0
|
||||
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||
if stats.get("episodeFileCount", 0) > 0:
|
||||
return {"state": "partially downloaded"}
|
||||
return {"state": "searching"}
|
||||
|
||||
|
||||
def test_connection(base_url: str, api_key: str) -> bool:
|
||||
try:
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Backend pytest suite for Kino Personal Media Server.
|
||||
Backend pytest suite for StreamHoard Personal Media Server.
|
||||
Tests auth, movies, watchlist, progress, requests, and stream auth.
|
||||
"""
|
||||
import os
|
||||
@@ -8,8 +8,8 @@ import uuid
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "http://localhost:8001").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def admin_token(api):
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def member_token(api):
|
||||
email = f"TEST_user_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_user_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Test User"})
|
||||
assert r.status_code == 200, f"register failed: {r.text}"
|
||||
data = r.json()
|
||||
@@ -73,7 +73,7 @@ class TestAuth:
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_register_and_me(self, api):
|
||||
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
@@ -86,7 +86,7 @@ class TestAuth:
|
||||
assert r2.json()["email"] == email.lower()
|
||||
|
||||
def test_register_duplicate(self, api):
|
||||
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def admin_headers(admin_token):
|
||||
@pytest.fixture(scope="module")
|
||||
def member(api):
|
||||
"""Create a fresh member for profile-scoped tests."""
|
||||
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "P2 User"})
|
||||
assert r.status_code == 200
|
||||
@@ -101,7 +101,7 @@ class TestProfiles:
|
||||
|
||||
def test_cannot_delete_last_profile(self, api):
|
||||
# fresh user with single profile
|
||||
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@kino.local"
|
||||
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@streamhoard.local"
|
||||
d = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
|
||||
h = {"Authorization": f"Bearer {d['access_token']}"}
|
||||
|
||||
@@ -17,7 +17,7 @@ import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ["REACT_APP_BACKEND_URL"].rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
QTEST_VIDEO = "/tmp/qtest.mp4"
|
||||
|
||||
@@ -40,7 +40,7 @@ def admin_token(api):
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def member_token(api):
|
||||
email = f"TEST_q_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_q_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "QTest"},
|
||||
headers={"Content-Type": "application/json"})
|
||||
|
||||
+105
-11
@@ -13,6 +13,11 @@ from typing import Optional, List, Tuple, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger("kino.transcode")
|
||||
|
||||
# Moderate priority (was 19, the absolute lowest) and a thread cap so an ABR encode can't
|
||||
# starve the rest of the stack (Mongo, *arr apps, the API itself) on this 8-core host.
|
||||
TRANSCODE_NICE = 10
|
||||
TRANSCODE_THREADS = 6
|
||||
|
||||
|
||||
async def probe_video(source: Path) -> Tuple[int, int]:
|
||||
"""Return (width, height) using ffprobe; (0, 0) on failure."""
|
||||
@@ -32,6 +37,23 @@ async def probe_video(source: Path) -> Tuple[int, int]:
|
||||
return (0, 0)
|
||||
|
||||
|
||||
async def probe_duration(source: Path) -> float:
|
||||
"""Return source duration in seconds using ffprobe; 0 on failure."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "json", str(source),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return 0.0
|
||||
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
|
||||
return float((data.get("format") or {}).get("duration") or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
|
||||
ALL_VARIANTS = [
|
||||
(1080, "5000k", "5500k", "7500k", "192k"),
|
||||
@@ -42,13 +64,38 @@ ALL_VARIANTS = [
|
||||
|
||||
|
||||
def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]:
|
||||
"""Pick variants ≤ source height. Always include at least one (smallest)."""
|
||||
"""Pick up to 2 variants ≤ source height (the two highest applicable renditions —
|
||||
ALL_VARIANTS is ordered highest-to-lowest, so slicing keeps the best pair). Capped at 2
|
||||
instead of the full ladder to roughly halve ABR encode time on this host's aging CPU, while
|
||||
still giving a real high/low adaptive-bitrate pair for most connections."""
|
||||
if src_height <= 0:
|
||||
return [ALL_VARIANTS[2]] # safe default 480p
|
||||
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
|
||||
if not chosen:
|
||||
chosen = [ALL_VARIANTS[-1]]
|
||||
return chosen
|
||||
return chosen[:2]
|
||||
|
||||
|
||||
async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
"""Video stream-copied untouched, audio re-encoded to AAC — fixes browsers' inability to
|
||||
play AC3/DTS/EAC3 (common on BDRips) without paying for a full video re-encode. Same
|
||||
H.264-source assumption as transcode_quick; HEVC/x265 sources still need the full ABR path
|
||||
since the video itself isn't browser-playable either in that case."""
|
||||
if not source.is_file():
|
||||
await on_status("failed", error=f"Source missing: {source}")
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"nice", "-n", str(TRANSCODE_NICE),
|
||||
"ffmpeg", "-y", "-i", str(source),
|
||||
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ac", "2",
|
||||
"-bsf:v", "h264_mp4toannexb",
|
||||
"-f", "hls", "-hls_time", "6",
|
||||
"-hls_list_size", "0", "-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(out_dir / "playlist.m3u8"),
|
||||
]
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
|
||||
|
||||
|
||||
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
@@ -58,7 +105,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"nice", "-n", "19",
|
||||
"nice", "-n", str(TRANSCODE_NICE),
|
||||
"ffmpeg", "-y", "-i", str(source),
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-bsf:v", "h264_mp4toannexb",
|
||||
@@ -67,7 +114,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(out_dir / "playlist.m3u8"),
|
||||
]
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8")
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
|
||||
|
||||
|
||||
async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
@@ -88,13 +135,19 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
|
||||
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
|
||||
filter_complex = ";".join(fc_parts)
|
||||
|
||||
cmd: List[str] = ["nice", "-n", "19", "ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex]
|
||||
# Per-variant thread count, not the full cap each — x264 threads are set per encoder
|
||||
# instance, so giving every one of the (up to 2) variants the full TRANSCODE_THREADS
|
||||
# budget lets them all run near-full-tilt simultaneously and still saturate the host.
|
||||
per_variant_threads = max(1, TRANSCODE_THREADS // n)
|
||||
|
||||
cmd: List[str] = ["nice", "-n", str(TRANSCODE_NICE), "ffmpeg", "-y", "-threads", str(per_variant_threads), "-i", str(source), "-filter_complex", filter_complex]
|
||||
|
||||
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
|
||||
cmd += [
|
||||
"-map", f"[v{i}out]",
|
||||
f"-c:v:{i}", "libx264",
|
||||
f"-preset:v:{i}", "veryfast",
|
||||
f"-preset:v:{i}", "superfast",
|
||||
f"-threads:v:{i}", str(per_variant_threads),
|
||||
f"-profile:v:{i}", "main",
|
||||
f"-pix_fmt:v:{i}", "yuv420p",
|
||||
f"-b:v:{i}", vb,
|
||||
@@ -125,23 +178,64 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
|
||||
for i in range(n):
|
||||
(out_dir / f"v{i}").mkdir(exist_ok=True)
|
||||
|
||||
await _run(cmd, on_status, out_dir, "master.m3u8")
|
||||
await _run(cmd, on_status, out_dir, "master.m3u8", source)
|
||||
|
||||
|
||||
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None:
|
||||
await on_status("running")
|
||||
def _parse_out_time_seconds(line: str) -> Optional[float]:
|
||||
"""Parse an `out_time=HH:MM:SS.microseconds` line from ffmpeg's -progress output."""
|
||||
_, _, value = line.partition("=")
|
||||
value = value.strip()
|
||||
if not value or value == "N/A":
|
||||
return None
|
||||
try:
|
||||
h, m, s = value.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str, source: Optional[Path] = None) -> None:
|
||||
duration = await probe_duration(source) if source else 0.0
|
||||
await on_status("running", progress=0.0)
|
||||
# Machine-readable progress on stdout, separate from ffmpeg's normal stderr logging.
|
||||
# Insert right after the "ffmpeg" token, not cmd[0] — cmd is prefixed with `nice -n N`,
|
||||
# and these flags belong to ffmpeg, not to nice.
|
||||
ffmpeg_idx = cmd.index("ffmpeg")
|
||||
cmd = cmd[:ffmpeg_idx + 1] + ["-progress", "pipe:1", "-nostats"] + cmd[ffmpeg_idx + 1:]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_stdout, stderr = await proc.communicate()
|
||||
|
||||
stderr_chunks: List[bytes] = []
|
||||
|
||||
async def _drain_stderr():
|
||||
async for line in proc.stderr:
|
||||
stderr_chunks.append(line)
|
||||
|
||||
stderr_task = asyncio.create_task(_drain_stderr())
|
||||
|
||||
last_reported = -1.0
|
||||
async for raw in proc.stdout:
|
||||
line = raw.decode("utf-8", errors="ignore").strip()
|
||||
if line.startswith("out_time=") and duration > 0:
|
||||
seconds = _parse_out_time_seconds(line)
|
||||
if seconds is not None:
|
||||
pct = round(min(99.0, seconds / duration * 100), 1)
|
||||
if pct != last_reported:
|
||||
last_reported = pct
|
||||
await on_status("running", progress=pct)
|
||||
|
||||
await proc.wait()
|
||||
await stderr_task
|
||||
stderr = b"".join(stderr_chunks)
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
logger.error(f"ffmpeg failed: {err}")
|
||||
await on_status("failed", error=err[:200])
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
return
|
||||
await on_status("done", entry=entry_filename)
|
||||
await on_status("done", entry=entry_filename, progress=100.0)
|
||||
except FileNotFoundError:
|
||||
await on_status("failed", error="ffmpeg not installed")
|
||||
except Exception as e:
|
||||
|
||||
+121
-14
@@ -1,9 +1,9 @@
|
||||
# Kino — Docker Compose deployment
|
||||
# StreamHoard — Docker Compose deployment
|
||||
#
|
||||
# Runs alongside existing LAMP stacks without conflicts:
|
||||
# - Apache keeps port 80/443
|
||||
# - MySQL is untouched (Kino uses its own MongoDB in a container)
|
||||
# - Kino exposes a single configurable port (default 8080)
|
||||
# - MySQL is untouched (StreamHoard uses its own MongoDB in a container)
|
||||
# - StreamHoard exposes a single configurable port (default 8080)
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.compose.example .env
|
||||
@@ -13,20 +13,20 @@
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
container_name: kino-mongo
|
||||
image: mongo:4.4
|
||||
container_name: streamhoard-mongo
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
networks:
|
||||
- kino
|
||||
# No ports exposed to host — only reachable inside kino network
|
||||
- streamhoard
|
||||
# No ports exposed to host — only reachable inside streamhoard network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: kino-backend
|
||||
container_name: streamhoard-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mongo
|
||||
@@ -37,35 +37,142 @@ services:
|
||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||
JWT_ALG: HS256
|
||||
JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-720}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@kino.local}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@streamhoard.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||
MEDIA_ROOT: /media
|
||||
volumes:
|
||||
# Host media directory (movies, HLS, subtitles) — point at your NAS/ZFS mount
|
||||
- ${MEDIA_ROOT_HOST:-./media}:/media
|
||||
# Radarr/Sonarr report absolute file paths under this same path — must match exactly
|
||||
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro
|
||||
# NAS music library — used by the admin Library Scan (movies/tv/music), read-only
|
||||
- ${MEDIA_NAS_MUSIC_ROOT:-/mnt/nas/music}:/mnt/nas/music:ro
|
||||
# Docker socket — lets the admin Services panel check/restart sibling containers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- kino
|
||||
- streamhoard
|
||||
# No direct host port — frontend container reverse-proxies /api → backend:8001
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: kino-frontend
|
||||
container_name: streamhoard-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
# Only Kino's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
|
||||
# Only StreamHoard's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
|
||||
- "${KINO_HTTP_PORT:-8080}:80"
|
||||
networks:
|
||||
- kino
|
||||
- streamhoard
|
||||
|
||||
# ============ *arr stack + qBittorrent, all routed through gluetun (NordVPN) ============
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun
|
||||
container_name: streamhoard-gluetun
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
VPN_SERVICE_PROVIDER: custom
|
||||
VPN_TYPE: wireguard
|
||||
WIREGUARD_ENDPOINT_IP: ${VPN_ENDPOINT_IP:?set VPN_ENDPOINT_IP in .env}
|
||||
WIREGUARD_ENDPOINT_PORT: ${VPN_ENDPOINT_PORT:-51820}
|
||||
WIREGUARD_PUBLIC_KEY: ${VPN_PUBLIC_KEY:?set VPN_PUBLIC_KEY in .env}
|
||||
WIREGUARD_PRIVATE_KEY: ${VPN_PRIVATE_KEY:?set VPN_PRIVATE_KEY in .env}
|
||||
WIREGUARD_ADDRESSES: ${VPN_ADDRESSES:-10.5.0.2/16}
|
||||
FIREWALL_OUTBOUND_SUBNETS: 10.48.200.0/24
|
||||
ports:
|
||||
- "8082:8080" # qBittorrent WebUI
|
||||
- "8989:8989" # Sonarr
|
||||
- "7878:7878" # Radarr
|
||||
- "9696:9696" # Prowlarr
|
||||
networks:
|
||||
- streamhoard
|
||||
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: streamhoard-qbittorrent
|
||||
restart: unless-stopped
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
- gluetun
|
||||
environment:
|
||||
PUID: 999
|
||||
PGID: 988
|
||||
TZ: ${TZ:-America/Chicago}
|
||||
WEBUI_PORT: 8080
|
||||
volumes:
|
||||
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/qbittorrent/qBittorrent:/config/qBittorrent
|
||||
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
|
||||
|
||||
radarr:
|
||||
image: lscr.io/linuxserver/radarr:latest
|
||||
container_name: streamhoard-radarr
|
||||
restart: unless-stopped
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
- gluetun
|
||||
environment:
|
||||
PUID: 999
|
||||
PGID: 988
|
||||
TZ: ${TZ:-America/Chicago}
|
||||
volumes:
|
||||
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/radarr:/config
|
||||
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
|
||||
|
||||
sonarr:
|
||||
image: lscr.io/linuxserver/sonarr:latest
|
||||
container_name: streamhoard-sonarr
|
||||
restart: unless-stopped
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
- gluetun
|
||||
environment:
|
||||
PUID: 999
|
||||
PGID: 988
|
||||
TZ: ${TZ:-America/Chicago}
|
||||
volumes:
|
||||
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/sonarr:/config
|
||||
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video
|
||||
|
||||
prowlarr:
|
||||
image: lscr.io/linuxserver/prowlarr:latest
|
||||
container_name: streamhoard-prowlarr
|
||||
restart: unless-stopped
|
||||
network_mode: "service:gluetun"
|
||||
depends_on:
|
||||
- gluetun
|
||||
environment:
|
||||
PUID: 999
|
||||
PGID: 988
|
||||
TZ: ${TZ:-America/Chicago}
|
||||
volumes:
|
||||
- ${STACK_CONFIG_ROOT:-/opt/streamhoard-config}/prowlarr:/config
|
||||
|
||||
# ============ Docker management GUI ============
|
||||
portainer:
|
||||
image: portainer/portainer-ce:latest
|
||||
container_name: streamhoard-portainer
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "9443:9443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- portainer-data:/data
|
||||
networks:
|
||||
- streamhoard
|
||||
|
||||
networks:
|
||||
kino:
|
||||
streamhoard:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
driver: local
|
||||
portainer-data:
|
||||
driver: local
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config();
|
||||
|
||||
// Check if we're in development/preview mode (not production build)
|
||||
// Craco sets NODE_ENV=development for start, NODE_ENV=production for build
|
||||
const isDevServer = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Environment variable overrides
|
||||
const config = {
|
||||
enableHealthCheck: process.env.ENABLE_HEALTH_CHECK === "true",
|
||||
@@ -81,20 +77,4 @@ webpackConfig.devServer = (devServerConfig) => {
|
||||
return devServerConfig;
|
||||
};
|
||||
|
||||
// Wrap with visual edits (automatically adds babel plugin, dev server, and overlay in dev mode)
|
||||
if (isDevServer) {
|
||||
try {
|
||||
const { withVisualEdits } = require("@emergentbase/visual-edits/craco");
|
||||
webpackConfig = withVisualEdits(webpackConfig);
|
||||
} catch (err) {
|
||||
if (err.code === 'MODULE_NOT_FOUND' && err.message.includes('@emergentbase/visual-edits/craco')) {
|
||||
console.warn(
|
||||
"[visual-edits] @emergentbase/visual-edits not installed — visual editing disabled."
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = webpackConfig;
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@emergentbase/visual-edits": "https://assets.emergent.sh/npm/emergentbase-visual-edits-1.0.8.tgz",
|
||||
"@eslint/js": "9.23.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "9.23.0",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="A product of emergent.sh" />
|
||||
<meta name="description" content="StreamHoard - self-hosted personal media server" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet" />
|
||||
@@ -21,9 +21,8 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Emergent | Fullstack App</title>
|
||||
<title>StreamHoard</title>
|
||||
<script>window.addEventListener("error",function(e){if(e.error instanceof DOMException&&e.error.name==="DataCloneError"&&e.message&&e.message.includes("PerformanceServerTiming")){e.stopImmediatePropagation();e.preventDefault()}},true);</script>
|
||||
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
@@ -38,76 +37,5 @@
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script>
|
||||
!(function (t, e) {
|
||||
var o, n, p, r;
|
||||
e.__SV ||
|
||||
((window.posthog = e),
|
||||
(e._i = []),
|
||||
(e.init = function (i, s, a) {
|
||||
function g(t, e) {
|
||||
var o = e.split(".");
|
||||
2 == o.length && ((t = t[o[0]]), (e = o[1])),
|
||||
(t[e] = function () {
|
||||
t.push(
|
||||
[e].concat(
|
||||
Array.prototype.slice.call(
|
||||
arguments,
|
||||
0,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
((p = t.createElement("script")).type =
|
||||
"text/javascript"),
|
||||
(p.crossOrigin = "anonymous"),
|
||||
(p.async = !0),
|
||||
(p.src =
|
||||
s.api_host.replace(
|
||||
".i.posthog.com",
|
||||
"-assets.i.posthog.com",
|
||||
) + "/static/array.js"),
|
||||
(r =
|
||||
t.getElementsByTagName(
|
||||
"script",
|
||||
)[0]).parentNode.insertBefore(p, r);
|
||||
var u = e;
|
||||
for (
|
||||
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
|
||||
u.people = u.people || [],
|
||||
u.toString = function (t) {
|
||||
var e = "posthog";
|
||||
return (
|
||||
"posthog" !== a && (e += "." + a),
|
||||
t || (e += " (stub)"),
|
||||
e
|
||||
);
|
||||
},
|
||||
u.people.toString = function () {
|
||||
return u.toString(1) + ".people (stub)";
|
||||
},
|
||||
o =
|
||||
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
|
||||
" ",
|
||||
),
|
||||
n = 0;
|
||||
n < o.length;
|
||||
n++
|
||||
)
|
||||
g(u, o[n]);
|
||||
e._i.push([i, s, a]);
|
||||
}),
|
||||
(e.__SV = 1));
|
||||
})(document, window.posthog || []);
|
||||
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
|
||||
session_recording: {
|
||||
recordCrossOriginIframes: true,
|
||||
capturePerformance: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+46
-34
@@ -2,9 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { ProfileProvider, useProfile } from "./lib/profile";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { MusicPlayerProvider } from "./lib/musicPlayer";
|
||||
import Navbar from "./components/Navbar";
|
||||
import GrainOverlay from "./components/GrainOverlay";
|
||||
import ServiceStatus from "./components/ServiceStatus";
|
||||
import MusicPlayerBar from "./components/MusicPlayerBar";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import Browse from "./pages/Browse";
|
||||
@@ -16,23 +18,26 @@ import Admin from "./pages/Admin";
|
||||
import AdminUpload from "./pages/AdminUpload";
|
||||
import Settings from "./pages/Settings";
|
||||
import RadarrImport from "./pages/RadarrImport";
|
||||
import ProfileSelect from "./pages/ProfileSelect";
|
||||
import TranscodeQueue from "./pages/TranscodeQueue";
|
||||
import Shows from "./pages/Shows";
|
||||
import ShowDetail from "./pages/ShowDetail";
|
||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||
import SonarrImport from "./pages/SonarrImport";
|
||||
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
|
||||
import Users from "./pages/Users";
|
||||
import Music from "./pages/Music";
|
||||
import AlbumDetail from "./pages/AlbumDetail";
|
||||
import LibraryScan from "./pages/LibraryScan";
|
||||
import LibraryCleanup from "./pages/LibraryCleanup";
|
||||
|
||||
const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { active, loading: profLoading } = useProfile();
|
||||
const loc = useLocation();
|
||||
if (authLoading || profLoading) {
|
||||
if (authLoading || profLoading || (user && !active)) {
|
||||
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
}
|
||||
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
@@ -48,11 +53,13 @@ const Shell = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { active } = useProfile();
|
||||
const loc = useLocation();
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
|
||||
return (
|
||||
<>
|
||||
{!hideChrome && user && active && <Navbar />}
|
||||
{!hideChrome && user?.is_admin && active && <ServiceStatus />}
|
||||
{children}
|
||||
{!hideChrome && user && active && <MusicPlayerBar />}
|
||||
<GrainOverlay />
|
||||
</>
|
||||
);
|
||||
@@ -60,10 +67,9 @@ const Shell = ({ children }) => {
|
||||
|
||||
const RootRedirect = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const { active, loading: pl } = useProfile();
|
||||
const { loading: pl } = useProfile();
|
||||
if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return <Navigate to="/browse" replace />;
|
||||
};
|
||||
|
||||
@@ -73,33 +79,39 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ProfileProvider>
|
||||
<Shell>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
|
||||
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
|
||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
|
||||
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
|
||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
||||
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
|
||||
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
||||
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
||||
}} />
|
||||
<MusicPlayerProvider>
|
||||
<Shell>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
|
||||
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
|
||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
|
||||
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
|
||||
<Route path="/music" element={<ProfileGate><Music /></ProfileGate>} />
|
||||
<Route path="/music/album/:artist/:album" element={<ProfileGate><AlbumDetail /></ProfileGate>} />
|
||||
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
|
||||
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
|
||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
||||
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></AdminGate>} />
|
||||
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
|
||||
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
||||
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
||||
}} />
|
||||
</MusicPlayerProvider>
|
||||
</ProfileProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Shows real album art when a URL is available; otherwise a branded placeholder instead of an
|
||||
// empty box. Once musicbrainz enrichment (or a manual edit) sets album_art_url, callers just
|
||||
// pass the new url and this swaps over automatically — no separate "loaded" state to manage.
|
||||
export default function AlbumArt({ url, size = "lg", className = "", loading }) {
|
||||
if (url) {
|
||||
return <img src={url} alt="" loading={loading} className={`w-full h-full object-cover ${className}`} />;
|
||||
}
|
||||
const small = size === "sm";
|
||||
return (
|
||||
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br from-[#161616] to-[#0a0a0a] ${className}`}>
|
||||
{small ? (
|
||||
<span className="font-display font-black text-[#3a3a3a] text-lg select-none">S<span className="text-[#D9381E]">.</span></span>
|
||||
) : (
|
||||
<span className="font-display font-black tracking-tighter text-[#3a3a3a] text-sm sm:text-base select-none text-center px-2">
|
||||
Stream<span className="text-[#D9381E]">hoard</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Play } from "lucide-react";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
export const MovieCard = ({ movie, onClick, progress }) => {
|
||||
const pct = progress?.duration_seconds
|
||||
@@ -10,13 +11,7 @@ export const MovieCard = ({ movie, onClick, progress }) => {
|
||||
className="group relative shrink-0 w-[180px] md:w-[220px] aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-all duration-300 hover:scale-105 hover:z-10 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`movie-card-${movie.id}`}
|
||||
>
|
||||
<img
|
||||
src={movie.poster_url || movie.backdrop_url}
|
||||
alt={movie.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
|
||||
/>
|
||||
<AlbumArt url={movie.poster_url || movie.backdrop_url} loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent opacity-90 md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-2 md:translate-y-0 md:opacity-0 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300">
|
||||
<h3 className="font-display text-lg font-bold tracking-tight text-white leading-none line-clamp-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
|
||||
const nav = useNavigate();
|
||||
@@ -64,11 +65,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
||||
</button>
|
||||
|
||||
<div className="relative h-[280px] md:h-[440px]">
|
||||
<img
|
||||
src={movie.backdrop_url || movie.poster_url}
|
||||
alt={movie.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<AlbumArt url={movie.backdrop_url || movie.poster_url} className="absolute inset-0" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-10">
|
||||
<h2 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white" data-testid="modal-movie-title">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Pause, SkipBack, SkipForward, Speaker } from "lucide-react";
|
||||
import { useMusicPlayer } from "../lib/musicPlayer";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
const fmt = (s) => {
|
||||
if (!s || !isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, "0");
|
||||
return `${m}:${sec}`;
|
||||
};
|
||||
|
||||
export default function MusicPlayerBar() {
|
||||
const {
|
||||
current, isPlaying, progress, togglePlay, next, prev, seek, hasNext, hasPrev,
|
||||
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
|
||||
} = useMusicPlayer();
|
||||
const [showOutputs, setShowOutputs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showOutputs) refreshOutputDevices();
|
||||
}, [showOutputs, refreshOutputDevices]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const pct = progress.duration ? (progress.position / progress.duration) * 100 : 0;
|
||||
|
||||
const onSeek = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
seek(ratio * (progress.duration || 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-t border-[#222] px-4 md:px-8 py-3" data-testid="music-player-bar">
|
||||
<div className="max-w-[1500px] mx-auto flex items-center gap-4">
|
||||
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||
<AlbumArt url={current.album_art_url} size="sm" />
|
||||
</div>
|
||||
<div className="min-w-0 w-40 md:w-56">
|
||||
<p className="text-white text-sm truncate">{current.title}</p>
|
||||
<p className="text-[#8A8A8A] text-xs truncate">{current.artist}</p>
|
||||
</div>
|
||||
<button onClick={prev} disabled={!hasPrev} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-prev">
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button onClick={togglePlay} className="w-8 h-8 flex items-center justify-center bg-[#D9381E] hover:bg-[#ED4B32] text-white" data-testid="music-play-pause">
|
||||
{isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} disabled={!hasNext} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-next">
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
<span className="text-[10px] text-[#8A8A8A] w-9 text-right shrink-0">{fmt(progress.position)}</span>
|
||||
<div className="flex-1 h-1 bg-white/10 cursor-pointer hidden sm:block" onClick={onSeek} data-testid="music-seek">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#8A8A8A] w-9 shrink-0">{fmt(progress.duration)}</span>
|
||||
|
||||
{sinkSupported && (
|
||||
<div className="relative shrink-0">
|
||||
<button onClick={() => setShowOutputs((v) => !v)} className="text-[#8A8A8A] hover:text-white" title="Output device" data-testid="music-output-toggle">
|
||||
<Speaker size={16} />
|
||||
</button>
|
||||
{showOutputs && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-56 bg-[#0F0F0F] border border-[#222] py-1" data-testid="music-output-list">
|
||||
{outputDevices.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-[#8A8A8A]">No output devices found</p>
|
||||
)}
|
||||
{outputDevices.map((d) => (
|
||||
<button key={d.deviceId} onClick={() => { selectOutput(d.deviceId); setShowOutputs(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs truncate ${sinkId === d.deviceId ? "text-[#D9381E]" : "text-white hover:bg-white/5"}`}
|
||||
data-testid={`music-output-${d.deviceId}`}>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { Link, NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { useProfile } from "../lib/profile";
|
||||
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
|
||||
import { Search, LogOut, Upload, Settings as SettingsIcon, Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../lib/api";
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { active, clearActive } = useProfile();
|
||||
const { active } = useProfile();
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [version, setVersion] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
@@ -16,27 +20,34 @@ export const Navbar = () => {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/version").then(({ data }) => setVersion(data.version)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Close the mobile menu on navigation so it never lingers open over the next page.
|
||||
useEffect(() => { setMobileOpen(false); }, [location.pathname]);
|
||||
|
||||
const linkClass = ({ isActive }) =>
|
||||
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
|
||||
|
||||
const switchProfile = () => {
|
||||
clearActive();
|
||||
nav("/profile");
|
||||
};
|
||||
const mobileLinkClass = ({ isActive }) =>
|
||||
`block py-3 text-base tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A]"}`;
|
||||
|
||||
return (
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled || mobileOpen ? "glass border-b" : ""}`} data-testid="main-navbar">
|
||||
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-10">
|
||||
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
|
||||
<span className="font-display text-2xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-2xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-2xl leading-none">.</span>
|
||||
{version && <span className="text-[10px] text-[#8A8A8A] self-start mt-1" data-testid="nav-version">v{version}</span>}
|
||||
</Link>
|
||||
{user && (
|
||||
<nav className="hidden md:flex items-center gap-7">
|
||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
|
||||
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</NavLink>
|
||||
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
|
||||
<NavLink to="/music" className={linkClass} data-testid="nav-music">Music</NavLink>
|
||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
|
||||
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
|
||||
</nav>
|
||||
@@ -49,39 +60,60 @@ export const Navbar = () => {
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
{user.is_admin && (
|
||||
<>
|
||||
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
|
||||
{active && (
|
||||
<>
|
||||
<div className="hidden sm:flex flex-col items-end leading-tight">
|
||||
<span className="text-xs text-white">{active.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
|
||||
</div>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
|
||||
{active.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
||||
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
||||
)}
|
||||
<button onClick={() => nav("/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
{active && (
|
||||
<div className="flex items-center gap-2" data-testid="account-avatar">
|
||||
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
|
||||
{active.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="hidden md:block text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-white -mr-2 p-2"
|
||||
data-testid="nav-mobile-toggle"
|
||||
aria-label={mobileOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
{mobileOpen ? <X size={22} strokeWidth={1.5} /> : <Menu size={22} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300" data-testid="nav-sign-in">Sign in</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && mobileOpen && (
|
||||
<nav className="md:hidden glass border-t border-[#222] px-6 pb-6" data-testid="nav-mobile-menu">
|
||||
<NavLink to="/browse" className={mobileLinkClass} data-testid="nav-mobile-browse">Library</NavLink>
|
||||
<NavLink to="/shows" className={mobileLinkClass} data-testid="nav-mobile-shows">Series</NavLink>
|
||||
<NavLink to="/my-list" className={mobileLinkClass} data-testid="nav-mobile-my-list">Shelf</NavLink>
|
||||
<NavLink to="/music" className={mobileLinkClass} data-testid="nav-mobile-music">Music</NavLink>
|
||||
<NavLink to="/requests" className={mobileLinkClass} data-testid="nav-mobile-requests">Wishlist</NavLink>
|
||||
{user.is_admin && <NavLink to="/admin" className={mobileLinkClass} data-testid="nav-mobile-admin">Admin</NavLink>}
|
||||
<div className="mt-2 pt-3 border-t border-[#222] flex flex-col gap-1">
|
||||
{user.is_admin && (
|
||||
<NavLink to="/admin/upload" className={mobileLinkClass} data-testid="nav-mobile-upload">Upload</NavLink>
|
||||
)}
|
||||
<NavLink to="/settings" className={mobileLinkClass} data-testid="nav-mobile-settings">Settings</NavLink>
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="text-left py-3 text-base tracking-wide text-[#8A8A8A]" data-testid="nav-mobile-logout">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCw } from "lucide-react";
|
||||
|
||||
export default function ServiceStatus() {
|
||||
const [services, setServices] = useState([]);
|
||||
const [restarting, setRestarting] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get("/services");
|
||||
setServices(data);
|
||||
} catch {
|
||||
setServices([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
const restart = async (key, label) => {
|
||||
setRestarting(key);
|
||||
try {
|
||||
await api.post(`/services/${key}/restart`);
|
||||
toast.success(`${label} restarting…`);
|
||||
setTimeout(load, 4000);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || `Could not restart ${label}`);
|
||||
} finally {
|
||||
setRestarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!services.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-16 left-0 right-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#222] px-6 md:px-12 py-2"
|
||||
data-testid="service-status"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{services.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className="flex items-center gap-2 bg-[#0F0F0F] border border-[#222] px-3 py-1.5 text-xs"
|
||||
data-testid={`service-${s.key}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${s.running ? "bg-[#86efac]" : "bg-[#fca5a5]"}`}
|
||||
/>
|
||||
<span className="text-[#8A8A8A]">{s.label}</span>
|
||||
{!s.running && (
|
||||
<button
|
||||
onClick={() => restart(s.key, s.label)}
|
||||
disabled={restarting === s.key}
|
||||
className="ml-1 flex items-center gap-1 text-[#D9381E] hover:text-[#ED4B32] disabled:opacity-50"
|
||||
data-testid={`service-restart-${s.key}`}
|
||||
>
|
||||
<RotateCw size={12} className={restarting === s.key ? "animate-spin" : ""} />
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,6 @@ const instance = axios.create({ baseURL: API });
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
// Active profile id (set by ProfileProvider via localStorage)
|
||||
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
|
||||
if (userId) {
|
||||
const pid = localStorage.getItem(`kino_profile_${userId}`);
|
||||
if (pid) config.headers["X-Profile-Id"] = pid;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -25,7 +19,7 @@ export const getStreamUrl = (movie) => {
|
||||
if (movie.hls_status === "done" && movie.hls_path) {
|
||||
return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`;
|
||||
}
|
||||
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
|
||||
if (movie.storage_type === "local" || movie.storage_type === "radarr" || movie.storage_type === "scan") {
|
||||
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return movie.video_url;
|
||||
@@ -37,3 +31,9 @@ export const getSubtitleUrl = (sub) => {
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
export const getTrackStreamUrl = (track) => {
|
||||
if (!track) return "";
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
return `${API}/stream/track/${track.id}?auth=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createContext, useContext, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { getTrackStreamUrl } from "./api";
|
||||
|
||||
const MusicPlayerCtx = createContext(null);
|
||||
|
||||
export const MusicPlayerProvider = ({ children }) => {
|
||||
const audioRef = useRef(new Audio());
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [index, setIndex] = useState(-1);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState({ position: 0, duration: 0 });
|
||||
const [outputDevices, setOutputDevices] = useState([]);
|
||||
const [sinkId, setSinkId] = useState("default");
|
||||
const sinkSupported = typeof audioRef.current.setSinkId === "function";
|
||||
|
||||
const current = index >= 0 ? queue[index] : null;
|
||||
|
||||
const refreshOutputDevices = useCallback(async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const outs = devices.filter((d) => d.kind === "audiooutput");
|
||||
setOutputDevices(outs.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Output ${i + 1}` })));
|
||||
} catch { /* enumeration unsupported/denied — picker just won't populate */ }
|
||||
}, []);
|
||||
|
||||
const selectOutput = useCallback(async (deviceId) => {
|
||||
if (!sinkSupported) return;
|
||||
try {
|
||||
await audioRef.current.setSinkId(deviceId);
|
||||
setSinkId(deviceId);
|
||||
} catch { /* device may have disappeared (e.g. BT speaker turned off) — keep current output */ }
|
||||
}, [sinkSupported]);
|
||||
|
||||
const playAt = useCallback((newQueue, startIndex) => {
|
||||
setQueue(newQueue);
|
||||
setIndex(startIndex);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!current) { audio.pause(); return; }
|
||||
audio.src = getTrackStreamUrl(current);
|
||||
audio.play().catch(() => {}); // isPlaying reflects reality via the native play/pause listeners below
|
||||
}, [current]);
|
||||
|
||||
// isPlaying always mirrors the audio element's own state, so it stays correct through anything
|
||||
// that changes playback outside togglePlay — a stall, an OS media-key pause, autoplay being blocked.
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
const onTime = () => setProgress({ position: audio.currentTime, duration: audio.duration || 0 });
|
||||
const onEnded = () => {
|
||||
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("ended", onEnded);
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("ended", onEnded);
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [index, queue]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!current) return;
|
||||
if (audio.paused) audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
}, [current]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
|
||||
}, [index, queue]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (index > 0) setIndex(index - 1);
|
||||
}, [index]);
|
||||
|
||||
const seek = useCallback((seconds) => {
|
||||
audioRef.current.currentTime = seconds;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MusicPlayerCtx.Provider value={{
|
||||
current, isPlaying, progress, playAt, togglePlay, next, prev, seek,
|
||||
hasNext: index < queue.length - 1, hasPrev: index > 0,
|
||||
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
|
||||
}}>
|
||||
{children}
|
||||
</MusicPlayerCtx.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMusicPlayer = () => useContext(MusicPlayerCtx);
|
||||
@@ -6,19 +6,15 @@ const ProfileCtx = createContext(null);
|
||||
|
||||
export const ProfileProvider = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [active, setActive] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!user) { setProfiles([]); setActive(null); return; }
|
||||
if (!user) { setActive(null); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/profiles");
|
||||
setProfiles(data);
|
||||
const stored = localStorage.getItem(`kino_profile_${user.id}`);
|
||||
const found = data.find((p) => p.id === stored);
|
||||
setActive(found || null);
|
||||
setActive(data[0] || null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const switchTo = (p) => {
|
||||
if (!user) return;
|
||||
localStorage.setItem(`kino_profile_${user.id}`, p.id);
|
||||
setActive(p);
|
||||
};
|
||||
|
||||
const clearActive = () => {
|
||||
if (!user) return;
|
||||
localStorage.removeItem(`kino_profile_${user.id}`);
|
||||
setActive(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
|
||||
<ProfileCtx.Provider value={{ active, loading, refresh }}>
|
||||
{children}
|
||||
</ProfileCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
|
||||
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
export default function Admin() {
|
||||
@@ -85,11 +85,17 @@ export default function Admin() {
|
||||
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
|
||||
<Download size={14} strokeWidth={1.5} /> Radarr Import
|
||||
</Link>
|
||||
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
|
||||
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
|
||||
</Link>
|
||||
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
|
||||
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
|
||||
</Link>
|
||||
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
|
||||
</Link>
|
||||
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Users
|
||||
</Link>
|
||||
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
|
||||
Review Requests
|
||||
@@ -116,12 +122,16 @@ export default function Admin() {
|
||||
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
|
||||
<span className="col-span-2">{hlsStatusBadge(m)}</span>
|
||||
<div className="col-span-2 flex justify-end gap-2">
|
||||
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && (
|
||||
{(m.storage_type === "local" || m.storage_type === "radarr" || m.storage_type === "scan") && m.hls_status !== "running" && m.hls_status !== "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
|
||||
title="Quick transcode (stream-copy, single bitrate)"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
data-testid={`transcode-quick-${m.id}`}>Quick</button>
|
||||
<button onClick={() => startTranscode(m, "audio")} disabled={transcoding[m.id]}
|
||||
title="Fix audio only (video untouched, audio re-encoded to AAC) — fast, fixes no-sound from AC3/DTS/EAC3"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
data-testid={`transcode-audio-${m.id}`}>Audio</button>
|
||||
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
|
||||
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function AdminShowEpisodes() {
|
||||
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
|
||||
msg = `${data.modified} unhidden`;
|
||||
} else if (action === "delete") {
|
||||
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but Kino entries and HLS output are removed.`)) return;
|
||||
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but StreamHoard entries and HLS output are removed.`)) return;
|
||||
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
|
||||
msg = `${data.deleted} deleted`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Play, Pause, ArrowLeft, Music2 } from "lucide-react";
|
||||
import { useMusicPlayer } from "../lib/musicPlayer";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function AlbumDetail() {
|
||||
const { artist, album } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const { current, isPlaying, togglePlay, playAt } = useMusicPlayer();
|
||||
|
||||
const artistName = decodeURIComponent(artist);
|
||||
const albumName = decodeURIComponent(album);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/tracks").then(({ data }) => setTracks(data));
|
||||
}, []);
|
||||
|
||||
const albumTracks = useMemo(
|
||||
() => tracks.filter((t) => t.artist === artistName && t.album === albumName)
|
||||
.sort((a, b) => (a.track_number || 0) - (b.track_number || 0)),
|
||||
[tracks, artistName, albumName]
|
||||
);
|
||||
const artUrl = albumTracks.find((t) => t.album_art_url)?.album_art_url;
|
||||
|
||||
const playTrack = (t) => {
|
||||
const idx = albumTracks.findIndex((x) => x.id === t.id);
|
||||
if (current?.id === t.id) togglePlay();
|
||||
else playAt(albumTracks, idx);
|
||||
};
|
||||
|
||||
if (albumTracks.length === 0) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid="album-detail-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<button onClick={() => nav(-1)} className="flex items-center gap-2 text-white/80 hover:text-white mb-8" data-testid="album-back">
|
||||
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-6 mb-10">
|
||||
<div className="w-32 h-32 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||
<AlbumArt url={artUrl} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Album</span>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2">{albumName}</h1>
|
||||
<p className="text-[#8A8A8A] mt-2">{artistName} · {albumTracks.length} tracks</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{albumTracks.map((t) => {
|
||||
const playing = current?.id === t.id;
|
||||
return (
|
||||
<button key={t.id} onClick={() => playTrack(t)}
|
||||
className={`w-full flex items-center gap-4 p-3 border transition-colors text-left group ${playing ? "border-[#D9381E] bg-[#0F0F0F]" : "border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F]"}`}
|
||||
data-testid={`track-${t.id}`}>
|
||||
<div className="w-9 h-9 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
|
||||
{playing && isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] w-6">{t.track_number || "-"}</span>
|
||||
<h3 className="font-display text-base text-white truncate flex-1">{t.title}</h3>
|
||||
{t.duration_seconds > 0 && <span className="text-xs text-[#8A8A8A]">{Math.floor(t.duration_seconds / 60)}:{Math.floor(t.duration_seconds % 60).toString().padStart(2, "0")}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCcw, Trash2, Wrench, ShieldCheck, Square } from "lucide-react";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: "missing_files", label: "Missing Files", desc: "Database record exists but the file is gone from disk.", action: "Remove record" },
|
||||
{ key: "orphaned_episodes", label: "Orphaned Episodes", desc: "Episode references a show that no longer exists.", action: "Delete episode" },
|
||||
{ key: "orphaned_hls", label: "Orphaned HLS Output", desc: "Transcoded HLS folders left behind by deleted movies/episodes.", action: "Delete folder" },
|
||||
{ key: "stuck_jobs", label: "Stuck Transcode Jobs", desc: "Jobs stuck \"running\" for 2+ hours, usually from a backend restart mid-job.", action: "Mark failed" },
|
||||
{ key: "duplicate_paths", label: "Duplicate Imports", desc: "The same file imported into the library more than once.", action: "Delete duplicate" },
|
||||
{ key: "orphaned_subtitles", label: "Orphaned Subtitles", desc: "Subtitle references a movie/episode that no longer exists.", action: "Delete subtitle" },
|
||||
{ key: "dangling_refs", label: "Dangling Watchlist/Progress", desc: "Watchlist or progress rows pointing at a deleted movie.", action: "Delete row" },
|
||||
];
|
||||
|
||||
export default function LibraryCleanup() {
|
||||
const [findings, setFindings] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [fixing, setFixing] = useState({});
|
||||
const pollRef = useRef(null);
|
||||
|
||||
const checkStatus = async () => {
|
||||
const { data } = await api.get("/library/cleanup/scan/status");
|
||||
setRunning(data.running);
|
||||
if (data.error) toast.error(`Scan failed: ${data.error}`);
|
||||
if (data.findings) setFindings(data.findings);
|
||||
return data.running;
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollRef.current) return;
|
||||
pollRef.current = setInterval(async () => {
|
||||
const stillRunning = await checkStatus();
|
||||
if (!stillRunning) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkStatus().then((isRunning) => { if (isRunning) startPolling(); });
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||
}, []);
|
||||
|
||||
const scan = async () => {
|
||||
if (!window.confirm(
|
||||
"This checks every movie, episode, and track's file against the NAS, which can take a while on a large library (roughly a minute or more) since each check is a network round trip. It runs in the background and won't block the rest of the app — you can navigate away or stop it anytime. Continue?"
|
||||
)) return;
|
||||
try {
|
||||
await api.post("/library/cleanup/scan/start");
|
||||
setRunning(true);
|
||||
setFindings(null);
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not start scan");
|
||||
}
|
||||
};
|
||||
|
||||
const stopScan = async () => {
|
||||
try {
|
||||
await api.post("/library/cleanup/scan/stop");
|
||||
setRunning(false);
|
||||
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||
toast.success("Scan stopped");
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not stop scan");
|
||||
}
|
||||
};
|
||||
|
||||
const fix = async (category, items) => {
|
||||
if (items.length === 0) return;
|
||||
setFixing((p) => ({ ...p, [category]: true }));
|
||||
try {
|
||||
const { data } = await api.post("/library/cleanup/fix", { category, items });
|
||||
toast.success(`Fixed ${data.fixed} item(s)`);
|
||||
scan();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Fix failed");
|
||||
} finally {
|
||||
setFixing((p) => ({ ...p, [category]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const totalIssues = findings ? Object.values(findings).reduce((n, arr) => n + arr.length, 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-cleanup-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Maintenance</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Cleanup</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
Checks for missing files, orphaned records, stuck jobs, and duplicate imports across movies, shows, and music. Nothing is deleted until you click Fix.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
{running ? (
|
||||
<button onClick={stopScan} className="flex items-center gap-2 border border-[#D9381E] text-[#D9381E] hover:bg-[#D9381E]/10 px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="cleanup-stop">
|
||||
<Square size={12} strokeWidth={1.5} fill="currentColor" /> Stop Scan
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={scan} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="cleanup-scan">
|
||||
<RefreshCcw size={14} strokeWidth={1.5} /> {findings ? "Rescan" : "Scan Library"}
|
||||
</button>
|
||||
)}
|
||||
{running && <span className="text-xs text-[#fcd34d] animate-pulse" data-testid="cleanup-running">Scanning… this can take a minute</span>}
|
||||
{!running && findings && (
|
||||
<span className="flex items-center gap-2 text-sm" data-testid="cleanup-summary">
|
||||
{totalIssues === 0 ? (
|
||||
<><ShieldCheck size={16} className="text-[#86efac]" /> <span className="text-[#86efac]">No issues found</span></>
|
||||
) : (
|
||||
<span className="text-[#fcd34d]">{totalIssues} issue(s) found</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-8">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const items = findings?.[cat.key] || [];
|
||||
if (findings && items.length === 0) return null;
|
||||
return (
|
||||
<div key={cat.key} className="border border-[#222]" data-testid={`cleanup-section-${cat.key}`}>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[#222] bg-[#0F0F0F]">
|
||||
<div>
|
||||
<h2 className="text-white text-lg font-display">{cat.label} <span className="text-[#8A8A8A] text-sm">({items.length})</span></h2>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">{cat.desc}</p>
|
||||
</div>
|
||||
<button onClick={() => fix(cat.key, items)} disabled={fixing[cat.key] || items.length === 0}
|
||||
className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-4 py-2 text-xs uppercase tracking-[0.2em] shrink-0"
|
||||
data-testid={`cleanup-fix-all-${cat.key}`}>
|
||||
<Wrench size={13} strokeWidth={1.5} /> {cat.action} All
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{items.map((it) => (
|
||||
<div key={`${it.content_type}-${it.id}`} className="flex items-center justify-between gap-4 px-5 py-3 border-b border-[#222] last:border-b-0" data-testid={`cleanup-item-${it.id}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white text-sm truncate">{it.label}</p>
|
||||
<p className="text-xs text-[#666] truncate">{it.detail}</p>
|
||||
</div>
|
||||
<button onClick={() => fix(cat.key, [it])} disabled={fixing[cat.key]}
|
||||
className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-3 py-2 shrink-0 disabled:opacity-50"
|
||||
data-testid={`cleanup-fix-${it.id}`}>
|
||||
<Trash2 size={12} strokeWidth={1.5} /> {cat.action}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!running && findings && totalIssues === 0 && (
|
||||
<div className="text-center py-16 text-[#8A8A8A]">
|
||||
<ShieldCheck size={40} strokeWidth={1.5} className="mx-auto mb-4 text-[#86efac]" />
|
||||
Library is clean — no missing files, orphaned records, or stuck jobs.
|
||||
</div>
|
||||
)}
|
||||
{!running && !findings && (
|
||||
<div className="text-center py-16 text-[#8A8A8A]">
|
||||
Click "Scan Library" above to check for issues.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCcw, Download, Check, Sparkles, CheckSquare, Square } from "lucide-react";
|
||||
|
||||
const KINDS = [
|
||||
{ key: "movies", label: "Movies" },
|
||||
{ key: "tv", label: "TV Shows" },
|
||||
{ key: "music", label: "Music" },
|
||||
];
|
||||
|
||||
export default function LibraryScan() {
|
||||
const [kind, setKind] = useState("movies");
|
||||
const [results, setResults] = useState([]);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [enrich, setEnrich] = useState(null);
|
||||
const requestSeq = useRef(0);
|
||||
|
||||
const load = async (k = kind) => {
|
||||
const seq = ++requestSeq.current;
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
try {
|
||||
const { data } = await api.get(`/scan/${k}`);
|
||||
if (seq !== requestSeq.current) return; // a newer tab switch/rescan superseded this response
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
toast.error(err.response?.data?.detail || "Scan failed");
|
||||
} finally {
|
||||
if (seq === requestSeq.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { setResults([]); load(kind); }, [kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enrich?.running) return;
|
||||
const t = setInterval(async () => {
|
||||
const { data } = await api.get("/music/enrich/status");
|
||||
setEnrich(data);
|
||||
if (!data.running) clearInterval(t);
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [enrich?.running]);
|
||||
|
||||
const toggle = (path) => {
|
||||
const s = new Set(selected);
|
||||
s.has(path) ? s.delete(path) : s.add(path);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const importable = results.filter((r) => !r.already_imported);
|
||||
const allSelected = importable.length > 0 && importable.every((r) => selected.has(r.storage_path));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
setSelected(allSelected ? new Set() : new Set(importable.map((r) => r.storage_path)));
|
||||
};
|
||||
|
||||
const importSelected = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const items = results.filter((r) => selected.has(r.storage_path));
|
||||
const { data } = await api.post("/scan/import", { kind, items });
|
||||
toast.success(`Imported ${data.imported} item(s)`);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Import failed");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEnrich = async () => {
|
||||
try {
|
||||
await api.post("/music/enrich");
|
||||
const { data } = await api.get("/music/enrich/status");
|
||||
setEnrich(data);
|
||||
toast.success("Fetching metadata & covers from MusicBrainz…");
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not start enrichment");
|
||||
}
|
||||
};
|
||||
|
||||
const label = (r) => {
|
||||
if (kind === "movies") return `${r.title}${r.year ? ` (${r.year})` : ""}`;
|
||||
if (kind === "tv") return `${r.show_title} · S${r.season_number}E${r.episode_number ?? "?"} · ${r.title}`;
|
||||
return `${r.artist} — ${r.album} — ${r.title}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="library-scan-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Filesystem</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Library Scan</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
Scans the NAS mounts directly for movies, TV, and music that aren't already in the library — separate from Radarr/Sonarr, which are untouched.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex gap-3 flex-wrap">
|
||||
{KINDS.map((k) => (
|
||||
<button key={k.key} onClick={() => setKind(k.key)}
|
||||
className={`text-sm px-4 py-2 border transition-colors ${kind === k.key ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
|
||||
data-testid={`scan-tab-${k.key}`}>
|
||||
{k.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-3 flex-wrap items-center">
|
||||
<button onClick={() => load()} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-refresh">
|
||||
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Rescan
|
||||
</button>
|
||||
<button onClick={toggleSelectAll} disabled={importable.length === 0} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="scan-select-all">
|
||||
{allSelected ? <CheckSquare size={14} strokeWidth={1.5} /> : <Square size={14} strokeWidth={1.5} />} {allSelected ? "Deselect All" : "Select All"}
|
||||
</button>
|
||||
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-import-btn">
|
||||
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</button>
|
||||
{kind === "music" && (
|
||||
<button onClick={startEnrich} disabled={enrich?.running} className="flex items-center gap-2 border border-white/10 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="scan-enrich-btn">
|
||||
<Sparkles size={14} strokeWidth={1.5} /> {enrich?.running ? `Fetching covers… ${enrich.done}/${enrich.total}` : "Fetch metadata & covers"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-[#8A8A8A] mt-6 text-sm">
|
||||
{loading ? "Scanning…" : `${importable.length} new of ${results.length} found on disk.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 border border-[#222]">
|
||||
{results.map((r) => (
|
||||
<button key={r.storage_path} onClick={() => !r.already_imported && toggle(r.storage_path)}
|
||||
disabled={r.already_imported}
|
||||
className={`w-full flex items-center gap-4 px-5 py-3 border-b border-[#222] last:border-b-0 text-left transition-colors ${
|
||||
r.already_imported ? "opacity-40 cursor-not-allowed" : selected.has(r.storage_path) ? "bg-[#D9381E]/10" : "hover:bg-[#0F0F0F]"
|
||||
}`}
|
||||
data-testid={`scan-row-${r.storage_path}`}>
|
||||
<div className={`w-5 h-5 shrink-0 border flex items-center justify-center ${selected.has(r.storage_path) ? "bg-[#D9381E] border-[#D9381E]" : "border-[#222]"}`}>
|
||||
{selected.has(r.storage_path) && <Check size={12} strokeWidth={2} color="white" />}
|
||||
</div>
|
||||
<span className="text-white text-sm truncate flex-1">{label(r)}</span>
|
||||
{r.already_imported && <span className="text-[10px] uppercase tracking-[0.2em] text-[#86efac] shrink-0">Imported</span>}
|
||||
</button>
|
||||
))}
|
||||
{results.length === 0 && !loading && (
|
||||
<div className="px-5 py-8 text-center text-[#8A8A8A] text-sm">Nothing found on disk for this category.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export default function Login() {
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="login-form">
|
||||
<Link to="/" className="block mb-12" data-testid="login-logo">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
|
||||
@@ -93,7 +93,7 @@ export default function Login() {
|
||||
|
||||
<div className="mt-8 p-4 border border-[#222] text-xs text-[#8A8A8A]" data-testid="login-demo-credentials">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] block mb-2">Demo Admin</span>
|
||||
admin@kino.local / kino-admin-2026
|
||||
admin@streamhoard.local / kino-admin-2026
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Music2 } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function Music() {
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/tracks").then(({ data }) => setTracks(data));
|
||||
}, []);
|
||||
|
||||
const albums = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const t of tracks) {
|
||||
const key = `${t.artist}|||${t.album}`;
|
||||
if (!map.has(key)) map.set(key, { artist: t.artist, album: t.album, album_art_url: t.album_art_url, count: 0 });
|
||||
map.get(key).count += 1;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.artist.localeCompare(b.artist) || a.album.localeCompare(b.album));
|
||||
}, [tracks]);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-empty-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
|
||||
<Music2 size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
|
||||
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No music yet</h1>
|
||||
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Run a Library Scan in Admin to import tracks from the NAS.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Music</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Music</h1>
|
||||
|
||||
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{albums.map((a) => (
|
||||
<button key={`${a.artist}|||${a.album}`}
|
||||
onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)}
|
||||
className="group relative aspect-square overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`album-card-${a.artist}-${a.album}`}>
|
||||
<AlbumArt url={a.album_art_url} />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{a.album}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { useProfile } from "../lib/profile";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { Plus, Pencil, Check, X, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const COLORS = ["#D9381E", "#EAB308", "#22C55E", "#3B82F6", "#A855F7", "#EC4899"];
|
||||
|
||||
export default function ProfileSelect() {
|
||||
const { user, logout } = useAuth();
|
||||
const { profiles, refresh, switchTo } = useProfile();
|
||||
const nav = useNavigate();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [draft, setDraft] = useState({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const choose = (p) => {
|
||||
switchTo(p);
|
||||
nav("/browse", { replace: true });
|
||||
};
|
||||
|
||||
const submitNew = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!draft.name.trim()) return;
|
||||
try {
|
||||
await api.post("/profiles", draft);
|
||||
toast.success("Profile created");
|
||||
setCreating(false);
|
||||
setDraft({ name: "", avatar_color: COLORS[0], is_kids: false, max_rating: "NR" });
|
||||
refresh();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not create");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
if (!window.confirm("Delete this profile? Watchlist & history will be removed.")) return;
|
||||
try { await api.delete(`/profiles/${id}`); refresh(); } catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not delete");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
|
||||
Choose a viewer
|
||||
</h1>
|
||||
|
||||
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
|
||||
<button
|
||||
onClick={() => editing ? null : choose(p)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
style={{ backgroundColor: p.avatar_color }}
|
||||
data-testid={`select-profile-${p.id}`}
|
||||
>
|
||||
{p.name?.[0]?.toUpperCase() || "?"}
|
||||
</button>
|
||||
<span className="font-display text-lg text-white">{p.name}</span>
|
||||
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{p.is_kids && <span>Kids</span>}
|
||||
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
|
||||
</div>
|
||||
{editing && (
|
||||
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{profiles.length < 5 && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus size={32} strokeWidth={1.5} />
|
||||
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex gap-4">
|
||||
<button onClick={() => setEditing(!editing)}
|
||||
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="manage-profiles-button">
|
||||
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
|
||||
{editing ? "Done" : "Manage Profiles"}
|
||||
</button>
|
||||
<button onClick={() => { logout(); nav("/login"); }}
|
||||
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="profile-logout-button">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
|
||||
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
|
||||
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
|
||||
|
||||
<label className="block mt-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="new-profile-name" />
|
||||
</label>
|
||||
|
||||
<div className="mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
|
||||
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
|
||||
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
|
||||
data-testid={`color-${c.replace('#', '')}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 mt-5 cursor-pointer">
|
||||
<input type="checkbox" checked={draft.is_kids}
|
||||
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
|
||||
className="accent-[#D9381E]" data-testid="new-profile-kids" />
|
||||
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
|
||||
</label>
|
||||
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
|
||||
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
|
||||
data-testid="new-profile-rating">
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NR">No restriction</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
|
||||
data-testid="new-profile-submit">Create profile</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function Register() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
toast.success("Welcome to Kino");
|
||||
toast.success("Welcome to StreamHoard");
|
||||
nav("/browse", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not register");
|
||||
@@ -49,7 +49,7 @@ export default function Register() {
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
|
||||
<Link to="/" className="block mb-12">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
|
||||
|
||||
@@ -3,6 +3,43 @@ import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
searching: "Searching…",
|
||||
queued: "Queued",
|
||||
downloading: "Downloading",
|
||||
"partially downloaded": "Partially downloaded",
|
||||
downloaded: "Downloaded",
|
||||
completed: "Downloaded",
|
||||
delay: "Waiting",
|
||||
unknown: "",
|
||||
};
|
||||
|
||||
function LiveStatus({ requestId }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/requests/${requestId}/status`);
|
||||
if (!cancelled) setStatus(data);
|
||||
} catch { /* transient — next poll will retry */ }
|
||||
};
|
||||
poll();
|
||||
const t = setInterval(poll, 15000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, [requestId]);
|
||||
|
||||
if (!status) return null;
|
||||
const label = STATUS_LABEL[status.state] || status.state;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Requests() {
|
||||
const { user } = useAuth();
|
||||
const [mine, setMine] = useState([]);
|
||||
@@ -10,6 +47,7 @@ export default function Requests() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [approving, setApproving] = useState({});
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/requests/mine");
|
||||
@@ -21,6 +59,19 @@ export default function Requests() {
|
||||
};
|
||||
useEffect(() => { load(); }, [user?.is_admin]);
|
||||
|
||||
const approve = async (id, contentType) => {
|
||||
setApproving((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
|
||||
toast.success(`Searching for "${data.matched_title || data.title}"`);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not approve request");
|
||||
} finally {
|
||||
setApproving((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
@@ -107,7 +158,8 @@ export default function Requests() {
|
||||
</div>
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
||||
r.status === "fulfilled" ? "text-[#86efac]" :
|
||||
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
|
||||
r.status === "rejected" ? "text-[#fca5a5]" :
|
||||
r.status === "approved" ? "text-[#93c5fd]" : "text-[#fcd34d]"
|
||||
}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
@@ -125,13 +177,26 @@ export default function Requests() {
|
||||
) : (
|
||||
<div className="border border-[#222]">
|
||||
{all.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||
<div>
|
||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
|
||||
<div key={r.id} className="flex items-center justify-between gap-4 px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white truncate">
|
||||
{r.matched_title || r.title} {(r.matched_year || r.year) ? <span className="text-[#8A8A8A]">({r.matched_year || r.year})</span> : null}
|
||||
</p>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}</p>
|
||||
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
|
||||
{r.status === "approved" && <div className="mt-1"><LiveStatus requestId={r.id} /></div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{r.status === "pending" && (
|
||||
<>
|
||||
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
|
||||
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => updateStatus(r.id, "fulfilled")}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
|
||||
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState, createContext, useContext } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
|
||||
|
||||
@@ -40,19 +41,28 @@ const Field = ({ label, k, type = "text", placeholder, mask }) => {
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = !!user?.is_admin;
|
||||
const [s, setS] = useState({
|
||||
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||
sonarr_url: "", sonarr_api_key: "",
|
||||
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
||||
auto_transcode: "off", queue_paused: false,
|
||||
music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "",
|
||||
});
|
||||
const [info, setInfo] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
||||
const [traktModal, setTraktModal] = useState(null);
|
||||
const [show, setShow] = useState({});
|
||||
const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" });
|
||||
const [pwShow, setPwShow] = useState(false);
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [enrich, setEnrich] = useState(null);
|
||||
const [identify, setIdentify] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
if (!isAdmin) return;
|
||||
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
||||
setS({
|
||||
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
|
||||
@@ -60,10 +70,13 @@ export default function Settings() {
|
||||
opensubs_api_key: data.opensubs_api_key || "",
|
||||
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
|
||||
auto_transcode: data.auto_transcode || "off", queue_paused: !!data.queue_paused,
|
||||
music_itunes_fallback: data.music_itunes_fallback !== false,
|
||||
music_acoustid_enabled: !!data.music_acoustid_enabled,
|
||||
acoustid_api_key: data.acoustid_api_key || "",
|
||||
});
|
||||
setInfo(data); setTraktStatus(ts);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault(); setSaving(true);
|
||||
@@ -97,19 +110,130 @@ export default function Settings() {
|
||||
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
|
||||
};
|
||||
|
||||
const changePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
if (pw.new_password.length < 8) { toast.error("New password must be at least 8 characters"); return; }
|
||||
if (pw.new_password !== pw.confirm) { toast.error("New passwords don't match"); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await api.post("/auth/change-password", { current_password: pw.current_password, new_password: pw.new_password });
|
||||
toast.success("Password changed");
|
||||
setPw({ current_password: "", new_password: "", confirm: "" });
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not change password"); }
|
||||
finally { setPwSaving(false); }
|
||||
};
|
||||
|
||||
const enrichAll = async () => {
|
||||
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
||||
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
||||
try {
|
||||
await api.post("/tmdb/enrich-all");
|
||||
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||
setEnrich(data);
|
||||
toast.success("Enrichment started");
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
|
||||
api.get("/music/identify/status").then(({ data }) => setIdentify(data)).catch(() => {});
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enrich?.running) return;
|
||||
const t = setInterval(async () => {
|
||||
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||
setEnrich(data);
|
||||
if (!data.running) {
|
||||
clearInterval(t);
|
||||
if (data.error) {
|
||||
toast.error(`Enrichment failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success(`Enrichment done — ${data.enriched} enriched, ${data.skipped} already complete, ${data.failed} not found`);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [enrich?.running]);
|
||||
|
||||
const identifyAll = async () => {
|
||||
if (!window.confirm("Fingerprint every track with an unknown artist or album and identify it via AcoustID? This may take a while.")) return;
|
||||
try {
|
||||
await api.post("/music/identify");
|
||||
const { data } = await api.get("/music/identify/status");
|
||||
setIdentify(data);
|
||||
toast.success("Identification started");
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!identify?.running) return;
|
||||
const t = setInterval(async () => {
|
||||
const { data } = await api.get("/music/identify/status");
|
||||
setIdentify(data);
|
||||
if (!data.running) {
|
||||
clearInterval(t);
|
||||
if (data.error) {
|
||||
toast.error(`Identification failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success(`Identification done — ${data.identified} of ${data.total} tracks identified`);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [identify?.running]);
|
||||
|
||||
return (
|
||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||
<div className="px-6 md:px-12 max-w-3xl mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{isAdmin ? "Admin" : "Account"}</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
|
||||
|
||||
<form onSubmit={changePassword} className="mt-12 space-y-4 pb-10 border-b border-[#222]" data-testid="change-password-form">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Password</h2>
|
||||
<p className="text-sm text-[#8A8A8A]">Requires your current password. Changing it locks out anyone who only had the old one.</p>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Current password</span>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={pwShow ? "text" : "password"}
|
||||
value={pw.current_password}
|
||||
onChange={(e) => setPw({ ...pw, current_password: e.target.value })}
|
||||
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
|
||||
data-testid="current-password"
|
||||
/>
|
||||
<button type="button" onClick={() => setPwShow(!pwShow)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
|
||||
{pwShow ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">New password (min 8 characters)</span>
|
||||
<input
|
||||
type={pwShow ? "text" : "password"}
|
||||
value={pw.new_password}
|
||||
onChange={(e) => setPw({ ...pw, new_password: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Confirm new password</span>
|
||||
<input
|
||||
type={pwShow ? "text" : "password"}
|
||||
value={pw.confirm}
|
||||
onChange={(e) => setPw({ ...pw, confirm: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="confirm-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pwSaving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="change-password-button">
|
||||
{pwSaving ? "Changing…" : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||
{/* TMDB */}
|
||||
<section>
|
||||
@@ -119,9 +243,21 @@ export default function Settings() {
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
|
||||
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||
{info.tmdb_configured && (
|
||||
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
|
||||
Bulk enrich all movies →
|
||||
</button>
|
||||
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||
<button type="button" onClick={enrichAll} disabled={enrich?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="enrich-all-button">
|
||||
{enrich?.running ? `Enriching… ${enrich.done}/${enrich.total}` : "Bulk enrich all movies →"}
|
||||
</button>
|
||||
{enrich && !enrich.running && enrich.error && (
|
||||
<span className="text-xs text-[#fca5a5]" data-testid="enrich-all-error">
|
||||
Last run failed: {enrich.error}
|
||||
</span>
|
||||
)}
|
||||
{enrich && !enrich.running && !enrich.error && enrich.total > 0 && (
|
||||
<span className="text-xs text-[#8A8A8A]" data-testid="enrich-all-summary">
|
||||
Last run: {enrich.enriched} enriched · {enrich.skipped} already complete · {enrich.failed} not found on TMDB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -204,10 +340,70 @@ export default function Settings() {
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Music Metadata & Artwork */}
|
||||
<section>
|
||||
<h2 className="font-display text-2xl font-bold text-white mb-3">Music Metadata & Artwork</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Album art/tags come from MusicBrainz + Cover Art Archive first (free, no key). These add free fallbacks for what that misses.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.music_itunes_fallback}
|
||||
onChange={(e) => setS({ ...s, music_itunes_fallback: e.target.checked })}
|
||||
className="w-4 h-4 accent-[#D9381E]"
|
||||
data-testid="settings-itunes-fallback"
|
||||
/>
|
||||
<span className="text-sm text-white">Fall back to iTunes Search for artwork MusicBrainz doesn't have</span>
|
||||
</label>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1 ml-7">No API key needed — always free, no signup.</p>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-[#222]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-white">AcoustID audio fingerprint identification</span>
|
||||
<Badge on={info.acoustid_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Identifies tracks with missing/unknown artist or album by fingerprinting the actual audio file — useful for badly-tagged files with nothing to text-search on. Free key at{" "}
|
||||
<a className="text-[#D9381E]" href="https://acoustid.org/api-key" target="_blank" rel="noreferrer">acoustid.org/api-key</a>.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.music_acoustid_enabled}
|
||||
onChange={(e) => setS({ ...s, music_acoustid_enabled: e.target.checked })}
|
||||
className="w-4 h-4 accent-[#D9381E]"
|
||||
data-testid="settings-acoustid-enabled"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable AcoustID identification</span>
|
||||
</label>
|
||||
<Field label="API key" k="acoustid_api_key" mask />
|
||||
{info.acoustid_configured && s.music_acoustid_enabled && (
|
||||
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||
<button type="button" onClick={identifyAll} disabled={identify?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="identify-all-button">
|
||||
{identify?.running ? `Identifying… ${identify.done}/${identify.total}` : "Identify unknown tracks →"}
|
||||
</button>
|
||||
{identify && !identify.running && identify.error && (
|
||||
<span className="text-xs text-[#fca5a5]" data-testid="identify-all-error">
|
||||
Last run failed: {identify.error}
|
||||
</span>
|
||||
)}
|
||||
{identify && !identify.running && !identify.error && identify.total > 0 && (
|
||||
<span className="text-xs text-[#8A8A8A]" data-testid="identify-all-summary">
|
||||
Last run: {identify.identified} of {identify.total} identified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trakt device-code modal */}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Play, ArrowLeft } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function ShowDetail() {
|
||||
const { id } = useParams();
|
||||
@@ -27,7 +28,7 @@ export default function ShowDetail() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
|
||||
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
|
||||
<img src={show.backdrop_url || show.poster_url} alt="" className="absolute inset-0 w-full h-full object-cover" />
|
||||
<AlbumArt url={show.backdrop_url || show.poster_url} className="absolute inset-0" />
|
||||
<div className="absolute inset-0 hero-fade" />
|
||||
<button onClick={() => nav(-1)} className="absolute top-24 left-6 md:left-12 flex items-center gap-2 text-white/80 hover:text-white bg-black/40 hover:bg-black/60 px-4 py-2 backdrop-blur transition-colors" data-testid="show-back">
|
||||
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Tv } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function Shows() {
|
||||
const [shows, setShows] = useState([]);
|
||||
@@ -51,7 +52,7 @@ export default function Shows() {
|
||||
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
|
||||
className="relative shrink-0 w-[260px] group focus:outline-none" data-testid={`continue-ep-${episode.id}`}>
|
||||
<div className="aspect-video overflow-hidden bg-[#0F0F0F] transition-transform duration-300 group-hover:scale-105">
|
||||
<img src={show.backdrop_url || show.poster_url} alt="" className="w-full h-full object-cover" />
|
||||
<AlbumArt url={show.backdrop_url || show.poster_url} />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
@@ -70,7 +71,7 @@ export default function Shows() {
|
||||
<button key={s.id} onClick={() => nav(`/show/${s.id}`)}
|
||||
className="group shrink-0 aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`show-card-${s.id}`}>
|
||||
<img src={s.poster_url || s.backdrop_url} alt={s.title} loading="lazy" className="w-full h-full object-cover" />
|
||||
<AlbumArt url={s.poster_url || s.backdrop_url} loading="lazy" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{s.title}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{s.season_count} seasons · {s.episode_count} eps</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
|
||||
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2, Volume2 } from "lucide-react";
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pending: "text-[#fcd34d]",
|
||||
@@ -9,11 +9,13 @@ const STATUS_COLORS = {
|
||||
done: "text-[#86efac]",
|
||||
failed: "text-[#fca5a5]",
|
||||
cancelled: "text-[#8A8A8A]",
|
||||
superseded: "text-[#8A8A8A]",
|
||||
};
|
||||
|
||||
export default function TranscodeQueue() {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false, auto_transcode: "off" });
|
||||
const [runningJob, setRunningJob] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
@@ -24,15 +26,25 @@ export default function TranscodeQueue() {
|
||||
setStats(s.data);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
// The running job can be far outside the main list's created_at-desc/500-row window once
|
||||
// there's a large backlog (the FIFO worker processes oldest-first), so it's fetched separately
|
||||
// rather than derived from `jobs`.
|
||||
const loadRunning = async () => {
|
||||
try { const { data } = await api.get("/transcode/queue/running"); setRunningJob(data); }
|
||||
catch { /* ignore transient poll failures */ }
|
||||
};
|
||||
useEffect(() => { load(); loadRunning(); }, []);
|
||||
|
||||
// Poll while jobs are active
|
||||
// Poll while jobs are active — faster while something is actually running so the
|
||||
// progress bar feels live, slower while only pending jobs are waiting.
|
||||
useEffect(() => {
|
||||
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
|
||||
if (!active) return;
|
||||
const t = setInterval(load, 4000);
|
||||
const pending = jobs.some((j) => j.status === "pending");
|
||||
if (!runningJob && !pending) return;
|
||||
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
|
||||
return () => clearInterval(t);
|
||||
}, [jobs]);
|
||||
}, [jobs, runningJob]);
|
||||
|
||||
const listedJobs = jobs.filter((j) => j.status !== "running");
|
||||
|
||||
const cancel = async (id) => {
|
||||
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
|
||||
@@ -44,6 +56,14 @@ export default function TranscodeQueue() {
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
|
||||
};
|
||||
|
||||
const retryAllFailed = async () => {
|
||||
try {
|
||||
const { data } = await api.post("/transcode/queue/retry-failed");
|
||||
toast.success(`Re-queued ${data.retried} job(s)`);
|
||||
load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
const { data } = await api.post("/transcode/queue/pause", { paused: !stats.paused });
|
||||
@@ -52,6 +72,17 @@ export default function TranscodeQueue() {
|
||||
} catch { toast.error("Could not toggle pause"); }
|
||||
};
|
||||
|
||||
const fixAudioAll = async () => {
|
||||
if (!window.confirm(
|
||||
"Cancels every job still pending (not yet started) and replaces it with a fast audio-only fix — video is left untouched, only the audio track is re-encoded to AAC. This is much faster than the full ABR queue and fixes \"no sound\" caused by AC3/DTS/EAC3 audio (common on BDRips), which browsers can't play natively. Continue?"
|
||||
)) return;
|
||||
try {
|
||||
const { data } = await api.post("/transcode/queue/fix-audio-all");
|
||||
toast.success(`Queued audio fix for ${data.queued} item(s), replacing ${data.cancelled} pending job(s)`);
|
||||
load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
|
||||
};
|
||||
|
||||
const clearFinished = async () => {
|
||||
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
|
||||
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
|
||||
@@ -64,9 +95,9 @@ export default function TranscodeQueue() {
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
One job runs at a time at low CPU priority. Auto-transcode is currently:{" "}
|
||||
One job runs at a time, moderate CPU priority, capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
|
||||
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
|
||||
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>
|
||||
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie — retry re-queues the same file.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
@@ -86,12 +117,42 @@ export default function TranscodeQueue() {
|
||||
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
|
||||
{stats.paused ? "Resume" : "Pause"}
|
||||
</button>
|
||||
<button onClick={retryAllFailed} disabled={!stats.failed && !stats.cancelled} className="flex items-center gap-2 border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="queue-retry-all">
|
||||
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
|
||||
</button>
|
||||
<button onClick={fixAudioAll} className="flex items-center gap-2 border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-fix-audio-all">
|
||||
<Volume2 size={14} strokeWidth={1.5} /> Fix Audio (Fast, All)
|
||||
</button>
|
||||
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-clear">
|
||||
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 border border-[#222]">
|
||||
{runningJob && (
|
||||
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="now-processing">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
|
||||
Now Processing
|
||||
</span>
|
||||
<span>{(runningJob.progress || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
|
||||
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
{runningJob.quality} · {runningJob.triggered_by}
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#D9381E] transition-[width] duration-500 ease-linear rounded-full"
|
||||
style={{ width: `${Math.max(2, runningJob.progress || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border border-[#222]">
|
||||
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<span className="col-span-5">Movie</span>
|
||||
<span className="col-span-1">Quality</span>
|
||||
@@ -100,9 +161,9 @@ export default function TranscodeQueue() {
|
||||
<span className="col-span-2">Created</span>
|
||||
<span className="col-span-1 text-right">Actions</span>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
{listedJobs.length === 0 ? (
|
||||
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div>
|
||||
) : jobs.map((j) => (
|
||||
) : listedJobs.map((j) => (
|
||||
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`queue-row-${j.id}`}>
|
||||
<div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
|
||||
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
|
||||
@@ -113,19 +174,14 @@ export default function TranscodeQueue() {
|
||||
</span>
|
||||
<span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</span>
|
||||
<div className="col-span-1 flex justify-end gap-2">
|
||||
{j.status === "running" && (
|
||||
<button disabled className="text-[#444] cursor-not-allowed" title="Cannot cancel a running job" aria-label="Running">
|
||||
<X size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{j.status === "pending" && (
|
||||
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
|
||||
<X size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
{(j.status === "failed" || j.status === "cancelled") && (
|
||||
<button onClick={() => retry(j.id)} className="text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`} aria-label="Retry">
|
||||
<RotateCcw size={14} strokeWidth={1.5} />
|
||||
<button onClick={() => retry(j.id)} className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`}>
|
||||
<RotateCcw size={13} strokeWidth={1.5} /> Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2, UserPlus, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
export default function Users() {
|
||||
const { user: me } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [form, setForm] = useState({ email: "", password: "", name: "", is_admin: false });
|
||||
const [showPw, setShowPw] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/admin/users");
|
||||
setUsers(data);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const createUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post("/admin/users", form);
|
||||
toast.success("User created");
|
||||
setForm({ email: "", password: "", name: "", is_admin: false });
|
||||
setShowNew(false);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not create user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAdmin = async (u) => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}`, { is_admin: !u.is_admin });
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not update user");
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (u) => {
|
||||
if (!window.confirm(`Delete user ${u.email}? This also removes their profiles/watchlist.`)) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${u.id}`);
|
||||
toast.success("User deleted");
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not delete user");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="users-page">
|
||||
<div className="px-6 md:px-12 max-w-4xl mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Users</h1>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNew(!showNew)}
|
||||
className="mt-8 flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]"
|
||||
data-testid="new-user-button"
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={1.5} /> {showNew ? "Cancel" : "New User"}
|
||||
</button>
|
||||
|
||||
{showNew && (
|
||||
<form onSubmit={createUser} className="mt-6 border border-[#222] p-6 space-y-4" data-testid="new-user-form">
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Email</span>
|
||||
<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3" />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Password (min 8 chars)</span>
|
||||
<div className="relative mt-2">
|
||||
<input type={showPw ? "text" : "password"} value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required
|
||||
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12" />
|
||||
<button type="button" onClick={() => setShowPw(!showPw)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
|
||||
{showPw ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-center gap-3">
|
||||
<input type="checkbox" checked={form.is_admin} onChange={(e) => setForm({ ...form, is_admin: e.target.checked })} />
|
||||
<span className="text-sm text-[#8A8A8A]">Admin access</span>
|
||||
</label>
|
||||
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]">
|
||||
{saving ? "Creating…" : "Create User"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-10 border border-[#222]">
|
||||
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<span className="col-span-4">Name</span>
|
||||
<span className="col-span-4">Email</span>
|
||||
<span className="col-span-2">Role</span>
|
||||
<span className="col-span-2 text-right">Actions</span>
|
||||
</div>
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`user-row-${u.id}`}>
|
||||
<span className="col-span-4 text-white truncate">{u.name}</span>
|
||||
<span className="col-span-4 text-[#8A8A8A] text-sm truncate">{u.email}</span>
|
||||
<div className="col-span-2">
|
||||
<button
|
||||
onClick={() => toggleAdmin(u)}
|
||||
disabled={u.id === me?.id && u.is_admin}
|
||||
title={u.id === me?.id && u.is_admin ? "Can't remove your own admin access" : "Toggle role"}
|
||||
className={`text-[10px] uppercase tracking-[0.2em] px-2 py-1 border disabled:opacity-40 disabled:cursor-not-allowed ${u.is_admin ? "border-[#D9381E] text-[#D9381E]" : "border-[#222] text-[#8A8A8A] hover:border-white hover:text-white"}`}
|
||||
data-testid={`toggle-admin-${u.id}`}
|
||||
>
|
||||
{u.is_admin ? "Admin" : "User"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => removeUser(u)}
|
||||
disabled={u.id === me?.id}
|
||||
title={u.id === me?.id ? "Can't delete your own account" : "Delete"}
|
||||
className="text-[#8A8A8A] hover:text-[#fca5a5] disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
data-testid={`delete-user-${u.id}`}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Kino — one-line installer
|
||||
# StreamHoard — one-line installer
|
||||
#
|
||||
# Usage (interactive):
|
||||
# curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh | sudo bash
|
||||
@@ -25,7 +25,7 @@ KINO_DIR="${KINO_DIR:-/opt/kino}"
|
||||
KINO_HTTP_PORT="${KINO_HTTP_PORT:-8080}"
|
||||
MEDIA_ROOT_HOST="${MEDIA_ROOT_HOST:-${KINO_DIR}/media}"
|
||||
DB_NAME="${DB_NAME:-kino}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@kino.local}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@streamhoard.local}"
|
||||
ADMIN_NAME="${ADMIN_NAME:-Admin}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||
JWT_SECRET="${JWT_SECRET:-}"
|
||||
@@ -172,7 +172,7 @@ fi
|
||||
# ---------- Done ----------
|
||||
cat <<EOF
|
||||
|
||||
$(c_grn '━━━ Kino is up ━━━')
|
||||
$(c_grn '━━━ StreamHoard is up ━━━')
|
||||
|
||||
URL : $(c_cyn "$URL")
|
||||
Admin : $(c_cyn "$ADMIN_EMAIL")
|
||||
|
||||
Reference in New Issue
Block a user