mirror of
https://github.com/myronblair/kino-app
synced 2026-07-31 13:02:29 -05:00
Compare commits
37 Commits
211d4c23d5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b28a49e8fa | |||
| 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 / agent runtime ---
|
||||||
test_reports/
|
test_reports/
|
||||||
.emergent/
|
|
||||||
tests/__pycache__/
|
tests/__pycache__/
|
||||||
|
|
||||||
# --- Misc ---
|
# --- 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
|
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.
|
uses whatever port you pick.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -54,7 +54,7 @@ Log in with the admin credentials from `.env`.
|
|||||||
|
|
||||||
## Running behind your existing Apache (optional, recommended)
|
## 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`
|
that reverse-proxies to the container. This gives you `https://kino.example.com`
|
||||||
instead of `http://ip:8080`.
|
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
|
## 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 |
|
| Port `KINO_HTTP_PORT` | Apache 80/443 | No — different ports |
|
||||||
| MongoDB (internal only) | MySQL | No — different DBs, Mongo not exposed to host |
|
| MongoDB (internal only) | MySQL | No — different DBs, Mongo not exposed to host |
|
||||||
@@ -144,16 +144,16 @@ same box.
|
|||||||
|
|
||||||
## Radarr integration
|
## 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**:
|
at it via **Admin → Settings → Radarr**:
|
||||||
|
|
||||||
- **URL**: `http://<host-ip>:7878` (use the host IP, not `localhost` — containers
|
- **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`)
|
can't reach the host's `localhost` unless you add `host.docker.internal`)
|
||||||
- **API key**: from Radarr → Settings → General → Security
|
- **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
|
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
|
```yaml
|
||||||
# docker-compose.yml — extend the backend volumes
|
# docker-compose.yml — extend the backend volumes
|
||||||
@@ -163,7 +163,7 @@ backend:
|
|||||||
- /mnt/radarr-movies:/mnt/radarr-movies:ro # <— add this
|
- /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).
|
(easiest: match the paths exactly).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -172,14 +172,14 @@ Radarr's file paths must be identical inside both Radarr and Kino containers
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
|
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
|
||||||
# {"app":"Kino","status":"ok"}
|
# {"app":"StreamHoard","status":"ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Firewalling
|
## 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:
|
**internal network only**, keep it that way:
|
||||||
|
|
||||||
```bash
|
```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.
|
A self-hosted Netflix-style streaming app for movies you legally own.
|
||||||
Built with FastAPI + React + MongoDB.
|
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.)*
|
*(Legal alternative to auto-downloading — auto-download of copyrighted material is not implemented.)*
|
||||||
|
|
||||||
## Default admin
|
## Default admin
|
||||||
- Email: `admin@kino.local`
|
- Email: `admin@streamhoard.local`
|
||||||
- Password: `kino-admin-2026`
|
- Password: `kino-admin-2026`
|
||||||
|
|
||||||
Change `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `backend/.env` before first start
|
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
|
# Login
|
||||||
curl -X POST $URL/api/auth/login \
|
curl -X POST $URL/api/auth/login \
|
||||||
-H 'Content-Type: application/json' \
|
-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
|
# List movies
|
||||||
curl $URL/api/movies
|
curl $URL/api/movies
|
||||||
@@ -130,7 +130,7 @@ curl $URL/api/stream/<movie_id>?auth=<token>
|
|||||||
## Why no auto-download?
|
## Why no auto-download?
|
||||||
|
|
||||||
Auto-downloading copyrighted movies from the internet is illegal in most
|
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
|
**personal media server** for content you legally own or that is in the
|
||||||
public domain (Internet Archive, Blender Open Movies, etc.).
|
public domain (Internet Archive, Blender Open Movies, etc.).
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,10 +1,12 @@
|
|||||||
# Kino backend — FastAPI + ffmpeg
|
# Kino backend — FastAPI + ffmpeg
|
||||||
FROM python:3.11-slim
|
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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
curl \
|
curl \
|
||||||
|
libchromaprint-tools \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1.1.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,53 @@
|
|||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
|
from transcode import classify_source
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
client = AsyncIOMotorClient(os.environ["MONGO_URL"])
|
||||||
|
db = client[os.environ.get("DB_NAME", "kino")]
|
||||||
|
videos_dir = Path(os.environ.get("MEDIA_ROOT", "/media")) / "videos"
|
||||||
|
|
||||||
|
cancel_res = await db.transcode_queue.update_many(
|
||||||
|
{"status": "pending"},
|
||||||
|
{"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}},
|
||||||
|
)
|
||||||
|
print(f"cancelled stale pending jobs: {cancel_res.modified_count}")
|
||||||
|
|
||||||
|
stats = {"native": 0, "audio": 0, "abr": 0, "no_source": 0}
|
||||||
|
for coll_name, content_type in (("movies", "movie"), ("episodes", "episode")):
|
||||||
|
coll = db[coll_name]
|
||||||
|
async for doc in coll.find({"hls_status": {"$ne": "done"}}, {"_id": 0}):
|
||||||
|
storage_type = doc.get("storage_type")
|
||||||
|
storage_path = doc.get("storage_path")
|
||||||
|
if storage_type not in ("local", "radarr", "sonarr", "scan") or not storage_path:
|
||||||
|
stats["no_source"] += 1
|
||||||
|
continue
|
||||||
|
source = Path(storage_path) if storage_type in ("radarr", "sonarr", "scan") else videos_dir / storage_path
|
||||||
|
|
||||||
|
classification = await classify_source(source)
|
||||||
|
if classification == "native":
|
||||||
|
await coll.update_one({"id": doc["id"]}, {"$set": {"hls_status": "not_needed"}})
|
||||||
|
stats["native"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
quality = "abr" if classification == "abr" else "audio"
|
||||||
|
stats[quality] += 1
|
||||||
|
job = {
|
||||||
|
"id": str(uuid.uuid4()), "movie_id": doc["id"], "movie_title": doc.get("title", ""),
|
||||||
|
"content_type": content_type, "quality": quality, "status": "pending", "error": "",
|
||||||
|
"triggered_by": "backfill", "progress": 0.0, "superseded_by": None,
|
||||||
|
"created_at": datetime.now(timezone.utc).isoformat(), "started_at": None, "finished_at": None,
|
||||||
|
}
|
||||||
|
await db.transcode_queue.insert_one(job)
|
||||||
|
await coll.update_one({"id": doc["id"]}, {"$set": {"hls_status": "pending"}})
|
||||||
|
|
||||||
|
print(stats)
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -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))
|
||||||
+83
-9
@@ -20,6 +20,11 @@ class UserLogin(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class UserPublic(BaseModel):
|
class UserPublic(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
id: str
|
id: str
|
||||||
@@ -29,15 +34,21 @@ class UserPublic(BaseModel):
|
|||||||
created_at: str
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
# ---------- Profiles ("Who's Watching") ----------
|
class AdminUserCreate(BaseModel):
|
||||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
class ProfileCreate(BaseModel):
|
|
||||||
name: str
|
name: str
|
||||||
avatar_color: str = "#D9381E"
|
is_admin: bool = False
|
||||||
is_kids: bool = False
|
|
||||||
max_rating: str = "NR" # one of RATING_ORDER
|
|
||||||
|
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):
|
class ProfileUpdate(BaseModel):
|
||||||
@@ -108,6 +119,29 @@ class Movie(MovieBase):
|
|||||||
created_at: str = Field(default_factory=_now_iso)
|
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 ----------
|
# ---------- Subtitles ----------
|
||||||
class Subtitle(BaseModel):
|
class Subtitle(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
@@ -157,6 +191,12 @@ class RequestUpdate(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class RequestApprove(BaseModel):
|
||||||
|
content_type: str # "movie" | "tv"
|
||||||
|
tmdb_id: Optional[int] = None # movie: explicit pick from /requests/{id}/candidates
|
||||||
|
tvdb_id: Optional[int] = None # tv: explicit pick from /requests/{id}/candidates
|
||||||
|
|
||||||
|
|
||||||
class MovieRequest(BaseModel):
|
class MovieRequest(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@@ -165,7 +205,12 @@ class MovieRequest(BaseModel):
|
|||||||
title: str
|
title: str
|
||||||
year: Optional[int] = None
|
year: Optional[int] = None
|
||||||
notes: str = ""
|
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)
|
created_at: str = Field(default_factory=_now_iso)
|
||||||
|
|
||||||
|
|
||||||
@@ -182,6 +227,9 @@ class AppSettings(BaseModel):
|
|||||||
trakt_client_secret: str = ""
|
trakt_client_secret: str = ""
|
||||||
auto_transcode: str = "off"
|
auto_transcode: str = "off"
|
||||||
queue_paused: bool = False
|
queue_paused: bool = False
|
||||||
|
music_itunes_fallback: bool = True
|
||||||
|
music_acoustid_enabled: bool = False
|
||||||
|
acoustid_api_key: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AppSettingsPublic(BaseModel):
|
class AppSettingsPublic(BaseModel):
|
||||||
@@ -200,6 +248,10 @@ class AppSettingsPublic(BaseModel):
|
|||||||
trakt_client_secret: str = ""
|
trakt_client_secret: str = ""
|
||||||
auto_transcode: str = "off"
|
auto_transcode: str = "off"
|
||||||
queue_paused: bool = False
|
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) ----------
|
# ---------- Shows / Episodes (Sonarr) ----------
|
||||||
@@ -323,6 +375,28 @@ class TranscodeJob(BaseModel):
|
|||||||
status: str = "pending"
|
status: str = "pending"
|
||||||
error: str = ""
|
error: str = ""
|
||||||
triggered_by: str = "manual"
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class CodecCheckJob(BaseModel):
|
||||||
|
"""Step 1 of the pipeline, ahead of TranscodeJob (step 2): probes the source file's actual
|
||||||
|
video/audio codecs and decides whether it needs a transcode at all. 'native' sources skip
|
||||||
|
straight to the library with no transcode job ever created; 'audio'/'abr' sources get
|
||||||
|
handed off to a TranscodeJob."""
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
movie_id: str # generic content id (movie_id OR episode_id, depending on content_type)
|
||||||
|
movie_title: str = ""
|
||||||
|
content_type: str = "movie" # movie | episode
|
||||||
|
status: str = "pending" # pending | running | done | failed | cancelled | superseded
|
||||||
|
classification: Optional[str] = None # native | audio | abr, set once done
|
||||||
|
error: str = ""
|
||||||
|
triggered_by: str = "manual"
|
||||||
|
superseded_by: Optional[str] = None
|
||||||
created_at: str = Field(default_factory=_now_iso)
|
created_at: str = Field(default_factory=_now_iso)
|
||||||
started_at: Optional[str] = None
|
started_at: Optional[str] = None
|
||||||
finished_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,73 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
|
|||||||
return out
|
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.
|
||||||
|
If the movie is already in Radarr's library, Radarr rejects the add with a 400
|
||||||
|
MovieExistsValidator — in that case, reuse the existing movie and just kick off a search
|
||||||
|
instead of surfacing the add as a failure."""
|
||||||
|
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)
|
||||||
|
if r.status_code == 400 and any(e.get("errorCode") == "MovieExistsValidator" for e in (r.json() or [])):
|
||||||
|
existing = requests.get(f"{base_url}/api/v3/movie", params={"tmdbId": tmdb_id}, headers=h, timeout=15).json()
|
||||||
|
if not existing:
|
||||||
|
raise RuntimeError("Radarr reported movie already exists but it could not be found")
|
||||||
|
movie = existing[0]
|
||||||
|
requests.put(f"{base_url}/api/v3/movie/{movie['id']}", json={**movie, "monitored": True}, headers=h, timeout=15)
|
||||||
|
requests.post(f"{base_url}/api/v3/command", json={"name": "MoviesSearch", "movieIds": [movie["id"]]}, headers=h, timeout=15)
|
||||||
|
return {"radarr_id": movie["id"], "title": movie.get("title"), "year": movie.get("year")}
|
||||||
|
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:
|
def test_connection(base_url: str, api_key: str) -> bool:
|
||||||
base_url = base_url.rstrip("/")
|
base_url = base_url.rstrip("/")
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ numpy>=1.26.0
|
|||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
jq>=1.6.0
|
jq>=1.6.0
|
||||||
typer>=0.9.0
|
typer>=0.9.0
|
||||||
emergentintegrations==0.1.0
|
docker>=7.1.0
|
||||||
|
mutagen>=1.47.0
|
||||||
|
|||||||
+945
-74
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
# The codec-check and transcode workers are asyncio background tasks inside the backend
|
||||||
|
# process itself, not separate containers — there's no way to bounce just one of them, so
|
||||||
|
# "restart" for either really means restarting the whole streamhoard-backend container. That
|
||||||
|
# briefly takes the API down too, not just the stalled queue; the caller (server.py) surfaces
|
||||||
|
# both worker "services" as needing this same restart target.
|
||||||
|
BACKEND_CONTAINER = "streamhoard-backend"
|
||||||
|
|
||||||
|
|
||||||
|
def restart_backend() -> bool:
|
||||||
|
client = _get_client()
|
||||||
|
c = client.containers.get(BACKEND_CONTAINER)
|
||||||
|
c.restart(timeout=15)
|
||||||
|
return True
|
||||||
@@ -14,6 +14,73 @@ def _poster(images):
|
|||||||
return ""
|
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.
|
||||||
|
If the series is already in Sonarr's library (common for older/library titles carried over
|
||||||
|
from a past migration), Sonarr rejects the add with a 400 SeriesExistsValidator — in that
|
||||||
|
case, reuse the existing series and just kick off a missing-episode search instead of
|
||||||
|
surfacing the add as a failure."""
|
||||||
|
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)
|
||||||
|
if r.status_code == 400 and any(e.get("errorCode") == "SeriesExistsValidator" for e in (r.json() or [])):
|
||||||
|
existing = requests.get(f"{base_url}/api/v3/series", params={"tvdbId": tvdb_id}, headers=h, timeout=15).json()
|
||||||
|
if not existing:
|
||||||
|
raise RuntimeError("Sonarr reported series already exists but it could not be found")
|
||||||
|
series = existing[0]
|
||||||
|
requests.post(f"{base_url}/api/v3/series/editor", json={
|
||||||
|
"seriesIds": [series["id"]], "monitored": True,
|
||||||
|
}, headers=h, timeout=15)
|
||||||
|
requests.post(f"{base_url}/api/v3/command", json={"name": "SeriesSearch", "seriesId": series["id"]}, headers=h, timeout=15)
|
||||||
|
return {"sonarr_id": series["id"], "title": series.get("title"), "year": series.get("year")}
|
||||||
|
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:
|
def test_connection(base_url: str, api_key: str) -> bool:
|
||||||
try:
|
try:
|
||||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
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.
|
Tests auth, movies, watchlist, progress, requests, and stream auth.
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
@@ -8,8 +8,8 @@ import uuid
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
|
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "http://localhost:8001").rstrip("/")
|
||||||
ADMIN_EMAIL = "admin@kino.local"
|
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||||
ADMIN_PASSWORD = "kino-admin-2026"
|
ADMIN_PASSWORD = "kino-admin-2026"
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ def admin_token(api):
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def member_token(api):
|
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"})
|
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}"
|
assert r.status_code == 200, f"register failed: {r.text}"
|
||||||
data = r.json()
|
data = r.json()
|
||||||
@@ -73,7 +73,7 @@ class TestAuth:
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
def test_register_and_me(self, api):
|
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"})
|
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
d = r.json()
|
d = r.json()
|
||||||
@@ -86,7 +86,7 @@ class TestAuth:
|
|||||||
assert r2.json()["email"] == email.lower()
|
assert r2.json()["email"] == email.lower()
|
||||||
|
|
||||||
def test_register_duplicate(self, api):
|
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"})
|
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"})
|
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "").rstrip("/")
|
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"
|
ADMIN_PASSWORD = "kino-admin-2026"
|
||||||
|
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ def admin_headers(admin_token):
|
|||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def member(api):
|
def member(api):
|
||||||
"""Create a fresh member for profile-scoped tests."""
|
"""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",
|
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||||
json={"email": email, "password": "pass1234", "name": "P2 User"})
|
json={"email": email, "password": "pass1234", "name": "P2 User"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -101,7 +101,7 @@ class TestProfiles:
|
|||||||
|
|
||||||
def test_cannot_delete_last_profile(self, api):
|
def test_cannot_delete_last_profile(self, api):
|
||||||
# fresh user with single profile
|
# 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",
|
d = api.post(f"{BASE_URL}/api/auth/register",
|
||||||
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
|
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
|
||||||
h = {"Authorization": f"Bearer {d['access_token']}"}
|
h = {"Authorization": f"Bearer {d['access_token']}"}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import pytest
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
BASE_URL = os.environ["REACT_APP_BACKEND_URL"].rstrip("/")
|
BASE_URL = os.environ["REACT_APP_BACKEND_URL"].rstrip("/")
|
||||||
ADMIN_EMAIL = "admin@kino.local"
|
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||||
ADMIN_PASSWORD = "kino-admin-2026"
|
ADMIN_PASSWORD = "kino-admin-2026"
|
||||||
QTEST_VIDEO = "/tmp/qtest.mp4"
|
QTEST_VIDEO = "/tmp/qtest.mp4"
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ def admin_token(api):
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def member_token(api):
|
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",
|
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||||
json={"email": email, "password": "pass1234", "name": "QTest"},
|
json={"email": email, "password": "pass1234", "name": "QTest"},
|
||||||
headers={"Content-Type": "application/json"})
|
headers={"Content-Type": "application/json"})
|
||||||
|
|||||||
+152
-11
@@ -13,6 +13,11 @@ from typing import Optional, List, Tuple, Callable, Awaitable
|
|||||||
|
|
||||||
logger = logging.getLogger("kino.transcode")
|
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]:
|
async def probe_video(source: Path) -> Tuple[int, int]:
|
||||||
"""Return (width, height) using ffprobe; (0, 0) on failure."""
|
"""Return (width, height) using ffprobe; (0, 0) on failure."""
|
||||||
@@ -32,6 +37,70 @@ async def probe_video(source: Path) -> Tuple[int, int]:
|
|||||||
return (0, 0)
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def probe_codecs(source: Path) -> Tuple[str, str]:
|
||||||
|
"""Return (video_codec, audio_codec), lowercased, via ffprobe; ('', '') on failure."""
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ffprobe", "-v", "error", "-show_entries", "stream=codec_type,codec_name",
|
||||||
|
"-of", "json", str(source),
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
stdout, _ = await proc.communicate()
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return ("", "")
|
||||||
|
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
|
||||||
|
video_codec = audio_codec = ""
|
||||||
|
for s in data.get("streams") or []:
|
||||||
|
if s.get("codec_type") == "video" and not video_codec:
|
||||||
|
video_codec = (s.get("codec_name") or "").lower()
|
||||||
|
elif s.get("codec_type") == "audio" and not audio_codec:
|
||||||
|
audio_codec = (s.get("codec_name") or "").lower()
|
||||||
|
return (video_codec, audio_codec)
|
||||||
|
except Exception:
|
||||||
|
return ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
BROWSER_NATIVE_AUDIO = {"aac", "mp3"}
|
||||||
|
|
||||||
|
|
||||||
|
async def classify_source(source: Path) -> str:
|
||||||
|
"""Decide the cheapest correct transcode path for a source file:
|
||||||
|
- 'native': H.264 video + AAC/MP3 audio — browsers already play this file as-is (the
|
||||||
|
raw-file stream endpoint serves it directly), so no transcode is needed at all.
|
||||||
|
- 'audio': H.264 video but non-browser audio (AC3/DTS/EAC3/etc) — cheap audio-only
|
||||||
|
re-encode, video stream-copied untouched.
|
||||||
|
- 'abr': anything else (HEVC/VP9/etc, or codec probing failed) — needs a real video
|
||||||
|
re-encode. Also the safe fallback on probe failure: it's the only mode that doesn't
|
||||||
|
assume the source is already H.264 — 'audio'/'quick' apply the h264_mp4toannexb
|
||||||
|
bitstream filter unconditionally, which makes ffmpeg hard-fail on non-H.264 input.
|
||||||
|
"""
|
||||||
|
if not source.is_file():
|
||||||
|
return "abr"
|
||||||
|
video_codec, audio_codec = await probe_codecs(source)
|
||||||
|
if not video_codec or video_codec != "h264":
|
||||||
|
return "abr"
|
||||||
|
if audio_codec in BROWSER_NATIVE_AUDIO:
|
||||||
|
return "native"
|
||||||
|
return "audio"
|
||||||
|
|
||||||
|
|
||||||
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
|
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
|
||||||
ALL_VARIANTS = [
|
ALL_VARIANTS = [
|
||||||
(1080, "5000k", "5500k", "7500k", "192k"),
|
(1080, "5000k", "5500k", "7500k", "192k"),
|
||||||
@@ -42,13 +111,38 @@ ALL_VARIANTS = [
|
|||||||
|
|
||||||
|
|
||||||
def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]:
|
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:
|
if src_height <= 0:
|
||||||
return [ALL_VARIANTS[2]] # safe default 480p
|
return [ALL_VARIANTS[2]] # safe default 480p
|
||||||
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
|
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
chosen = [ALL_VARIANTS[-1]]
|
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:
|
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||||
@@ -58,7 +152,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
|||||||
return
|
return
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
cmd = [
|
cmd = [
|
||||||
"nice", "-n", "19",
|
"nice", "-n", str(TRANSCODE_NICE),
|
||||||
"ffmpeg", "-y", "-i", str(source),
|
"ffmpeg", "-y", "-i", str(source),
|
||||||
"-c:v", "copy", "-c:a", "copy",
|
"-c:v", "copy", "-c:a", "copy",
|
||||||
"-bsf:v", "h264_mp4toannexb",
|
"-bsf:v", "h264_mp4toannexb",
|
||||||
@@ -67,7 +161,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
|||||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||||
str(out_dir / "playlist.m3u8"),
|
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:
|
async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||||
@@ -88,13 +182,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]")
|
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
|
||||||
filter_complex = ";".join(fc_parts)
|
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):
|
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
|
||||||
cmd += [
|
cmd += [
|
||||||
"-map", f"[v{i}out]",
|
"-map", f"[v{i}out]",
|
||||||
f"-c:v:{i}", "libx264",
|
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"-profile:v:{i}", "main",
|
||||||
f"-pix_fmt:v:{i}", "yuv420p",
|
f"-pix_fmt:v:{i}", "yuv420p",
|
||||||
f"-b:v:{i}", vb,
|
f"-b:v:{i}", vb,
|
||||||
@@ -125,23 +225,64 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
|
|||||||
for i in range(n):
|
for i in range(n):
|
||||||
(out_dir / f"v{i}").mkdir(exist_ok=True)
|
(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:
|
def _parse_out_time_seconds(line: str) -> Optional[float]:
|
||||||
await on_status("running")
|
"""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:
|
try:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
*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:
|
if proc.returncode != 0:
|
||||||
err = stderr.decode("utf-8", errors="ignore")[-500:]
|
err = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||||
logger.error(f"ffmpeg failed: {err}")
|
logger.error(f"ffmpeg failed: {err}")
|
||||||
await on_status("failed", error=err[:200])
|
await on_status("failed", error=err[:200])
|
||||||
shutil.rmtree(out_dir, ignore_errors=True)
|
shutil.rmtree(out_dir, ignore_errors=True)
|
||||||
return
|
return
|
||||||
await on_status("done", entry=entry_filename)
|
await on_status("done", entry=entry_filename, progress=100.0)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
await on_status("failed", error="ffmpeg not installed")
|
await on_status("failed", error="ffmpeg not installed")
|
||||||
except Exception as e:
|
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:
|
# Runs alongside existing LAMP stacks without conflicts:
|
||||||
# - Apache keeps port 80/443
|
# - Apache keeps port 80/443
|
||||||
# - MySQL is untouched (Kino uses its own MongoDB in a container)
|
# - MySQL is untouched (StreamHoard uses its own MongoDB in a container)
|
||||||
# - Kino exposes a single configurable port (default 8080)
|
# - StreamHoard exposes a single configurable port (default 8080)
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# cp .env.compose.example .env
|
# cp .env.compose.example .env
|
||||||
@@ -13,20 +13,20 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
mongo:
|
mongo:
|
||||||
image: mongo:7
|
image: mongo:4.4
|
||||||
container_name: kino-mongo
|
container_name: streamhoard-mongo
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- mongo-data:/data/db
|
- mongo-data:/data/db
|
||||||
networks:
|
networks:
|
||||||
- kino
|
- streamhoard
|
||||||
# No ports exposed to host — only reachable inside kino network
|
# No ports exposed to host — only reachable inside streamhoard network
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: kino-backend
|
container_name: streamhoard-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
@@ -37,35 +37,142 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||||
JWT_ALG: HS256
|
JWT_ALG: HS256
|
||||||
JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-720}
|
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_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||||
MEDIA_ROOT: /media
|
MEDIA_ROOT: /media
|
||||||
volumes:
|
volumes:
|
||||||
# Host media directory (movies, HLS, subtitles) — point at your NAS/ZFS mount
|
# Host media directory (movies, HLS, subtitles) — point at your NAS/ZFS mount
|
||||||
- ${MEDIA_ROOT_HOST:-./media}:/media
|
- ${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:
|
networks:
|
||||||
- kino
|
- streamhoard
|
||||||
# No direct host port — frontend container reverse-proxies /api → backend:8001
|
# No direct host port — frontend container reverse-proxies /api → backend:8001
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: kino-frontend
|
container_name: streamhoard-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
ports:
|
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"
|
- "${KINO_HTTP_PORT:-8080}:80"
|
||||||
networks:
|
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:
|
networks:
|
||||||
kino:
|
streamhoard:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
portainer-data:
|
||||||
|
driver: local
|
||||||
|
|||||||
@@ -2,10 +2,6 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
require("dotenv").config();
|
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
|
// Environment variable overrides
|
||||||
const config = {
|
const config = {
|
||||||
enableHealthCheck: process.env.ENABLE_HEALTH_CHECK === "true",
|
enableHealthCheck: process.env.ENABLE_HEALTH_CHECK === "true",
|
||||||
@@ -81,20 +77,4 @@ webpackConfig.devServer = (devServerConfig) => {
|
|||||||
return 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;
|
module.exports = webpackConfig;
|
||||||
|
|||||||
@@ -76,7 +76,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||||
"@craco/craco": "^7.1.0",
|
"@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",
|
"@eslint/js": "9.23.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "9.23.0",
|
"eslint": "9.23.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet" />
|
<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.
|
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`.
|
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>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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<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 begin the development, run `npm start` or `yarn start`.
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+23
-9
@@ -2,9 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
|
|||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { AuthProvider, useAuth } from "./lib/auth";
|
import { AuthProvider, useAuth } from "./lib/auth";
|
||||||
import { ProfileProvider, useProfile } from "./lib/profile";
|
import { ProfileProvider, useProfile } from "./lib/profile";
|
||||||
import ProtectedRoute from "./components/ProtectedRoute";
|
import { MusicPlayerProvider } from "./lib/musicPlayer";
|
||||||
import Navbar from "./components/Navbar";
|
import Navbar from "./components/Navbar";
|
||||||
import GrainOverlay from "./components/GrainOverlay";
|
import GrainOverlay from "./components/GrainOverlay";
|
||||||
|
import ServiceStatus from "./components/ServiceStatus";
|
||||||
|
import MusicPlayerBar from "./components/MusicPlayerBar";
|
||||||
import Login from "./pages/Login";
|
import Login from "./pages/Login";
|
||||||
import Register from "./pages/Register";
|
import Register from "./pages/Register";
|
||||||
import Browse from "./pages/Browse";
|
import Browse from "./pages/Browse";
|
||||||
@@ -16,23 +18,27 @@ import Admin from "./pages/Admin";
|
|||||||
import AdminUpload from "./pages/AdminUpload";
|
import AdminUpload from "./pages/AdminUpload";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import RadarrImport from "./pages/RadarrImport";
|
import RadarrImport from "./pages/RadarrImport";
|
||||||
import ProfileSelect from "./pages/ProfileSelect";
|
|
||||||
import TranscodeQueue from "./pages/TranscodeQueue";
|
import TranscodeQueue from "./pages/TranscodeQueue";
|
||||||
|
import CodecCheckQueue from "./pages/CodecCheckQueue";
|
||||||
import Shows from "./pages/Shows";
|
import Shows from "./pages/Shows";
|
||||||
import ShowDetail from "./pages/ShowDetail";
|
import ShowDetail from "./pages/ShowDetail";
|
||||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||||
import SonarrImport from "./pages/SonarrImport";
|
import SonarrImport from "./pages/SonarrImport";
|
||||||
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
|
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 ProfileGate = ({ children }) => {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const { active, loading: profLoading } = useProfile();
|
const { active, loading: profLoading } = useProfile();
|
||||||
const loc = useLocation();
|
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>;
|
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 (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||||
if (!active) return <Navigate to="/profile" replace />;
|
|
||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,11 +54,13 @@ const Shell = ({ children }) => {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { active } = useProfile();
|
const { active } = useProfile();
|
||||||
const loc = useLocation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{!hideChrome && user && active && <Navbar />}
|
{!hideChrome && user && active && <Navbar />}
|
||||||
|
{!hideChrome && user?.is_admin && active && <ServiceStatus />}
|
||||||
{children}
|
{children}
|
||||||
|
{!hideChrome && user && active && <MusicPlayerBar />}
|
||||||
<GrainOverlay />
|
<GrainOverlay />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -60,10 +68,9 @@ const Shell = ({ children }) => {
|
|||||||
|
|
||||||
const RootRedirect = () => {
|
const RootRedirect = () => {
|
||||||
const { user, loading } = useAuth();
|
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 (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 (!user) return <Navigate to="/login" replace />;
|
||||||
if (!active) return <Navigate to="/profile" replace />;
|
|
||||||
return <Navigate to="/browse" replace />;
|
return <Navigate to="/browse" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,12 +80,12 @@ function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
|
<MusicPlayerProvider>
|
||||||
<Shell>
|
<Shell>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
|
||||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||||
@@ -87,19 +94,26 @@ function App() {
|
|||||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||||
<Route path="/requests" element={<ProfileGate><Requests /></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" element={<AdminGate><Admin /></AdminGate>} />
|
||||||
|
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
|
||||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></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/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||||
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></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/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||||
|
<Route path="/admin/codec-check" element={<AdminGate><CodecCheckQueue /></AdminGate>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Shell>
|
</Shell>
|
||||||
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
||||||
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
||||||
}} />
|
}} />
|
||||||
|
</MusicPlayerProvider>
|
||||||
</ProfileProvider>
|
</ProfileProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</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 { Play } from "lucide-react";
|
||||||
|
import AlbumArt from "./AlbumArt";
|
||||||
|
|
||||||
export const MovieCard = ({ movie, onClick, progress }) => {
|
export const MovieCard = ({ movie, onClick, progress }) => {
|
||||||
const pct = progress?.duration_seconds
|
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]"
|
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}`}
|
data-testid={`movie-card-${movie.id}`}
|
||||||
>
|
>
|
||||||
<img
|
<AlbumArt url={movie.poster_url || movie.backdrop_url} loading="lazy" />
|
||||||
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; }}
|
|
||||||
/>
|
|
||||||
<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-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">
|
<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">
|
<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 api from "../lib/api";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import AlbumArt from "./AlbumArt";
|
||||||
|
|
||||||
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
|
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -64,11 +65,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="relative h-[280px] md:h-[440px]">
|
<div className="relative h-[280px] md:h-[440px]">
|
||||||
<img
|
<AlbumArt url={movie.backdrop_url || movie.poster_url} className="absolute inset-0" />
|
||||||
src={movie.backdrop_url || movie.poster_url}
|
|
||||||
alt={movie.title}
|
|
||||||
className="absolute inset-0 w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
|
<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">
|
<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">
|
<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 { useAuth } from "../lib/auth";
|
||||||
import { useProfile } from "../lib/profile";
|
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 { useState, useEffect } from "react";
|
||||||
|
import api from "../lib/api";
|
||||||
|
|
||||||
export const Navbar = () => {
|
export const Navbar = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { active, clearActive } = useProfile();
|
const { active } = useProfile();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
const [version, setVersion] = useState("");
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||||
@@ -16,27 +20,34 @@ export const Navbar = () => {
|
|||||||
return () => window.removeEventListener("scroll", onScroll);
|
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 }) =>
|
const linkClass = ({ isActive }) =>
|
||||||
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
|
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
|
||||||
|
|
||||||
const switchProfile = () => {
|
const mobileLinkClass = ({ isActive }) =>
|
||||||
clearActive();
|
`block py-3 text-base tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A]"}`;
|
||||||
nav("/profile");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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="px-6 md:px-12 py-4 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-10">
|
<div className="flex items-center gap-10">
|
||||||
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
|
<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>
|
<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>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<nav className="hidden md:flex items-center gap-7">
|
<nav className="hidden md:flex items-center gap-7">
|
||||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
|
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
|
||||||
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</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="/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>
|
<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>}
|
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -49,39 +60,60 @@ export const Navbar = () => {
|
|||||||
<Search size={18} strokeWidth={1.5} />
|
<Search size={18} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
{user.is_admin && (
|
{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">
|
<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
|
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||||
</button>
|
</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">
|
)}
|
||||||
|
<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
|
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||||
</button>
|
</button>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
<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 && (
|
{active && (
|
||||||
<>
|
<div className="flex items-center gap-2" data-testid="account-avatar">
|
||||||
<div className="hidden sm:flex flex-col items-end leading-tight">
|
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
|
||||||
<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" }}>
|
<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"}
|
{active.name?.[0]?.toUpperCase() || "U"}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
<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">
|
||||||
</button>
|
|
||||||
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
|
||||||
<LogOut size={16} strokeWidth={1.5} />
|
<LogOut size={16} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</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>
|
<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>
|
</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>
|
</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) => {
|
instance.interceptors.request.use((config) => {
|
||||||
const token = localStorage.getItem("kino_token");
|
const token = localStorage.getItem("kino_token");
|
||||||
if (token) config.headers.Authorization = `Bearer ${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;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -25,7 +19,7 @@ export const getStreamUrl = (movie) => {
|
|||||||
if (movie.hls_status === "done" && movie.hls_path) {
|
if (movie.hls_status === "done" && movie.hls_path) {
|
||||||
return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`;
|
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 `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
return movie.video_url;
|
return movie.video_url;
|
||||||
@@ -37,3 +31,9 @@ export const getSubtitleUrl = (sub) => {
|
|||||||
const token = localStorage.getItem("kino_token") || "";
|
const token = localStorage.getItem("kino_token") || "";
|
||||||
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(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 }) => {
|
export const ProfileProvider = ({ children }) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [profiles, setProfiles] = useState([]);
|
|
||||||
const [active, setActive] = useState(null);
|
const [active, setActive] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!user) { setProfiles([]); setActive(null); return; }
|
if (!user) { setActive(null); return; }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get("/profiles");
|
const { data } = await api.get("/profiles");
|
||||||
setProfiles(data);
|
setActive(data[0] || null);
|
||||||
const stored = localStorage.getItem(`kino_profile_${user.id}`);
|
|
||||||
const found = data.find((p) => p.id === stored);
|
|
||||||
setActive(found || null);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => {
|
|||||||
|
|
||||||
useEffect(() => { refresh(); }, [refresh]);
|
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 (
|
return (
|
||||||
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
|
<ProfileCtx.Provider value={{ active, loading, refresh }}>
|
||||||
{children}
|
{children}
|
||||||
</ProfileCtx.Provider>
|
</ProfileCtx.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,67 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
|
import {
|
||||||
|
Trash2, Star, Film, Settings as SettingsIcon, Download, FolderSearch, Wrench, ScanSearch,
|
||||||
|
Tv, ClipboardList, ChevronDown, Users as UsersIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { useNavigate, Link } from "react-router-dom";
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
|
||||||
|
// Ordered to match the actual content pipeline: a request comes in, gets pulled into
|
||||||
|
// Radarr/Sonarr (or uploaded/scanned in directly), gets codec-checked, then transcoded if
|
||||||
|
// needed — after that it's just in the library. Everything else is a standing utility, not a
|
||||||
|
// step content passes through, so it lives in its own group.
|
||||||
|
const PIPELINE_LINKS = [
|
||||||
|
{ to: "/requests", label: "Review Requests", icon: ClipboardList, testId: "admin-requests-link" },
|
||||||
|
{ to: "/admin/radarr", label: "Radarr Import", icon: Download, testId: "admin-radarr-link" },
|
||||||
|
{ to: "/admin/sonarr", label: "Sonarr Import", icon: Tv, testId: "admin-sonarr-link" },
|
||||||
|
{ to: "/admin/upload", label: "Upload Movie", icon: Film, testId: "admin-upload-link" },
|
||||||
|
{ to: "/admin/scan", label: "Library Scan", icon: FolderSearch, testId: "admin-scan-link" },
|
||||||
|
{ to: "/admin/codec-check", label: "Codec Check Queue", icon: ScanSearch, testId: "admin-codec-check-link" },
|
||||||
|
{ to: "/admin/queue", label: "Transcode Queue", icon: SettingsIcon, testId: "admin-queue-link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UTILITY_LINKS = [
|
||||||
|
{ to: "/admin/cleanup", label: "Library Cleanup", icon: Wrench, testId: "admin-cleanup-link" },
|
||||||
|
{ to: "/admin/users", label: "Users", icon: UsersIcon, testId: "admin-users-link" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AdminMenu({ label, links }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
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-menu-${label.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{label} <ChevronDown size={14} strokeWidth={1.5} className={`transition-transform ${open ? "rotate-180" : ""}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 min-w-[220px] bg-[#0F0F0F] border border-[#222] z-20 shadow-lg" data-testid={`admin-menu-${label.toLowerCase()}-panel`}>
|
||||||
|
{links.map(({ to, label: linkLabel, icon: Icon, testId }) => (
|
||||||
|
<Link
|
||||||
|
key={to} to={to} onClick={() => setOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white hover:bg-white/5 border-b border-[#222] last:border-b-0"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<Icon size={14} strokeWidth={1.5} /> {linkLabel}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [movies, setMovies] = useState([]);
|
const [movies, setMovies] = useState([]);
|
||||||
@@ -79,21 +137,8 @@ export default function Admin() {
|
|||||||
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
|
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
|
||||||
|
|
||||||
<div className="mt-10 flex flex-wrap gap-3">
|
<div className="mt-10 flex flex-wrap gap-3">
|
||||||
<Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link">
|
<AdminMenu label="Pipeline" links={PIPELINE_LINKS} />
|
||||||
<Film size={14} strokeWidth={1.5} /> Upload Movie
|
<AdminMenu label="Utilities" links={UTILITY_LINKS} />
|
||||||
</Link>
|
|
||||||
<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/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>
|
|
||||||
<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
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-12 border border-[#222]">
|
<div className="mt-12 border border-[#222]">
|
||||||
@@ -116,12 +161,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 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
|
||||||
<span className="col-span-2">{hlsStatusBadge(m)}</span>
|
<span className="col-span-2">{hlsStatusBadge(m)}</span>
|
||||||
<div className="col-span-2 flex justify-end gap-2">
|
<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">
|
<div className="flex gap-1">
|
||||||
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
|
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
|
||||||
title="Quick transcode (stream-copy, single bitrate)"
|
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"
|
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>
|
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]}
|
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
|
||||||
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
|
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"
|
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 });
|
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
|
||||||
msg = `${data.modified} unhidden`;
|
msg = `${data.modified} unhidden`;
|
||||||
} else if (action === "delete") {
|
} 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 });
|
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
|
||||||
msg = `${data.deleted} deleted`;
|
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,173 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
pending: "text-[#fcd34d]",
|
||||||
|
running: "text-[#fcd34d]",
|
||||||
|
done: "text-[#86efac]",
|
||||||
|
failed: "text-[#fca5a5]",
|
||||||
|
cancelled: "text-[#8A8A8A]",
|
||||||
|
superseded: "text-[#8A8A8A]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASSIFICATION_LABEL = {
|
||||||
|
native: "Native (no transcode)",
|
||||||
|
audio: "Audio fix needed",
|
||||||
|
abr: "Full re-encode needed",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CodecCheckQueue() {
|
||||||
|
const [jobs, setJobs] = useState([]);
|
||||||
|
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false });
|
||||||
|
const [runningJob, setRunningJob] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [j, s] = await Promise.all([api.get("/codec-check/queue"), api.get("/codec-check/queue/stats")]);
|
||||||
|
setJobs(j.data);
|
||||||
|
setStats(s.data);
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
const loadRunning = async () => {
|
||||||
|
try { const { data } = await api.get("/codec-check/queue/running"); setRunningJob(data); }
|
||||||
|
catch { /* ignore transient poll failures */ }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); loadRunning(); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = jobs.some((j) => j.status === "pending");
|
||||||
|
if (!runningJob && !pending) return;
|
||||||
|
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [jobs, runningJob]);
|
||||||
|
|
||||||
|
const listedJobs = jobs.filter((j) => j.status !== "running");
|
||||||
|
|
||||||
|
const cancel = async (id) => {
|
||||||
|
try { await api.delete(`/codec-check/queue/${id}`); toast.success("Job cancelled"); load(); }
|
||||||
|
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = async (id) => {
|
||||||
|
try { await api.post(`/codec-check/queue/${id}/retry`); toast.success("Re-queued"); load(); }
|
||||||
|
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryAllFailed = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post("/codec-check/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("/codec-check/queue/pause", { paused: !stats.paused });
|
||||||
|
toast.success(data.paused ? "Queue paused" : "Queue resumed");
|
||||||
|
load();
|
||||||
|
} catch { toast.error("Could not toggle pause"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFinished = async () => {
|
||||||
|
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
|
||||||
|
try { const { data } = await api.post("/codec-check/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
|
||||||
|
catch { toast.error("Could not clear"); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="codec-check-queue-page">
|
||||||
|
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||||
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 1 of 2</span>
|
||||||
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Codec Check Queue</h1>
|
||||||
|
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||||
|
Every newly imported movie/episode lands here first. A quick ffprobe decides whether it actually needs
|
||||||
|
transcoding at all: already-browser-native files (H.264 + AAC/MP3) skip straight to the library with
|
||||||
|
no transcode job ever created; everything else is handed off to the{" "}
|
||||||
|
<a href="/admin/queue" className="text-[#D9381E] hover:text-[#ED4B32]">Transcode Queue (step 2)</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
|
{["pending", "running", "done", "failed", "cancelled"].map((k) => (
|
||||||
|
<div key={k} className="border border-[#222] p-4" data-testid={`codec-stat-${k}`}>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{k}</div>
|
||||||
|
<div className={`mt-1 font-display text-3xl font-bold ${STATUS_COLORS[k]}`}>{stats[k] || 0}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="codec-queue-refresh">
|
||||||
|
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
|
||||||
|
</button>
|
||||||
|
<button onClick={togglePause} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="codec-queue-pause-toggle">
|
||||||
|
{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="codec-queue-retry-all">
|
||||||
|
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
|
||||||
|
</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="codec-queue-clear">
|
||||||
|
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{runningJob && (
|
||||||
|
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="codec-now-processing">
|
||||||
|
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
|
||||||
|
Checking Codec
|
||||||
|
</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.triggered_by}</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-4">Title</span>
|
||||||
|
<span className="col-span-3">Result</span>
|
||||||
|
<span className="col-span-1">Trigger</span>
|
||||||
|
<span className="col-span-2">Status</span>
|
||||||
|
<span className="col-span-1">Created</span>
|
||||||
|
<span className="col-span-1 text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
{listedJobs.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="codec-queue-empty">No jobs yet.</div>
|
||||||
|
) : 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={`codec-queue-row-${j.id}`}>
|
||||||
|
<div className="col-span-4 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
|
||||||
|
<span className="col-span-3 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||||
|
{j.classification ? CLASSIFICATION_LABEL[j.classification] || j.classification : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.triggered_by}</span>
|
||||||
|
<span className={`col-span-2 text-[10px] uppercase tracking-[0.3em] ${STATUS_COLORS[j.status]}`} data-testid={`codec-queue-status-${j.id}`}>
|
||||||
|
{j.status}
|
||||||
|
{j.error && j.status === "failed" && <span className="block text-[#fca5a5]/70 normal-case truncate" title={j.error}>{j.error.slice(0, 40)}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="col-span-1 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleDateString()}</span>
|
||||||
|
<div className="col-span-1 flex justify-end gap-2">
|
||||||
|
{j.status === "pending" && (
|
||||||
|
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`codec-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="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`codec-retry-${j.id}`}>
|
||||||
|
<RotateCcw size={13} strokeWidth={1.5} /> Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</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">
|
<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">
|
<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">
|
<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>
|
<span className="text-[#D9381E] text-3xl">.</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
|
<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">
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await register(email, password, name);
|
await register(email, password, name);
|
||||||
toast.success("Welcome to Kino");
|
toast.success("Welcome to StreamHoard");
|
||||||
nav("/browse", { replace: true });
|
nav("/browse", { replace: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.response?.data?.detail || "Could not register");
|
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">
|
<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">
|
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
|
||||||
<Link to="/" className="block mb-12">
|
<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>
|
<span className="text-[#D9381E] text-3xl">.</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
|
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
|
||||||
|
|||||||
@@ -3,6 +3,117 @@ import api from "../lib/api";
|
|||||||
import { useAuth } from "../lib/auth";
|
import { useAuth } from "../lib/auth";
|
||||||
import { toast } from "sonner";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CandidatePicker({ request, contentType, onClose, onPicked }) {
|
||||||
|
const [candidates, setCandidates] = useState(null);
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/requests/${request.id}/candidates`, { params: { content_type: contentType } });
|
||||||
|
if (!cancelled) setCandidates(data);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error(err.response?.data?.detail || "Could not search Radarr/Sonarr");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [request.id, contentType]);
|
||||||
|
|
||||||
|
const pick = async (c) => {
|
||||||
|
setPicking(true);
|
||||||
|
try {
|
||||||
|
await onPicked(c);
|
||||||
|
} finally {
|
||||||
|
setPicking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-6" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-[#0F0F0F] border border-[#222] max-w-2xl w-full max-h-[80vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
data-testid="candidate-picker"
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 border-b border-[#222] flex items-center justify-between">
|
||||||
|
<h3 className="text-white font-display font-bold">
|
||||||
|
Pick the right match for "{request.title}"{request.year ? ` (${request.year})` : ""}
|
||||||
|
</h3>
|
||||||
|
<button onClick={onClose} className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em]">Close</button>
|
||||||
|
</div>
|
||||||
|
{candidates === null ? (
|
||||||
|
<p className="text-[#8A8A8A] text-sm px-5 py-6">Searching…</p>
|
||||||
|
) : candidates.length === 0 ? (
|
||||||
|
<p className="text-[#8A8A8A] text-sm px-5 py-6">No results found.</p>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.tmdb_id || c.tvdb_id}
|
||||||
|
onClick={() => pick(c)}
|
||||||
|
disabled={picking}
|
||||||
|
className="w-full flex items-start gap-4 px-5 py-4 border-b border-[#222] last:border-b-0 text-left hover:bg-[#1A1A1A] disabled:opacity-50"
|
||||||
|
data-testid={`candidate-${c.tmdb_id || c.tvdb_id}`}
|
||||||
|
>
|
||||||
|
{c.poster_url ? (
|
||||||
|
<img src={c.poster_url} alt="" className="w-12 h-18 object-cover shrink-0 bg-[#1A1A1A]" />
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-18 shrink-0 bg-[#1A1A1A]" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-white">{c.title} {c.year ? <span className="text-[#8A8A8A]">({c.year})</span> : null}</p>
|
||||||
|
{c.overview && <p className="text-xs text-[#8A8A8A] mt-1 line-clamp-2">{c.overview}</p>}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Requests() {
|
export default function Requests() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [mine, setMine] = useState([]);
|
const [mine, setMine] = useState([]);
|
||||||
@@ -10,6 +121,8 @@ export default function Requests() {
|
|||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [year, setYear] = useState("");
|
const [year, setYear] = useState("");
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
|
const [approving, setApproving] = useState({});
|
||||||
|
const [picker, setPicker] = useState(null); // { request, contentType }
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const { data } = await api.get("/requests/mine");
|
const { data } = await api.get("/requests/mine");
|
||||||
@@ -21,6 +134,22 @@ export default function Requests() {
|
|||||||
};
|
};
|
||||||
useEffect(() => { load(); }, [user?.is_admin]);
|
useEffect(() => { load(); }, [user?.is_admin]);
|
||||||
|
|
||||||
|
const approveWithCandidate = async (request, contentType, candidate) => {
|
||||||
|
const id = request.id;
|
||||||
|
setApproving((p) => ({ ...p, [id]: true }));
|
||||||
|
try {
|
||||||
|
const idField = contentType === "movie" ? { tmdb_id: candidate.tmdb_id } : { tvdb_id: candidate.tvdb_id };
|
||||||
|
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType, ...idField });
|
||||||
|
toast.success(`Searching for "${data.matched_title || data.title}"`);
|
||||||
|
setPicker(null);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.detail || "Could not approve request");
|
||||||
|
} finally {
|
||||||
|
setApproving((p) => ({ ...p, [id]: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
@@ -107,7 +236,8 @@ export default function Requests() {
|
|||||||
</div>
|
</div>
|
||||||
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
||||||
r.status === "fulfilled" ? "text-[#86efac]" :
|
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}
|
{r.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -125,13 +255,26 @@ export default function Requests() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border border-[#222]">
|
<div className="border border-[#222]">
|
||||||
{all.map((r) => (
|
{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 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>
|
<div className="min-w-0">
|
||||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
<p className="text-white truncate">
|
||||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
|
{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.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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 shrink-0">
|
||||||
|
{r.status === "pending" && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setPicker({ request: r, contentType: "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={() => setPicker({ request: r, contentType: "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")}
|
<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"
|
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>
|
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
||||||
@@ -146,6 +289,14 @@ export default function Requests() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{picker && (
|
||||||
|
<CandidatePicker
|
||||||
|
request={picker.request}
|
||||||
|
contentType={picker.contentType}
|
||||||
|
onClose={() => setPicker(null)}
|
||||||
|
onPicked={(c) => approveWithCandidate(picker.request, picker.contentType, c)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState, createContext, useContext } from "react";
|
import { useEffect, useState, createContext, useContext } from "react";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
|
import { useAuth } from "../lib/auth";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
|
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() {
|
export default function Settings() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const isAdmin = !!user?.is_admin;
|
||||||
const [s, setS] = useState({
|
const [s, setS] = useState({
|
||||||
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||||
sonarr_url: "", sonarr_api_key: "",
|
sonarr_url: "", sonarr_api_key: "",
|
||||||
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
||||||
auto_transcode: "off", queue_paused: false,
|
auto_transcode: "off", queue_paused: false,
|
||||||
|
music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "",
|
||||||
});
|
});
|
||||||
const [info, setInfo] = useState({});
|
const [info, setInfo] = useState({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
||||||
const [traktModal, setTraktModal] = useState(null);
|
const [traktModal, setTraktModal] = useState(null);
|
||||||
const [show, setShow] = useState({});
|
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 () => {
|
const load = async () => {
|
||||||
|
if (!isAdmin) return;
|
||||||
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
||||||
setS({
|
setS({
|
||||||
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
|
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 || "",
|
opensubs_api_key: data.opensubs_api_key || "",
|
||||||
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
|
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,
|
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);
|
setInfo(data); setTraktStatus(ts);
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const save = async (e) => {
|
const save = async (e) => {
|
||||||
e.preventDefault(); setSaving(true);
|
e.preventDefault(); setSaving(true);
|
||||||
@@ -97,19 +110,130 @@ export default function Settings() {
|
|||||||
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
|
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 () => {
|
const enrichAll = async () => {
|
||||||
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
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}`); }
|
try {
|
||||||
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
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 (
|
return (
|
||||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
<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">
|
<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>
|
<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">
|
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||||
{/* TMDB */}
|
{/* TMDB */}
|
||||||
<section>
|
<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>
|
<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 />
|
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||||
{info.tmdb_configured && (
|
{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">
|
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||||
Bulk enrich all movies →
|
<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>
|
</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>
|
</section>
|
||||||
|
|
||||||
@@ -204,10 +340,70 @@ export default function Settings() {
|
|||||||
</label>
|
</label>
|
||||||
</section>
|
</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">
|
<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"}
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trakt device-code modal */}
|
{/* Trakt device-code modal */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from "react";
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { Play, ArrowLeft } from "lucide-react";
|
import { Play, ArrowLeft } from "lucide-react";
|
||||||
|
import AlbumArt from "../components/AlbumArt";
|
||||||
|
|
||||||
export default function ShowDetail() {
|
export default function ShowDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -27,7 +28,7 @@ export default function ShowDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
|
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
|
||||||
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
|
<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" />
|
<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">
|
<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>
|
<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 { useNavigate } from "react-router-dom";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { Tv } from "lucide-react";
|
import { Tv } from "lucide-react";
|
||||||
|
import AlbumArt from "../components/AlbumArt";
|
||||||
|
|
||||||
export default function Shows() {
|
export default function Shows() {
|
||||||
const [shows, setShows] = useState([]);
|
const [shows, setShows] = useState([]);
|
||||||
@@ -51,7 +52,7 @@ export default function Shows() {
|
|||||||
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
|
<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}`}>
|
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">
|
<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>
|
||||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
||||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
<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}`)}
|
<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]"
|
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}`}>
|
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">
|
<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-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>
|
<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 { useEffect, useState } from "react";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { toast } from "sonner";
|
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 = {
|
const STATUS_COLORS = {
|
||||||
pending: "text-[#fcd34d]",
|
pending: "text-[#fcd34d]",
|
||||||
@@ -9,11 +9,13 @@ const STATUS_COLORS = {
|
|||||||
done: "text-[#86efac]",
|
done: "text-[#86efac]",
|
||||||
failed: "text-[#fca5a5]",
|
failed: "text-[#fca5a5]",
|
||||||
cancelled: "text-[#8A8A8A]",
|
cancelled: "text-[#8A8A8A]",
|
||||||
|
superseded: "text-[#8A8A8A]",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TranscodeQueue() {
|
export default function TranscodeQueue() {
|
||||||
const [jobs, setJobs] = useState([]);
|
const [jobs, setJobs] = useState([]);
|
||||||
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false, auto_transcode: "off" });
|
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 [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -24,15 +26,25 @@ export default function TranscodeQueue() {
|
|||||||
setStats(s.data);
|
setStats(s.data);
|
||||||
} finally { setLoading(false); }
|
} 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(() => {
|
useEffect(() => {
|
||||||
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
|
const pending = jobs.some((j) => j.status === "pending");
|
||||||
if (!active) return;
|
if (!runningJob && !pending) return;
|
||||||
const t = setInterval(load, 4000);
|
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, [jobs]);
|
}, [jobs, runningJob]);
|
||||||
|
|
||||||
|
const listedJobs = jobs.filter((j) => j.status !== "running");
|
||||||
|
|
||||||
const cancel = async (id) => {
|
const cancel = async (id) => {
|
||||||
try { await api.delete(`/transcode/queue/${id}`); toast.success("Job cancelled"); load(); }
|
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"); }
|
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 () => {
|
const togglePause = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post("/transcode/queue/pause", { paused: !stats.paused });
|
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"); }
|
} 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 () => {
|
const clearFinished = async () => {
|
||||||
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
|
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(); }
|
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
|
||||||
@@ -61,12 +92,14 @@ export default function TranscodeQueue() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page">
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page">
|
||||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 2 of 2</span>
|
||||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
|
<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">
|
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||||
One job runs at a time at low CPU priority. Auto-transcode is currently:{" "}
|
Only files the <a href="/admin/codec-check" className="text-[#D9381E] hover:text-[#ED4B32]">Codec Check Queue (step 1)</a> flagged
|
||||||
|
as needing work land here — already browser-native files skip this queue entirely. 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>
|
<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>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
|
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||||
@@ -86,12 +119,42 @@ export default function TranscodeQueue() {
|
|||||||
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
|
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
|
||||||
{stats.paused ? "Resume" : "Pause"}
|
{stats.paused ? "Resume" : "Pause"}
|
||||||
</button>
|
</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">
|
<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
|
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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]">
|
<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-5">Movie</span>
|
||||||
<span className="col-span-1">Quality</span>
|
<span className="col-span-1">Quality</span>
|
||||||
@@ -100,9 +163,9 @@ export default function TranscodeQueue() {
|
|||||||
<span className="col-span-2">Created</span>
|
<span className="col-span-2">Created</span>
|
||||||
<span className="col-span-1 text-right">Actions</span>
|
<span className="col-span-1 text-right">Actions</span>
|
||||||
</div>
|
</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>
|
<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 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>
|
<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>
|
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
|
||||||
@@ -113,19 +176,14 @@ export default function TranscodeQueue() {
|
|||||||
</span>
|
</span>
|
||||||
<span className="col-span-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</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">
|
<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" && (
|
{j.status === "pending" && (
|
||||||
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
|
<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} />
|
<X size={14} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(j.status === "failed" || j.status === "cancelled") && (
|
{(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">
|
<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={14} strokeWidth={1.5} />
|
<RotateCcw size={13} strokeWidth={1.5} /> Retry
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Kino — one-line installer
|
# StreamHoard — one-line installer
|
||||||
#
|
#
|
||||||
# Usage (interactive):
|
# Usage (interactive):
|
||||||
# curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh | sudo bash
|
# 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}"
|
KINO_HTTP_PORT="${KINO_HTTP_PORT:-8080}"
|
||||||
MEDIA_ROOT_HOST="${MEDIA_ROOT_HOST:-${KINO_DIR}/media}"
|
MEDIA_ROOT_HOST="${MEDIA_ROOT_HOST:-${KINO_DIR}/media}"
|
||||||
DB_NAME="${DB_NAME:-kino}"
|
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_NAME="${ADMIN_NAME:-Admin}"
|
||||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||||
JWT_SECRET="${JWT_SECRET:-}"
|
JWT_SECRET="${JWT_SECRET:-}"
|
||||||
@@ -172,7 +172,7 @@ fi
|
|||||||
# ---------- Done ----------
|
# ---------- Done ----------
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
$(c_grn '━━━ Kino is up ━━━')
|
$(c_grn '━━━ StreamHoard is up ━━━')
|
||||||
|
|
||||||
URL : $(c_cyn "$URL")
|
URL : $(c_cyn "$URL")
|
||||||
Admin : $(c_cyn "$ADMIN_EMAIL")
|
Admin : $(c_cyn "$ADMIN_EMAIL")
|
||||||
|
|||||||
Reference in New Issue
Block a user