mirror of
https://github.com/myronblair/kino-app
synced 2026-07-30 06:23:40 -05:00
Compare commits
43 Commits
966ab6a34f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 671074764e | |||
| 628a729afd | |||
| 4397617cf1 | |||
| 119dd20743 | |||
| eed071dd8f | |||
| 8fa1291e0f | |||
| 71e731124a | |||
| 6438407f5b | |||
| e54803abb0 | |||
| 9742ebf50c | |||
| b74d8a2652 | |||
| cf8c7890be | |||
| 43340c0f49 | |||
| ef5c13d587 | |||
| 5fc37e46e4 | |||
| 5ac78654d4 | |||
| 9af505b45c | |||
| a313008440 | |||
| 4e72f568f3 | |||
| c174ac60b1 | |||
| 3c47894628 | |||
| 3d2b7a4122 | |||
| 1891fd0b7c | |||
| 64d8945460 | |||
| cf31a14ae1 | |||
| 514cbad4ab | |||
| f7626ced2e | |||
| 5b191da471 | |||
| 0f6f03509a | |||
| 4d77a36f99 | |||
| 19374b694e | |||
| 5b881a902d | |||
| 003512c01f | |||
| e27bb37f7b | |||
| d4dffdb6fb | |||
| 3e30f1491c | |||
| 211d4c23d5 | |||
| bd8480bd14 | |||
| 22d8479b39 | |||
| 34a052dabf | |||
| 1471d58385 | |||
| 21cdd24116 | |||
| da8a2903b4 |
@@ -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-04-29T16:22:36.278232+00:00Z"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
[user]
|
||||
email = github@emergent.sh
|
||||
name = emergent-agent-e1
|
||||
+1
-1
@@ -61,7 +61,6 @@ logs/
|
||||
|
||||
# --- Test reports / agent runtime ---
|
||||
test_reports/
|
||||
.emergent/
|
||||
tests/__pycache__/
|
||||
|
||||
# --- Misc ---
|
||||
@@ -104,3 +103,4 @@ credentials.json
|
||||
*.pem
|
||||
*.key
|
||||
.credentials
|
||||
frontend/node_modules/.cache/default-development/0.pack
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# StreamHoard — Deployment on a Proxmox LAMP host
|
||||
|
||||
StreamHoard is FastAPI + MongoDB + React. It runs happily alongside your existing
|
||||
LAMP stack (Apache/MySQL/PHP) because we run everything in Docker containers
|
||||
on their own network. Apache keeps port 80/443, MySQL is untouched, StreamHoard
|
||||
uses whatever port you pick.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites on the Proxmox host (or LXC / VM)
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install -y docker.io docker-compose-plugin git
|
||||
sudo systemctl enable --now docker
|
||||
|
||||
# Verify
|
||||
docker compose version
|
||||
```
|
||||
|
||||
If you already have Docker and Compose v2, skip this step.
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/YOU/kino-media-server.git /opt/kino
|
||||
cd /opt/kino
|
||||
|
||||
# 2. Configure
|
||||
cp .env.compose.example .env
|
||||
nano .env
|
||||
# - Set KINO_HTTP_PORT (default 8080; anything not used by Apache)
|
||||
# - Set MEDIA_ROOT_HOST to your bulk storage path (e.g. /mnt/nas/movies)
|
||||
# - Set a strong JWT_SECRET: openssl rand -hex 32
|
||||
# - Set a strong ADMIN_PASSWORD
|
||||
# - Optional: change ADMIN_EMAIL
|
||||
|
||||
# 3. Start
|
||||
docker compose up -d
|
||||
|
||||
# 4. Check
|
||||
docker compose ps
|
||||
docker compose logs -f backend # confirm "Transcode worker started"
|
||||
```
|
||||
|
||||
Visit `http://<proxmox-ip>:8080` (or whatever `KINO_HTTP_PORT` you set).
|
||||
Log in with the admin credentials from `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Running behind your existing Apache (optional, recommended)
|
||||
|
||||
If you already have Apache on port 80/443 with virtual hosts, add a StreamHoard vhost
|
||||
that reverse-proxies to the container. This gives you `https://kino.example.com`
|
||||
instead of `http://ip:8080`.
|
||||
|
||||
**`/etc/apache2/sites-available/kino.conf`**
|
||||
```apache
|
||||
<VirtualHost *:80>
|
||||
ServerName kino.local # or kino.yourdomain.tld
|
||||
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
|
||||
# SPA + API
|
||||
ProxyPass / http://127.0.0.1:8080/
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
|
||||
# Streaming needs disabled buffering
|
||||
SetEnv proxy-sendchunked 1
|
||||
|
||||
# Multi-GB uploads
|
||||
LimitRequestBody 21474836480
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http headers
|
||||
sudo a2ensite kino
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
For HTTPS, either terminate TLS at Apache (add certbot/`SSLEngine on`) or
|
||||
put Caddy in front of it.
|
||||
|
||||
---
|
||||
|
||||
## Common ops
|
||||
|
||||
```bash
|
||||
# Update to latest code
|
||||
cd /opt/kino
|
||||
git pull
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
# Stop
|
||||
docker compose down
|
||||
|
||||
# Logs
|
||||
docker compose logs -f backend # FastAPI + transcode worker
|
||||
docker compose logs -f frontend # nginx access
|
||||
docker compose logs -f mongo
|
||||
|
||||
# Shell into backend
|
||||
docker compose exec backend bash
|
||||
|
||||
# Backup MongoDB
|
||||
docker compose exec mongo mongodump --out /data/db/backup-$(date +%F)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Where does data live?
|
||||
|
||||
| What | Where |
|
||||
| -------------------------- | ---------------------------------------------------- |
|
||||
| Movie files, HLS, subs | `$MEDIA_ROOT_HOST` on host (bind-mounted) |
|
||||
| MongoDB data | Docker volume `mongo-data` (managed by Docker) |
|
||||
| TMDB / Radarr keys | In MongoDB (`db.settings` collection) |
|
||||
| Uploads via web UI | `$MEDIA_ROOT_HOST/videos/` |
|
||||
|
||||
To reset everything: `docker compose down -v` (deletes the mongo-data volume).
|
||||
|
||||
---
|
||||
|
||||
## Coexistence with your LAMP stack
|
||||
|
||||
| StreamHoard uses | LAMP uses | Conflict? |
|
||||
| ------------------------ | ----------------------- | --------- |
|
||||
| Port `KINO_HTTP_PORT` | Apache 80/443 | No — different ports |
|
||||
| MongoDB (internal only) | MySQL | No — different DBs, Mongo not exposed to host |
|
||||
| ffmpeg (in container) | anything | No |
|
||||
| Docker networks | native host services | No — bridged network |
|
||||
|
||||
You can happily keep running Wordpress, phpMyAdmin, Nextcloud, whatever on the
|
||||
same box.
|
||||
|
||||
---
|
||||
|
||||
## Radarr integration
|
||||
|
||||
If Radarr is running on the same Proxmox host (native or container), point StreamHoard
|
||||
at it via **Admin → Settings → Radarr**:
|
||||
|
||||
- **URL**: `http://<host-ip>:7878` (use the host IP, not `localhost` — containers
|
||||
can't reach the host's `localhost` unless you add `host.docker.internal`)
|
||||
- **API key**: from Radarr → Settings → General → Security
|
||||
|
||||
**Important**: For StreamHoard to actually play the imported movies, the path Radarr
|
||||
reports (e.g. `/movies/Inception (2010)/Inception.mkv`) must be **readable from
|
||||
inside the StreamHoard backend container**. Mount Radarr's media path into StreamHoard:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — extend the backend volumes
|
||||
backend:
|
||||
volumes:
|
||||
- ${MEDIA_ROOT_HOST:-./media}:/media
|
||||
- /mnt/radarr-movies:/mnt/radarr-movies:ro # <— add this
|
||||
```
|
||||
|
||||
Radarr's file paths must be identical inside both Radarr and StreamHoard containers
|
||||
(easiest: match the paths exactly).
|
||||
|
||||
---
|
||||
|
||||
## Health check
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
|
||||
# {"app":"StreamHoard","status":"ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Firewalling
|
||||
|
||||
StreamHoard has no built-in rate limiting or IP allowlist. Since you confirmed
|
||||
**internal network only**, keep it that way:
|
||||
|
||||
```bash
|
||||
# ufw example — allow only your LAN
|
||||
sudo ufw allow from 192.168.0.0/16 to any port ${KINO_HTTP_PORT:-8080}
|
||||
sudo ufw deny ${KINO_HTTP_PORT:-8080}
|
||||
```
|
||||
@@ -1,8 +1,34 @@
|
||||
# Kino — Personal Media Server (Netflix Clone)
|
||||
# StreamHoard — Personal Media Server (Netflix Clone)
|
||||
|
||||
A self-hosted Netflix-style streaming app for movies you legally own.
|
||||
Built with FastAPI + React + MongoDB.
|
||||
|
||||
## ⚡ One-line install (Proxmox / any Linux with root)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/myronblair/kino-app/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer:
|
||||
1. Installs Docker + Compose if missing
|
||||
2. Clones the repo into `/opt/kino`
|
||||
3. Generates a strong `JWT_SECRET` and admin password
|
||||
4. Starts the stack via `docker compose`
|
||||
5. Prints the URL + credentials
|
||||
|
||||
Non-interactive with overrides:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/myronblair/kino-app/main/install.sh \
|
||||
| sudo KINO_HTTP_PORT=9000 \
|
||||
MEDIA_ROOT_HOST=/mnt/nas/movies \
|
||||
ADMIN_PASSWORD='choose-your-own' \
|
||||
bash
|
||||
```
|
||||
|
||||
## 📖 Full deployment
|
||||
|
||||
See **[DEPLOY.md](./DEPLOY.md)** for LAMP-coexistence, Apache reverse proxy, Radarr integration, and firewalling.
|
||||
|
||||
## Features
|
||||
|
||||
- Cinematic browse UI with hero banner, horizontal carousels, hover details
|
||||
@@ -15,7 +41,7 @@ Built with FastAPI + React + MongoDB.
|
||||
*(Legal alternative to auto-downloading — auto-download of copyrighted material is not implemented.)*
|
||||
|
||||
## Default admin
|
||||
- Email: `admin@kino.local`
|
||||
- Email: `admin@streamhoard.local`
|
||||
- Password: `kino-admin-2026`
|
||||
|
||||
Change `ADMIN_EMAIL` / `ADMIN_PASSWORD` in `backend/.env` before first start
|
||||
@@ -92,7 +118,7 @@ Movie media files should live on your Proxmox storage, not Git.
|
||||
# Login
|
||||
curl -X POST $URL/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"admin@kino.local","password":"kino-admin-2026"}'
|
||||
-d '{"email":"admin@streamhoard.local","password":"kino-admin-2026"}'
|
||||
|
||||
# List movies
|
||||
curl $URL/api/movies
|
||||
@@ -104,7 +130,7 @@ curl $URL/api/stream/<movie_id>?auth=<token>
|
||||
## Why no auto-download?
|
||||
|
||||
Auto-downloading copyrighted movies from the internet is illegal in most
|
||||
jurisdictions (DMCA, EU copyright directive, etc.). Kino is built as a
|
||||
jurisdictions (DMCA, EU copyright directive, etc.). StreamHoard is built as a
|
||||
**personal media server** for content you legally own or that is in the
|
||||
public domain (Internet Archive, Blender Open Movies, etc.).
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Kino backend — FastAPI + ffmpeg
|
||||
FROM python:3.11-slim
|
||||
|
||||
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit);
|
||||
# libchromaprint-tools provides `fpcalc`, used for AcoustID audio fingerprinting.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
curl \
|
||||
libchromaprint-tools \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python deps first (layer cache)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy app source
|
||||
COPY . .
|
||||
|
||||
# Create media dirs (will be overridden by volume mount at runtime)
|
||||
RUN mkdir -p /media/videos /media/posters /media/subtitles /media/hls
|
||||
|
||||
EXPOSE 8001
|
||||
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8001", "--proxy-headers"]
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -0,0 +1,83 @@
|
||||
"""AcoustID audio fingerprint identification — free, but requires:
|
||||
1. A free API key from https://acoustid.org/api-key (one signup, no cost)
|
||||
2. The `fpcalc` binary (Chromaprint) to compute a fingerprint from the actual audio file
|
||||
|
||||
Unlike musicbrainz.py/itunes.py (which match on artist+album text), this identifies a track
|
||||
from its audio content — useful for files with missing/wrong tags where there's nothing
|
||||
usable to text-search on.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kino.acoustid")
|
||||
|
||||
ACOUSTID_BASE = "https://api.acoustid.org/v2/lookup"
|
||||
|
||||
|
||||
def fingerprint_file(path: Path) -> Optional[Dict]:
|
||||
"""Runs fpcalc on the file; returns {"duration": float, "fingerprint": str} or None."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["fpcalc", "-json", str(path)],
|
||||
capture_output=True, timeout=60, text=True,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
data = json.loads(proc.stdout)
|
||||
if not data.get("fingerprint"):
|
||||
return None
|
||||
return {"duration": data.get("duration"), "fingerprint": data["fingerprint"]}
|
||||
except FileNotFoundError:
|
||||
logger.warning("fpcalc not installed — AcoustID identification unavailable")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def lookup_recording(api_key: str, duration: float, fingerprint: str) -> Optional[Dict]:
|
||||
"""Identifies a recording from its fingerprint. Returns best-match title/artist/album."""
|
||||
try:
|
||||
r = requests.get(ACOUSTID_BASE, params={
|
||||
"client": api_key,
|
||||
"duration": int(duration or 0),
|
||||
"fingerprint": fingerprint,
|
||||
"meta": "recordings+releasegroups",
|
||||
}, timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json() or {}
|
||||
except Exception:
|
||||
return None
|
||||
if data.get("status") != "ok":
|
||||
return None
|
||||
results = sorted(data.get("results") or [], key=lambda x: x.get("score", 0), reverse=True)
|
||||
for res in results:
|
||||
recordings = res.get("recordings") or []
|
||||
if not recordings:
|
||||
continue
|
||||
rec = recordings[0]
|
||||
releasegroups = rec.get("releasegroups") or []
|
||||
artist = (rec.get("artists") or [{}])[0].get("name")
|
||||
title = rec.get("title")
|
||||
if not (artist and title):
|
||||
continue
|
||||
return {
|
||||
"title": title,
|
||||
"artist": artist,
|
||||
"album": releasegroups[0].get("title") if releasegroups else None,
|
||||
"mbid": rec.get("id"),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
async def identify_track(api_key: str, path: Path) -> Optional[Dict]:
|
||||
"""Fingerprint + lookup a single file. Runs the blocking pieces off the event loop."""
|
||||
fp = await asyncio.to_thread(fingerprint_file, path)
|
||||
if not fp:
|
||||
return None
|
||||
return await asyncio.to_thread(lookup_recording, api_key, fp["duration"], fp["fingerprint"])
|
||||
@@ -0,0 +1,32 @@
|
||||
"""iTunes Search API client — free, no API key, no auth, no rate-limit registration.
|
||||
Used as a fallback when MusicBrainz + Cover Art Archive has no match/art for a release.
|
||||
"""
|
||||
import requests
|
||||
from typing import Optional, Dict
|
||||
|
||||
ITUNES_BASE = "https://itunes.apple.com/search"
|
||||
|
||||
|
||||
def lookup_album(artist: str, album: str) -> Optional[Dict]:
|
||||
"""Best-effort match for an artist+album against the iTunes Store catalog."""
|
||||
try:
|
||||
r = requests.get(ITUNES_BASE, params={
|
||||
"term": f"{artist} {album}", "media": "music", "entity": "album", "limit": 1,
|
||||
}, timeout=15)
|
||||
r.raise_for_status()
|
||||
results = (r.json() or {}).get("results") or []
|
||||
except Exception:
|
||||
return None
|
||||
if not results:
|
||||
return None
|
||||
res = results[0]
|
||||
art = res.get("artworkUrl100")
|
||||
if art:
|
||||
# iTunes serves a small default; the same CDN path accepts arbitrary sizes.
|
||||
art = art.replace("100x100bb", "1200x1200bb")
|
||||
return {
|
||||
"title": res.get("collectionName") or album,
|
||||
"artist": res.get("artistName") or artist,
|
||||
"year": (res.get("releaseDate") or "")[:4] or None,
|
||||
"art_url": art,
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Library integrity scan — missing files, orphaned records/disk usage, stuck jobs, duplicates.
|
||||
Read-only by design: scan() only reports findings; server.py's /library/cleanup/fix endpoint
|
||||
performs the actual deletion/reset the admin picked, one category at a time.
|
||||
"""
|
||||
import shutil
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import asyncio
|
||||
|
||||
# Movie/episode/track files often live on an NFS/CIFS-mounted NAS, where every is_file() stat
|
||||
# is a real network round trip. Checking hundreds of them one at a time (even off the event
|
||||
# loop) can take minutes; a bounded worker pool checks them concurrently instead.
|
||||
_STAT_WORKERS = 24
|
||||
|
||||
|
||||
def _file_path(storage_type: str, storage_path: str, videos_dir: Path) -> Path:
|
||||
if storage_type in ("radarr", "sonarr", "scan"):
|
||||
return Path(storage_path)
|
||||
return videos_dir / storage_path
|
||||
|
||||
|
||||
def _scan_filesystem(movies: List[Dict], episodes: List[Dict], tracks: List[Dict],
|
||||
videos_dir: Path, hls_dir: Path, live_ids: set) -> Dict[str, List[Dict]]:
|
||||
"""Every filesystem stat call in the whole scan lives here, run off the event loop via
|
||||
asyncio.to_thread, with the individual stat calls themselves parallelized across a small
|
||||
worker pool — sequential one-at-a-time NAS round trips were taking minutes for a library
|
||||
of any real size."""
|
||||
out: Dict[str, List[Dict]] = {"missing_files": [], "orphaned_hls": []}
|
||||
|
||||
candidates = [] # (finding_dict, path_to_check)
|
||||
for m in movies:
|
||||
if m.get("storage_type") not in ("local", "radarr", "scan") or not m.get("storage_path"):
|
||||
continue
|
||||
candidates.append((
|
||||
{"id": m["id"], "content_type": "movie", "label": m.get("title", "Untitled"), "detail": m["storage_path"]},
|
||||
_file_path(m["storage_type"], m["storage_path"], videos_dir),
|
||||
))
|
||||
for e in episodes:
|
||||
if e.get("_orphaned"):
|
||||
continue # already reported as orphaned_episodes; don't also report a missing file
|
||||
if e.get("storage_type") not in ("local", "sonarr", "scan") or not e.get("storage_path"):
|
||||
continue
|
||||
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
|
||||
candidates.append((
|
||||
{"id": e["id"], "content_type": "episode", "label": label, "detail": e["storage_path"]},
|
||||
_file_path(e["storage_type"], e["storage_path"], videos_dir),
|
||||
))
|
||||
for t in tracks:
|
||||
candidates.append((
|
||||
{"id": t["id"], "content_type": "track", "label": f"{t.get('artist','')} — {t.get('title','Untitled')}", "detail": t["storage_path"]},
|
||||
Path(t["storage_path"]),
|
||||
))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=_STAT_WORKERS) as pool:
|
||||
exists = pool.map(lambda pair: pair[1].is_file(), candidates)
|
||||
for (finding, _path), is_present in zip(candidates, exists):
|
||||
if not is_present:
|
||||
out["missing_files"].append(finding)
|
||||
|
||||
if hls_dir.is_dir():
|
||||
for d in hls_dir.iterdir():
|
||||
if d.is_dir() and d.name not in live_ids:
|
||||
size = sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
|
||||
out["orphaned_hls"].append({"id": d.name, "content_type": "hls_dir", "label": d.name, "detail": f"{round(size / 1_000_000)} MB reclaimable"})
|
||||
|
||||
return out
|
||||
|
||||
|
||||
async def scan(db, videos_dir: Path, hls_dir: Path) -> Dict[str, List[Dict]]:
|
||||
findings: Dict[str, List[Dict]] = {
|
||||
"missing_files": [], "orphaned_episodes": [], "orphaned_hls": [],
|
||||
"stuck_jobs": [], "duplicate_paths": [], "orphaned_subtitles": [], "dangling_refs": [],
|
||||
}
|
||||
|
||||
movies = await db.movies.find({}, {"_id": 0, "id": 1, "title": 1, "storage_type": 1, "storage_path": 1}).to_list(10000)
|
||||
episodes = await db.episodes.find({}, {"_id": 0, "id": 1, "title": 1, "show_id": 1, "season_number": 1, "episode_number": 1, "storage_type": 1, "storage_path": 1}).to_list(20000)
|
||||
tracks = await db.tracks.find({}, {"_id": 0, "id": 1, "title": 1, "artist": 1, "storage_path": 1}).to_list(20000)
|
||||
show_ids = set(await db.shows.distinct("id"))
|
||||
|
||||
for e in episodes:
|
||||
label = f"S{e.get('season_number')}E{e.get('episode_number')} {e.get('title') or ''}".strip()
|
||||
if e.get("show_id") not in show_ids:
|
||||
findings["orphaned_episodes"].append({"id": e["id"], "content_type": "episode", "label": label, "detail": f"show_id {e.get('show_id')} not found"})
|
||||
e["_orphaned"] = True
|
||||
|
||||
live_ids = {m["id"] for m in movies} | {e["id"] for e in episodes}
|
||||
fs_findings = await asyncio.to_thread(_scan_filesystem, movies, episodes, tracks, videos_dir, hls_dir, live_ids)
|
||||
findings["missing_files"] = fs_findings["missing_files"]
|
||||
findings["orphaned_hls"] = fs_findings["orphaned_hls"]
|
||||
|
||||
# --- Stuck transcode jobs (running with no progress for 2+ hours — likely dead from a restart) ---
|
||||
now = datetime.now(timezone.utc)
|
||||
running = await db.transcode_queue.find({"status": "running"}, {"_id": 0}).to_list(500)
|
||||
for j in running:
|
||||
started = j.get("started_at")
|
||||
if not started:
|
||||
continue
|
||||
try:
|
||||
age_hours = (now - datetime.fromisoformat(started)).total_seconds() / 3600
|
||||
except ValueError:
|
||||
continue
|
||||
if age_hours > 2:
|
||||
findings["stuck_jobs"].append({"id": j["id"], "content_type": "job", "label": j.get("movie_title") or j["movie_id"], "detail": f"running {round(age_hours, 1)}h with no completion"})
|
||||
|
||||
# --- Duplicate storage_path within the same collection (same file imported twice) ---
|
||||
for coll_name, coll, label_fn in (
|
||||
("movies", db.movies, lambda d: d.get("title", "Untitled")),
|
||||
("episodes", db.episodes, lambda d: d.get("title") or d.get("id")),
|
||||
("tracks", db.tracks, lambda d: d.get("title", "Untitled")),
|
||||
):
|
||||
docs = await coll.find({"storage_path": {"$ne": None}}, {"_id": 0}).sort("created_at", 1).to_list(20000)
|
||||
seen = {}
|
||||
for d in docs:
|
||||
key = d.get("storage_path")
|
||||
if not key:
|
||||
continue
|
||||
if key in seen:
|
||||
findings["duplicate_paths"].append({"id": d["id"], "content_type": coll_name[:-1], "label": label_fn(d), "detail": f"duplicate of {seen[key]}"})
|
||||
else:
|
||||
seen[key] = d["id"]
|
||||
|
||||
# --- Orphaned subtitles (reference a movie/episode that no longer exists) ---
|
||||
movie_ids = {m["id"] for m in movies}
|
||||
episode_ids = {e["id"] for e in episodes}
|
||||
subs = await db.subtitles.find({}, {"_id": 0}).to_list(5000)
|
||||
for s in subs:
|
||||
mid, eid = s.get("movie_id") or "", s.get("episode_id") or ""
|
||||
if mid and mid not in movie_ids:
|
||||
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"movie_id {mid} not found"})
|
||||
elif eid and eid not in episode_ids:
|
||||
findings["orphaned_subtitles"].append({"id": s["id"], "content_type": "subtitle", "label": s.get("label", "Subtitle"), "detail": f"episode_id {eid} not found"})
|
||||
|
||||
# --- Dangling watchlist/progress rows (reference a deleted movie) ---
|
||||
for coll_name, coll in (("watchlist", db.watchlist), ("progress", db.progress)):
|
||||
rows = await coll.find({}, {"_id": 0}).to_list(20000)
|
||||
for r in rows:
|
||||
if r.get("movie_id") and r["movie_id"] not in movie_ids:
|
||||
findings["dangling_refs"].append({"id": r["id"] if "id" in r else f"{r['profile_id']}:{r['movie_id']}", "content_type": coll_name, "label": r["movie_id"], "detail": f"references deleted movie {r['movie_id']}", "profile_id": r.get("profile_id"), "movie_id": r.get("movie_id")})
|
||||
|
||||
# --- Dangling episode_progress rows (reference a deleted episode) ---
|
||||
ep_rows = await db.episode_progress.find({}, {"_id": 0}).to_list(20000)
|
||||
for r in ep_rows:
|
||||
if r.get("episode_id") and r["episode_id"] not in episode_ids:
|
||||
findings["dangling_refs"].append({"id": f"{r['profile_id']}:{r['episode_id']}", "content_type": "episode_progress", "label": r["episode_id"], "detail": f"references deleted episode {r['episode_id']}", "profile_id": r.get("profile_id"), "episode_id": r.get("episode_id")})
|
||||
|
||||
return findings
|
||||
|
||||
|
||||
async def _delete_content(db, content_type: str, item_id: str) -> None:
|
||||
"""Deletes a movie/episode/track and every row that references it, so a Fix never
|
||||
leaves orphans behind for the next scan to catch on a second pass."""
|
||||
if content_type == "movie":
|
||||
await db.movies.delete_one({"id": item_id})
|
||||
await db.watchlist.delete_many({"movie_id": item_id})
|
||||
await db.progress.delete_many({"movie_id": item_id})
|
||||
await db.subtitles.delete_many({"movie_id": item_id})
|
||||
elif content_type == "episode":
|
||||
await db.episodes.delete_one({"id": item_id})
|
||||
await db.episode_progress.delete_many({"episode_id": item_id})
|
||||
await db.subtitles.delete_many({"episode_id": item_id})
|
||||
elif content_type == "track":
|
||||
await db.tracks.delete_one({"id": item_id})
|
||||
else:
|
||||
raise ValueError(f"Unknown content_type: {content_type}")
|
||||
|
||||
|
||||
async def fix_one(db, videos_dir: Path, hls_dir: Path, category: str, item: Dict) -> None:
|
||||
content_type = item.get("content_type")
|
||||
item_id = item.get("id")
|
||||
|
||||
if category == "missing_files":
|
||||
await _delete_content(db, content_type, item_id)
|
||||
|
||||
elif category == "orphaned_episodes":
|
||||
await _delete_content(db, "episode", item_id)
|
||||
|
||||
elif category == "orphaned_hls":
|
||||
shutil.rmtree(hls_dir / item_id, ignore_errors=True)
|
||||
|
||||
elif category == "stuck_jobs":
|
||||
await db.transcode_queue.update_one({"id": item_id}, {"$set": {
|
||||
"status": "failed", "error": "Reset by library cleanup (stuck with no progress)",
|
||||
"finished_at": datetime.now(timezone.utc).isoformat(),
|
||||
}})
|
||||
|
||||
elif category == "duplicate_paths":
|
||||
await _delete_content(db, content_type, item_id)
|
||||
|
||||
elif category == "orphaned_subtitles":
|
||||
sub = await db.subtitles.find_one({"id": item_id}, {"_id": 0})
|
||||
if sub and sub.get("storage_path"):
|
||||
try:
|
||||
(Path(str(videos_dir.parent / "subtitles")) / sub["storage_path"]).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
await db.subtitles.delete_one({"id": item_id})
|
||||
|
||||
elif category == "dangling_refs":
|
||||
if content_type == "episode_progress":
|
||||
await db.episode_progress.delete_one({"profile_id": item.get("profile_id"), "episode_id": item.get("episode_id")})
|
||||
else:
|
||||
coll = {"watchlist": db.watchlist, "progress": db.progress}[content_type]
|
||||
await coll.delete_one({"profile_id": item.get("profile_id"), "movie_id": item.get("movie_id")})
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown cleanup category: {category}")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Filesystem scanner for content already sitting on the NAS mounts —
|
||||
covers movies/tv placed outside Radarr/Sonarr, and the music library
|
||||
(nothing else in StreamHoard touches music). Read-only: never writes files,
|
||||
just walks paths and reports what it finds. Callers (server.py) cross-check
|
||||
results against the DB to decide what's new vs. already imported.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
|
||||
MOVIE_EXTS = {".mp4", ".mkv", ".avi", ".m4v", ".mov"}
|
||||
AUDIO_EXTS = {".mp3", ".m4a", ".flac", ".aac", ".wav", ".ogg"}
|
||||
|
||||
MOVIES_DIR = Path("/mnt/nas/video/movies")
|
||||
TV_DIR = Path("/mnt/nas/video/tv")
|
||||
MUSIC_DIR = Path("/mnt/nas/music/Music")
|
||||
|
||||
_YEAR_RE = re.compile(r"[\[\(]\s*(\d{4})\s*[\]\)]")
|
||||
_SEASON_DIR_RE = re.compile(r"season\s*(\d+)", re.IGNORECASE)
|
||||
_EPISODE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,3})")
|
||||
_TRACK_NUM_RE = re.compile(r"^(\d{1,3})[\s._-]+(.*)$")
|
||||
|
||||
|
||||
def _clean_title(name: str) -> str:
|
||||
name = _YEAR_RE.sub("", name)
|
||||
return re.sub(r"\s+", " ", name).strip(" -_.")
|
||||
|
||||
|
||||
def _parse_year(name: str) -> Optional[int]:
|
||||
m = _YEAR_RE.search(name)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _largest_video_file(files: List[Path]) -> Optional[Path]:
|
||||
vids = [f for f in files if f.suffix.lower() in MOVIE_EXTS]
|
||||
if not vids:
|
||||
return None
|
||||
return max(vids, key=lambda f: f.stat().st_size)
|
||||
|
||||
|
||||
def scan_movies() -> List[Dict]:
|
||||
"""Each movies/ entry is either a standalone video file or a folder containing one."""
|
||||
if not MOVIES_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for entry in sorted(MOVIES_DIR.iterdir()):
|
||||
if entry.name.startswith("@") or entry.name.startswith("#"):
|
||||
continue # Synology internal dirs (@eaDir, #recycle)
|
||||
if entry.is_file():
|
||||
if entry.suffix.lower() not in MOVIE_EXTS:
|
||||
continue
|
||||
title_raw = entry.stem
|
||||
file_path = entry
|
||||
elif entry.is_dir():
|
||||
try:
|
||||
files = [f for f in entry.rglob("*") if f.is_file()]
|
||||
except OSError:
|
||||
continue
|
||||
file_path = _largest_video_file(files)
|
||||
if not file_path:
|
||||
continue
|
||||
title_raw = entry.name
|
||||
else:
|
||||
continue
|
||||
out.append({
|
||||
"title": _clean_title(title_raw) or title_raw,
|
||||
"year": _parse_year(title_raw),
|
||||
"storage_path": str(file_path),
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def scan_tv() -> List[Dict]:
|
||||
"""tv/<Show>/Season N/<episode files>. Groups by show, one entry per episode."""
|
||||
if not TV_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for show_dir in sorted(TV_DIR.iterdir()):
|
||||
if not show_dir.is_dir() or show_dir.name.startswith("@") or show_dir.name.startswith("#"):
|
||||
continue
|
||||
show_title = _clean_title(show_dir.name) or show_dir.name
|
||||
for season_dir in sorted(show_dir.iterdir()):
|
||||
if not season_dir.is_dir():
|
||||
continue
|
||||
m = _SEASON_DIR_RE.search(season_dir.name)
|
||||
season_number = int(m.group(1)) if m else 1
|
||||
for f in sorted(season_dir.iterdir()):
|
||||
if not f.is_file() or f.suffix.lower() not in MOVIE_EXTS:
|
||||
continue
|
||||
em = _EPISODE_RE.search(f.stem)
|
||||
if em:
|
||||
season_number = int(em.group(1))
|
||||
episode_number = int(em.group(2))
|
||||
title = _clean_title(f.stem[em.end():]) or f"Episode {episode_number}"
|
||||
else:
|
||||
episode_number = None
|
||||
title = _clean_title(f.stem)
|
||||
out.append({
|
||||
"show_title": show_title,
|
||||
"season_number": season_number,
|
||||
"episode_number": episode_number,
|
||||
"title": title,
|
||||
"storage_path": str(f),
|
||||
"size_bytes": f.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _read_tags(path: Path) -> Dict:
|
||||
try:
|
||||
tags = MutagenFile(path, easy=True)
|
||||
except Exception:
|
||||
tags = None
|
||||
duration = 0.0
|
||||
artist = album = title = track_number = None
|
||||
if tags is not None:
|
||||
if getattr(tags, "info", None) is not None:
|
||||
duration = getattr(tags.info, "length", 0.0) or 0.0
|
||||
artist = (tags.get("artist") or [None])[0]
|
||||
album = (tags.get("album") or [None])[0]
|
||||
title = (tags.get("title") or [None])[0]
|
||||
tn = (tags.get("tracknumber") or [None])[0]
|
||||
if tn:
|
||||
try:
|
||||
track_number = int(str(tn).split("/")[0])
|
||||
except ValueError:
|
||||
track_number = None
|
||||
return {"duration": duration, "artist": artist, "album": album, "title": title, "track_number": track_number}
|
||||
|
||||
|
||||
def scan_music() -> List[Dict]:
|
||||
"""Music/<Artist>/<Album>/<NN Title.ext>. Filename/path-derived only — no file reads, so this
|
||||
stays fast even over a network share with a large library. Embedded tags (better titles, real
|
||||
duration) are read later, only for the tracks actually selected for import — see tags_for_file().
|
||||
"""
|
||||
if not MUSIC_DIR.is_dir():
|
||||
return []
|
||||
out = []
|
||||
for artist_dir in sorted(MUSIC_DIR.iterdir()):
|
||||
if not artist_dir.is_dir() or artist_dir.name.startswith("@") or artist_dir.name.startswith("#"):
|
||||
continue
|
||||
for album_dir in sorted(artist_dir.iterdir()):
|
||||
if not album_dir.is_dir():
|
||||
continue
|
||||
for f in sorted(album_dir.iterdir()):
|
||||
if not f.is_file() or f.suffix.lower() not in AUDIO_EXTS:
|
||||
continue
|
||||
track_number = None
|
||||
title = f.stem
|
||||
fm = _TRACK_NUM_RE.match(f.stem)
|
||||
if fm:
|
||||
track_number = int(fm.group(1))
|
||||
title = fm.group(2)
|
||||
out.append({
|
||||
"artist": artist_dir.name,
|
||||
"album": album_dir.name,
|
||||
"title": _clean_title(title) or title,
|
||||
"track_number": track_number,
|
||||
"duration_seconds": 0,
|
||||
"storage_path": str(f),
|
||||
"size_bytes": f.stat().st_size,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def tags_for_file(path_str: str) -> Dict:
|
||||
"""Embedded-tag lookup for a single file — used at import time, not during scan."""
|
||||
return _read_tags(Path(path_str))
|
||||
+208
-10
@@ -20,6 +20,11 @@ class UserLogin(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class UserPublic(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str
|
||||
@@ -29,15 +34,21 @@ class UserPublic(BaseModel):
|
||||
created_at: str
|
||||
|
||||
|
||||
# ---------- Profiles ("Who's Watching") ----------
|
||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
||||
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
avatar_color: str = "#D9381E"
|
||||
is_kids: bool = False
|
||||
max_rating: str = "NR" # one of RATING_ORDER
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class AdminUserUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_admin: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- Profile (1:1 with account) ----------
|
||||
RATING_ORDER = ["G", "PG", "PG-13", "R", "NR"]
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
@@ -108,6 +119,29 @@ class Movie(MovieBase):
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Music ----------
|
||||
class TrackUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
artist: Optional[str] = None
|
||||
album: Optional[str] = None
|
||||
track_number: Optional[int] = None
|
||||
album_art_url: Optional[str] = None
|
||||
|
||||
|
||||
class Track(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
title: str
|
||||
artist: str = "Unknown Artist"
|
||||
album: str = "Unknown Album"
|
||||
album_art_url: str = ""
|
||||
track_number: Optional[int] = None
|
||||
duration_seconds: float = 0
|
||||
storage_type: str = "scan" # scan is currently the only source for tracks
|
||||
storage_path: str
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
# ---------- Subtitles ----------
|
||||
class Subtitle(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
@@ -157,6 +191,10 @@ class RequestUpdate(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class RequestApprove(BaseModel):
|
||||
content_type: str # "movie" | "tv"
|
||||
|
||||
|
||||
class MovieRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
@@ -165,7 +203,12 @@ class MovieRequest(BaseModel):
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
notes: str = ""
|
||||
status: str = "pending"
|
||||
status: str = "pending" # pending | approved | fulfilled | rejected
|
||||
content_type: Optional[str] = None # movie | tv, set once approved
|
||||
radarr_id: Optional[int] = None
|
||||
sonarr_id: Optional[int] = None
|
||||
matched_title: Optional[str] = None
|
||||
matched_year: Optional[int] = None
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
@@ -175,15 +218,170 @@ class AppSettings(BaseModel):
|
||||
tmdb_api_key: str = ""
|
||||
radarr_url: str = ""
|
||||
radarr_api_key: str = ""
|
||||
sonarr_url: str = ""
|
||||
sonarr_api_key: str = ""
|
||||
opensubs_api_key: str = ""
|
||||
trakt_client_id: str = ""
|
||||
trakt_client_secret: str = ""
|
||||
auto_transcode: str = "off"
|
||||
queue_paused: bool = False
|
||||
music_itunes_fallback: bool = True
|
||||
music_acoustid_enabled: bool = False
|
||||
acoustid_api_key: str = ""
|
||||
|
||||
|
||||
class AppSettingsPublic(BaseModel):
|
||||
"""Settings visible to admin UI (does not redact, since admin already has access)."""
|
||||
tmdb_configured: bool = False
|
||||
radarr_configured: bool = False
|
||||
sonarr_configured: bool = False
|
||||
opensubs_configured: bool = False
|
||||
trakt_configured: bool = False
|
||||
tmdb_api_key: str = ""
|
||||
radarr_url: str = ""
|
||||
radarr_api_key: str = ""
|
||||
sonarr_url: str = ""
|
||||
sonarr_api_key: str = ""
|
||||
opensubs_api_key: str = ""
|
||||
trakt_client_id: str = ""
|
||||
trakt_client_secret: str = ""
|
||||
auto_transcode: str = "off"
|
||||
queue_paused: bool = False
|
||||
music_itunes_fallback: bool = True
|
||||
music_acoustid_enabled: bool = False
|
||||
acoustid_api_key: str = ""
|
||||
acoustid_configured: bool = False
|
||||
|
||||
|
||||
# ---------- Shows / Episodes (Sonarr) ----------
|
||||
class ShowBase(BaseModel):
|
||||
title: str
|
||||
description: str = ""
|
||||
year: int = 2024
|
||||
rating: str = "NR"
|
||||
genres: List[str] = []
|
||||
cast: List[str] = []
|
||||
creator: str = ""
|
||||
poster_url: str = ""
|
||||
backdrop_url: str = ""
|
||||
featured: bool = False
|
||||
tvdb_id: Optional[int] = None
|
||||
tmdb_id: Optional[int] = None
|
||||
sonarr_id: Optional[int] = None
|
||||
|
||||
|
||||
class Show(ShowBase):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
episode_count: int = 0
|
||||
season_count: int = 0
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
class ShowCreate(ShowBase):
|
||||
pass
|
||||
|
||||
|
||||
class ShowUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
year: Optional[int] = None
|
||||
rating: Optional[str] = None
|
||||
genres: Optional[List[str]] = None
|
||||
poster_url: Optional[str] = None
|
||||
backdrop_url: Optional[str] = None
|
||||
featured: Optional[bool] = None
|
||||
|
||||
|
||||
class EpisodeBase(BaseModel):
|
||||
show_id: str
|
||||
season_number: int
|
||||
episode_number: int
|
||||
title: str
|
||||
description: str = ""
|
||||
duration_minutes: int = 0
|
||||
air_date: str = ""
|
||||
still_url: str = ""
|
||||
storage_type: str = "external" # local | sonarr | external
|
||||
storage_path: Optional[str] = None
|
||||
video_url: str = ""
|
||||
|
||||
|
||||
class Episode(EpisodeBase):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
sonarr_episode_id: Optional[int] = None
|
||||
hidden: bool = False
|
||||
hls_path: Optional[str] = None
|
||||
hls_status: Optional[str] = None
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
|
||||
|
||||
class EpisodeCreate(EpisodeBase):
|
||||
sonarr_episode_id: Optional[int] = None
|
||||
|
||||
|
||||
class EpisodeProgressUpsert(BaseModel):
|
||||
episode_id: str
|
||||
position_seconds: float
|
||||
duration_seconds: float
|
||||
|
||||
|
||||
class SonarrSeriesDTO(BaseModel):
|
||||
sonarr_id: int
|
||||
title: str
|
||||
year: Optional[int] = None
|
||||
overview: str = ""
|
||||
poster_url: str = ""
|
||||
tvdb_id: Optional[int] = None
|
||||
tmdb_id: Optional[int] = None
|
||||
season_count: int = 0
|
||||
episode_file_count: int = 0
|
||||
|
||||
|
||||
# ---------- Trakt ----------
|
||||
class TraktDeviceCodeResponse(BaseModel):
|
||||
device_code: str
|
||||
user_code: str
|
||||
verification_url: str
|
||||
expires_in: int
|
||||
interval: int
|
||||
|
||||
|
||||
class TraktStatus(BaseModel):
|
||||
connected: bool
|
||||
username: str = ""
|
||||
|
||||
|
||||
# ---------- OpenSubtitles ----------
|
||||
class OpenSubsResult(BaseModel):
|
||||
file_id: int
|
||||
language: str
|
||||
label: str = ""
|
||||
release: str = ""
|
||||
fps: float = 0
|
||||
downloads: int = 0
|
||||
|
||||
|
||||
# ---------- Transcode Queue ----------
|
||||
class TranscodeJob(BaseModel):
|
||||
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
|
||||
quality: str = "abr"
|
||||
status: str = "pending"
|
||||
error: str = ""
|
||||
triggered_by: str = "manual"
|
||||
progress: float = 0.0
|
||||
superseded_by: Optional[str] = None
|
||||
created_at: str = Field(default_factory=_now_iso)
|
||||
started_at: Optional[str] = None
|
||||
finished_at: Optional[str] = None
|
||||
|
||||
|
||||
class QueuePauseToggle(BaseModel):
|
||||
paused: bool
|
||||
|
||||
|
||||
# ---------- Auth Tokens ----------
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
"""OpenSubtitles.com REST API v1 client.
|
||||
|
||||
Auth: Api-Key header (from opensubtitles.com/consumer/apps).
|
||||
Endpoints used:
|
||||
GET /api/v1/subtitles?tmdb_id=…&languages=…
|
||||
POST /api/v1/download {file_id} → returns temporary download link
|
||||
"""
|
||||
import requests
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
BASE = "https://api.opensubtitles.com/api/v1"
|
||||
UA = "Kino/1.0"
|
||||
|
||||
|
||||
def _headers(api_key: str) -> dict:
|
||||
return {
|
||||
"Api-Key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": UA,
|
||||
}
|
||||
|
||||
|
||||
def search(api_key: str, tmdb_id: Optional[int] = None, query: Optional[str] = None,
|
||||
languages: str = "en", episode_number: Optional[int] = None,
|
||||
season_number: Optional[int] = None) -> List[Dict]:
|
||||
params = {"languages": languages}
|
||||
if tmdb_id: params["tmdb_id"] = tmdb_id
|
||||
if query: params["query"] = query
|
||||
if season_number is not None: params["season_number"] = season_number
|
||||
if episode_number is not None: params["episode_number"] = episode_number
|
||||
r = requests.get(f"{BASE}/subtitles", params=params, headers=_headers(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for item in (r.json() or {}).get("data", [])[:30]:
|
||||
attrs = item.get("attributes") or {}
|
||||
files = attrs.get("files") or []
|
||||
if not files: continue
|
||||
f = files[0]
|
||||
out.append({
|
||||
"file_id": f.get("file_id"),
|
||||
"language": attrs.get("language", ""),
|
||||
"label": attrs.get("release", "") or f.get("file_name", ""),
|
||||
"release": attrs.get("release", ""),
|
||||
"fps": float(attrs.get("fps") or 0),
|
||||
"downloads": int(attrs.get("download_count") or 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def get_download_link(api_key: str, file_id: int) -> str:
|
||||
r = requests.post(f"{BASE}/download", json={"file_id": int(file_id)}, headers=_headers(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
return (r.json() or {}).get("link", "")
|
||||
|
||||
|
||||
def download_content(link: str) -> str:
|
||||
"""Fetch subtitle file content as text."""
|
||||
r = requests.get(link, timeout=30, headers={"User-Agent": UA})
|
||||
r.raise_for_status()
|
||||
# OpenSubtitles serves .srt files (usually)
|
||||
return r.content.decode("utf-8", errors="ignore")
|
||||
@@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
|
||||
return out
|
||||
|
||||
|
||||
def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||
"""Radarr's own title lookup (proxies TMDB) — used to resolve a free-text request to a real movie."""
|
||||
base_url = base_url.rstrip("/")
|
||||
r = requests.get(f"{base_url}/api/v3/movie/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for m in r.json() or []:
|
||||
poster = ""
|
||||
for img in m.get("images", []) or []:
|
||||
if img.get("coverType") == "poster":
|
||||
poster = img.get("remoteUrl") or img.get("url") or ""
|
||||
break
|
||||
out.append({
|
||||
"tmdb_id": m.get("tmdbId"), "title": m.get("title") or "",
|
||||
"year": m.get("year"), "overview": m.get("overview") or "", "poster_url": poster,
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
|
||||
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
|
||||
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill."""
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||
if not profiles or not roots:
|
||||
raise RuntimeError("Radarr has no quality profile or root folder configured")
|
||||
payload = {
|
||||
"title": title, "tmdbId": tmdb_id, "year": year or 0,
|
||||
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||
"monitored": True, "addOptions": {"searchForMovie": True},
|
||||
}
|
||||
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
|
||||
r.raise_for_status()
|
||||
added = r.json()
|
||||
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||
|
||||
|
||||
def movie_status(base_url: str, api_key: str, radarr_id: int) -> Dict:
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
m = requests.get(f"{base_url}/api/v3/movie/{radarr_id}", headers=h, timeout=15).json()
|
||||
if m.get("hasFile"):
|
||||
return {"state": "downloaded"}
|
||||
q = requests.get(f"{base_url}/api/v3/queue", params={"movieIds": radarr_id}, headers=h, timeout=15).json()
|
||||
items = q.get("records") if isinstance(q, dict) else q
|
||||
if items:
|
||||
it = items[0]
|
||||
size = it.get("size") or 0
|
||||
sizeleft = it.get("sizeleft") or 0
|
||||
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||
return {"state": "searching"}
|
||||
|
||||
|
||||
def test_connection(base_url: str, api_key: str) -> bool:
|
||||
base_url = base_url.rstrip("/")
|
||||
try:
|
||||
|
||||
@@ -24,4 +24,5 @@ numpy>=1.26.0
|
||||
python-multipart>=0.0.9
|
||||
jq>=1.6.0
|
||||
typer>=0.9.0
|
||||
emergentintegrations==0.1.0
|
||||
docker>=7.1.0
|
||||
mutagen>=1.47.0
|
||||
|
||||
+1336
-56
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
||||
"""Docker service status/restart for the internal StreamHoard stack."""
|
||||
import docker
|
||||
|
||||
SERVICES = [
|
||||
{"key": "gluetun", "label": "VPN (gluetun)", "container": "streamhoard-gluetun"},
|
||||
{"key": "sonarr", "label": "Sonarr", "container": "streamhoard-sonarr"},
|
||||
{"key": "radarr", "label": "Radarr", "container": "streamhoard-radarr"},
|
||||
{"key": "prowlarr", "label": "Prowlarr", "container": "streamhoard-prowlarr"},
|
||||
{"key": "qbittorrent", "label": "qBittorrent", "container": "streamhoard-qbittorrent"},
|
||||
{"key": "mongo", "label": "Database", "container": "streamhoard-mongo"},
|
||||
{"key": "portainer", "label": "Portainer", "container": "streamhoard-portainer"},
|
||||
]
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def _get_client():
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = docker.DockerClient(base_url="unix:///var/run/docker.sock")
|
||||
return _client
|
||||
|
||||
|
||||
def list_service_status():
|
||||
client = _get_client()
|
||||
out = []
|
||||
for svc in SERVICES:
|
||||
try:
|
||||
c = client.containers.get(svc["container"])
|
||||
running = c.status == "running"
|
||||
except docker.errors.NotFound:
|
||||
running = False
|
||||
except Exception:
|
||||
running = False
|
||||
out.append({"key": svc["key"], "label": svc["label"], "running": running})
|
||||
return out
|
||||
|
||||
|
||||
def restart_service(key: str) -> bool:
|
||||
svc = next((s for s in SERVICES if s["key"] == key), None)
|
||||
if not svc:
|
||||
return False
|
||||
client = _get_client()
|
||||
c = client.containers.get(svc["container"])
|
||||
c.restart(timeout=15)
|
||||
return True
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Sonarr v3 API client. https://sonarr.tv/docs/api/"""
|
||||
import requests
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
def _h(api_key: str) -> dict:
|
||||
return {"X-Api-Key": api_key, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _poster(images):
|
||||
for img in images or []:
|
||||
if img.get("coverType") == "poster":
|
||||
return img.get("remoteUrl") or img.get("url") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
|
||||
"""Sonarr's own title lookup (proxies TVDB) — used to resolve a free-text request to a real series."""
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for s in r.json() or []:
|
||||
out.append({
|
||||
"tvdb_id": s.get("tvdbId"), "tmdb_id": s.get("tmdbId"), "title": s.get("title") or "",
|
||||
"year": s.get("year"), "overview": s.get("overview") or "", "poster_url": _poster(s.get("images")),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
|
||||
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled."""
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
|
||||
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
|
||||
if not profiles or not roots:
|
||||
raise RuntimeError("Sonarr has no quality profile or root folder configured")
|
||||
payload = {
|
||||
"title": title, "tvdbId": tvdb_id, "year": year or 0,
|
||||
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
|
||||
"seasonFolder": True, "monitored": True,
|
||||
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
|
||||
}
|
||||
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
|
||||
r.raise_for_status()
|
||||
added = r.json()
|
||||
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
|
||||
|
||||
|
||||
def series_status(base_url: str, api_key: str, sonarr_id: int) -> Dict:
|
||||
base_url = base_url.rstrip("/")
|
||||
h = _h(api_key)
|
||||
s = requests.get(f"{base_url}/api/v3/series/{sonarr_id}", headers=h, timeout=15).json()
|
||||
stats = s.get("statistics") or {}
|
||||
if stats.get("episodeFileCount", 0) > 0 and stats.get("episodeFileCount") >= stats.get("episodeCount", 1):
|
||||
return {"state": "downloaded"}
|
||||
q = requests.get(f"{base_url}/api/v3/queue", params={"seriesId": sonarr_id, "includeSeries": False}, headers=h, timeout=15).json()
|
||||
items = q.get("records") if isinstance(q, dict) else q
|
||||
if items:
|
||||
it = items[0]
|
||||
size = it.get("size") or 0
|
||||
sizeleft = it.get("sizeleft") or 0
|
||||
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
|
||||
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
|
||||
if stats.get("episodeFileCount", 0) > 0:
|
||||
return {"state": "partially downloaded"}
|
||||
return {"state": "searching"}
|
||||
|
||||
|
||||
def test_connection(base_url: str, api_key: str) -> bool:
|
||||
try:
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
||||
return r.ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_series(base_url: str, api_key: str) -> List[Dict]:
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series", headers=_h(api_key), timeout=30)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for s in r.json() or []:
|
||||
stats = s.get("statistics") or {}
|
||||
out.append({
|
||||
"sonarr_id": s["id"],
|
||||
"title": s.get("title", ""),
|
||||
"year": s.get("year"),
|
||||
"overview": s.get("overview") or "",
|
||||
"poster_url": _poster(s.get("images")),
|
||||
"tvdb_id": s.get("tvdbId"),
|
||||
"tmdb_id": s.get("tmdbId"),
|
||||
"season_count": stats.get("seasonCount", 0),
|
||||
"episode_file_count": stats.get("episodeFileCount", 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def list_episodes(base_url: str, api_key: str, series_id: int) -> List[Dict]:
|
||||
r = requests.get(
|
||||
f"{base_url.rstrip('/')}/api/v3/episode",
|
||||
params={"seriesId": series_id, "includeEpisodeFile": True},
|
||||
headers=_h(api_key), timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for e in r.json() or []:
|
||||
ef = e.get("episodeFile") or {}
|
||||
out.append({
|
||||
"sonarr_episode_id": e["id"],
|
||||
"season_number": e.get("seasonNumber", 0),
|
||||
"episode_number": e.get("episodeNumber", 0),
|
||||
"title": e.get("title", ""),
|
||||
"description": e.get("overview") or "",
|
||||
"air_date": (e.get("airDate") or ""),
|
||||
"duration_minutes": int((ef.get("runTime") or 0) / 60) if ef.get("runTime") else 0,
|
||||
"has_file": bool(e.get("hasFile")),
|
||||
"file_path": ef.get("path") if ef else None,
|
||||
})
|
||||
return out
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Backend pytest suite for Kino Personal Media Server.
|
||||
Backend pytest suite for StreamHoard Personal Media Server.
|
||||
Tests auth, movies, watchlist, progress, requests, and stream auth.
|
||||
"""
|
||||
import os
|
||||
@@ -8,8 +8,8 @@ import uuid
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "https://streamhoard.preview.emergentagent.com").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "http://localhost:8001").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def admin_token(api):
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def member_token(api):
|
||||
email = f"TEST_user_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_user_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Test User"})
|
||||
assert r.status_code == 200, f"register failed: {r.text}"
|
||||
data = r.json()
|
||||
@@ -73,7 +73,7 @@ class TestAuth:
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_register_and_me(self, api):
|
||||
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_reg_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "Reg User"})
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
@@ -86,7 +86,7 @@ class TestAuth:
|
||||
assert r2.json()["email"] == email.lower()
|
||||
|
||||
def test_register_duplicate(self, api):
|
||||
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_dup_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
r = api.post(f"{BASE_URL}/api/auth/register", json={"email": email, "password": "pass1234", "name": "U"})
|
||||
assert r.status_code == 400
|
||||
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("REACT_APP_BACKEND_URL", "").rstrip("/")
|
||||
ADMIN_EMAIL = "admin@kino.local"
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def admin_headers(admin_token):
|
||||
@pytest.fixture(scope="module")
|
||||
def member(api):
|
||||
"""Create a fresh member for profile-scoped tests."""
|
||||
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@kino.local"
|
||||
email = f"TEST_p2_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "P2 User"})
|
||||
assert r.status_code == 200
|
||||
@@ -101,7 +101,7 @@ class TestProfiles:
|
||||
|
||||
def test_cannot_delete_last_profile(self, api):
|
||||
# fresh user with single profile
|
||||
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@kino.local"
|
||||
email = f"TEST_solo_{uuid.uuid4().hex[:6]}@streamhoard.local"
|
||||
d = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "Solo"}).json()
|
||||
h = {"Authorization": f"Bearer {d['access_token']}"}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""Phase 4: Transcode Queue tests.
|
||||
|
||||
Covers:
|
||||
- Queue stats & list (admin only)
|
||||
- Settings persistence for auto_transcode + queue_paused
|
||||
- Manual + auto enqueue
|
||||
- Worker FIFO processing
|
||||
- Cancel/Retry/Clear/Pause flows
|
||||
- 409 conflict guard
|
||||
- Pause idle behavior
|
||||
- Crash-recovery code path (running -> failed) by direct DB insert + restart
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ["REACT_APP_BACKEND_URL"].rstrip("/")
|
||||
ADMIN_EMAIL = "admin@streamhoard.local"
|
||||
ADMIN_PASSWORD = "kino-admin-2026"
|
||||
QTEST_VIDEO = "/tmp/qtest.mp4"
|
||||
|
||||
|
||||
# ---------- Fixtures ----------
|
||||
@pytest.fixture(scope="session")
|
||||
def api():
|
||||
s = requests.Session()
|
||||
return s
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def admin_token(api):
|
||||
r = api.post(f"{BASE_URL}/api/auth/login",
|
||||
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
|
||||
headers={"Content-Type": "application/json"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def member_token(api):
|
||||
email = f"TEST_q_{uuid.uuid4().hex[:8]}@streamhoard.local"
|
||||
r = api.post(f"{BASE_URL}/api/auth/register",
|
||||
json={"email": email, "password": "pass1234", "name": "QTest"},
|
||||
headers={"Content-Type": "application/json"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(admin_token):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def member_headers(member_token):
|
||||
return {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
|
||||
def _set_settings(api, admin_headers, **kwargs):
|
||||
"""Helper: PUT /api/settings preserving existing keys, override with kwargs."""
|
||||
cur = api.get(f"{BASE_URL}/api/settings", headers=admin_headers).json()
|
||||
body = {
|
||||
"tmdb_api_key": cur.get("tmdb_api_key", ""),
|
||||
"radarr_url": cur.get("radarr_url", ""),
|
||||
"radarr_api_key": cur.get("radarr_api_key", ""),
|
||||
"auto_transcode": cur.get("auto_transcode", "off"),
|
||||
"queue_paused": cur.get("queue_paused", False),
|
||||
}
|
||||
body.update(kwargs)
|
||||
r = api.put(f"{BASE_URL}/api/settings", json=body,
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _upload_movie(api, admin_headers, title):
|
||||
with open(QTEST_VIDEO, "rb") as f:
|
||||
r = api.post(
|
||||
f"{BASE_URL}/api/upload/video",
|
||||
data={"title": title, "year": 2024, "rating": "NR", "genres": "Test"},
|
||||
files={"file": (f"{title}.mp4", f, "video/mp4")},
|
||||
headers=admin_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
# Cleanup at end of module
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _cleanup(request):
|
||||
yield
|
||||
# best-effort: reset settings & clear queue via admin
|
||||
try:
|
||||
s = requests.Session()
|
||||
tok = s.post(f"{BASE_URL}/api/auth/login",
|
||||
json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD},
|
||||
headers={"Content-Type": "application/json"}).json()["access_token"]
|
||||
h = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}
|
||||
s.put(f"{BASE_URL}/api/settings", json={
|
||||
"tmdb_api_key": "", "radarr_url": "", "radarr_api_key": "",
|
||||
"auto_transcode": "off", "queue_paused": False,
|
||||
}, headers=h)
|
||||
s.post(f"{BASE_URL}/api/transcode/queue/clear", headers=h)
|
||||
# delete TEST_ movies created
|
||||
movies = s.get(f"{BASE_URL}/api/movies?limit=500", headers=h).json()
|
||||
for m in movies:
|
||||
if m.get("title", "").startswith("TEST_Q_"):
|
||||
s.delete(f"{BASE_URL}/api/movies/{m['id']}", headers=h)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- Tests ----------
|
||||
class TestQueueAuth:
|
||||
def test_queue_list_admin_only(self, api, member_headers):
|
||||
r = api.get(f"{BASE_URL}/api/transcode/queue", headers=member_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_queue_stats_admin_only(self, api, member_headers):
|
||||
r = api.get(f"{BASE_URL}/api/transcode/queue/stats", headers=member_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestQueueStatsAndSettings:
|
||||
def test_stats_shape(self, api, admin_headers):
|
||||
r = api.get(f"{BASE_URL}/api/transcode/queue/stats", headers=admin_headers)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
for k in ("pending", "running", "done", "failed", "cancelled", "paused", "auto_transcode"):
|
||||
assert k in d, f"missing {k}"
|
||||
assert isinstance(d["paused"], bool)
|
||||
|
||||
def test_settings_persists_auto_and_paused(self, api, admin_headers):
|
||||
d = _set_settings(api, admin_headers, auto_transcode="quick", queue_paused=False)
|
||||
assert d["auto_transcode"] == "quick"
|
||||
assert d["queue_paused"] is False
|
||||
# GET to verify persistence
|
||||
g = api.get(f"{BASE_URL}/api/settings", headers=admin_headers).json()
|
||||
assert g["auto_transcode"] == "quick"
|
||||
# reset
|
||||
_set_settings(api, admin_headers, auto_transcode="off")
|
||||
|
||||
|
||||
class TestManualEnqueueAndWorker:
|
||||
def test_upload_with_auto_off_no_job(self, api, admin_headers):
|
||||
_set_settings(api, admin_headers, auto_transcode="off", queue_paused=False)
|
||||
api.post(f"{BASE_URL}/api/transcode/queue/clear", headers=admin_headers)
|
||||
movie = _upload_movie(api, admin_headers, "TEST_Q_off")
|
||||
time.sleep(1)
|
||||
# No job for this movie
|
||||
rows = api.get(f"{BASE_URL}/api/transcode/queue", headers=admin_headers).json()
|
||||
assert not any(j["movie_id"] == movie["id"] for j in rows)
|
||||
# cleanup
|
||||
api.delete(f"{BASE_URL}/api/movies/{movie['id']}", headers=admin_headers)
|
||||
|
||||
def test_manual_transcode_and_worker_runs(self, api, admin_headers):
|
||||
_set_settings(api, admin_headers, auto_transcode="off", queue_paused=False)
|
||||
movie = _upload_movie(api, admin_headers, "TEST_Q_manual")
|
||||
mid = movie["id"]
|
||||
r = api.post(f"{BASE_URL}/api/movies/{mid}/transcode",
|
||||
json={"quality": "quick"},
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "pending"
|
||||
assert "job_id" in body
|
||||
# movie hls_status pending
|
||||
m = api.get(f"{BASE_URL}/api/movies/{mid}").json()
|
||||
assert m["hls_status"] in ("pending", "running", "done")
|
||||
|
||||
# 409 conflict when re-triggering
|
||||
r2 = api.post(f"{BASE_URL}/api/movies/{mid}/transcode",
|
||||
json={"quality": "quick"},
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
assert r2.status_code == 409
|
||||
|
||||
# Wait for completion (worker polls every 8s; quick transcode of 3s clip <30s)
|
||||
deadline = time.time() + 60
|
||||
final = None
|
||||
while time.time() < deadline:
|
||||
m = api.get(f"{BASE_URL}/api/movies/{mid}").json()
|
||||
if m.get("hls_status") in ("done", "failed"):
|
||||
final = m["hls_status"]
|
||||
break
|
||||
time.sleep(2)
|
||||
assert final == "done", f"hls did not complete; status={final}"
|
||||
|
||||
rows = api.get(f"{BASE_URL}/api/transcode/queue?status=done",
|
||||
headers=admin_headers).json()
|
||||
assert any(j["movie_id"] == mid and j["status"] == "done" for j in rows)
|
||||
# cleanup
|
||||
api.delete(f"{BASE_URL}/api/movies/{mid}", headers=admin_headers)
|
||||
|
||||
def test_retry_done_job_returns_400(self, api, admin_headers):
|
||||
rows = api.get(f"{BASE_URL}/api/transcode/queue?status=done",
|
||||
headers=admin_headers).json()
|
||||
if not rows:
|
||||
pytest.skip("No done jobs available for retry-400 test")
|
||||
r = api.post(f"{BASE_URL}/api/transcode/queue/{rows[0]['id']}/retry",
|
||||
headers=admin_headers)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
class TestAutoEnqueue:
|
||||
def test_auto_quick_enqueues_on_upload(self, api, admin_headers):
|
||||
_set_settings(api, admin_headers, auto_transcode="quick", queue_paused=True)
|
||||
# paused so the job stays pending
|
||||
movie = _upload_movie(api, admin_headers, "TEST_Q_auto")
|
||||
mid = movie["id"]
|
||||
time.sleep(1)
|
||||
rows = api.get(f"{BASE_URL}/api/transcode/queue", headers=admin_headers).json()
|
||||
match = [j for j in rows if j["movie_id"] == mid]
|
||||
assert match, "Auto-enqueue did not create a job"
|
||||
job = match[0]
|
||||
assert job["quality"] == "quick"
|
||||
assert job["triggered_by"] == "auto"
|
||||
# While paused, should remain pending (worker polls every 15s when paused)
|
||||
time.sleep(3)
|
||||
rows2 = api.get(f"{BASE_URL}/api/transcode/queue", headers=admin_headers).json()
|
||||
j2 = next(j for j in rows2 if j["movie_id"] == mid)
|
||||
assert j2["status"] == "pending", f"Job should stay pending while paused, got {j2['status']}"
|
||||
|
||||
# Cancel the pending job
|
||||
c = api.delete(f"{BASE_URL}/api/transcode/queue/{job['id']}", headers=admin_headers)
|
||||
assert c.status_code == 200
|
||||
# status -> cancelled, movie hls_status reset
|
||||
rows3 = api.get(f"{BASE_URL}/api/transcode/queue", headers=admin_headers).json()
|
||||
j3 = next(j for j in rows3 if j["id"] == job["id"])
|
||||
assert j3["status"] == "cancelled"
|
||||
m = api.get(f"{BASE_URL}/api/movies/{mid}").json()
|
||||
assert m.get("hls_status") in (None, "")
|
||||
|
||||
# Retry cancelled job creates new pending job
|
||||
rt = api.post(f"{BASE_URL}/api/transcode/queue/{job['id']}/retry",
|
||||
headers=admin_headers)
|
||||
assert rt.status_code == 200, rt.text
|
||||
new_id = rt.json()["job_id"]
|
||||
assert new_id != job["id"]
|
||||
|
||||
# Cancel again to clean up
|
||||
api.delete(f"{BASE_URL}/api/transcode/queue/{new_id}", headers=admin_headers)
|
||||
|
||||
# Reset settings
|
||||
_set_settings(api, admin_headers, auto_transcode="off", queue_paused=False)
|
||||
api.delete(f"{BASE_URL}/api/movies/{mid}", headers=admin_headers)
|
||||
|
||||
|
||||
class TestCancelRunningRejected:
|
||||
def test_cancel_running_returns_400(self, api, admin_headers):
|
||||
# Inject a fake "running" row directly via... we don't have DB access here.
|
||||
# Instead, attempt an actual run: upload, manual transcode, poll for running
|
||||
_set_settings(api, admin_headers, auto_transcode="off", queue_paused=False)
|
||||
movie = _upload_movie(api, admin_headers, "TEST_Q_running")
|
||||
mid = movie["id"]
|
||||
r = api.post(f"{BASE_URL}/api/movies/{mid}/transcode",
|
||||
json={"quality": "quick"},
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
assert r.status_code == 200
|
||||
job_id = r.json()["job_id"]
|
||||
|
||||
# Poll for running status (worker idles up to 8s)
|
||||
deadline = time.time() + 30
|
||||
saw_running = False
|
||||
while time.time() < deadline:
|
||||
rows = api.get(f"{BASE_URL}/api/transcode/queue", headers=admin_headers).json()
|
||||
j = next((x for x in rows if x["id"] == job_id), None)
|
||||
if j and j["status"] == "running":
|
||||
saw_running = True
|
||||
# Try to cancel -> 400
|
||||
c = api.delete(f"{BASE_URL}/api/transcode/queue/{job_id}", headers=admin_headers)
|
||||
assert c.status_code == 400
|
||||
break
|
||||
if j and j["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
# Wait for completion before cleanup
|
||||
deadline = time.time() + 60
|
||||
while time.time() < deadline:
|
||||
m = api.get(f"{BASE_URL}/api/movies/{mid}").json()
|
||||
if m.get("hls_status") in ("done", "failed"):
|
||||
break
|
||||
time.sleep(2)
|
||||
api.delete(f"{BASE_URL}/api/movies/{mid}", headers=admin_headers)
|
||||
if not saw_running:
|
||||
pytest.skip("Could not catch a 'running' job in time window — non-deterministic")
|
||||
|
||||
|
||||
class TestClearFinished:
|
||||
def test_clear_removes_finished(self, api, admin_headers):
|
||||
before = api.get(f"{BASE_URL}/api/transcode/queue/stats",
|
||||
headers=admin_headers).json()
|
||||
r = api.post(f"{BASE_URL}/api/transcode/queue/clear", headers=admin_headers)
|
||||
assert r.status_code == 200
|
||||
d = r.json()
|
||||
assert "deleted" in d
|
||||
# after clear, done/failed/cancelled = 0
|
||||
after = api.get(f"{BASE_URL}/api/transcode/queue/stats",
|
||||
headers=admin_headers).json()
|
||||
assert after["done"] == 0
|
||||
assert after["failed"] == 0
|
||||
assert after["cancelled"] == 0
|
||||
|
||||
|
||||
class TestPauseToggle:
|
||||
def test_pause_endpoint_sets_flag(self, api, admin_headers):
|
||||
r = api.post(f"{BASE_URL}/api/transcode/queue/pause",
|
||||
json={"paused": True},
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["paused"] is True
|
||||
s = api.get(f"{BASE_URL}/api/transcode/queue/stats",
|
||||
headers=admin_headers).json()
|
||||
assert s["paused"] is True
|
||||
# reset
|
||||
api.post(f"{BASE_URL}/api/transcode/queue/pause",
|
||||
json={"paused": False},
|
||||
headers={**admin_headers, "Content-Type": "application/json"})
|
||||
|
||||
|
||||
class TestNicePrefix:
|
||||
def test_transcode_module_uses_nice(self):
|
||||
"""Code-level: ffmpeg commands prefixed with `nice -n 19`."""
|
||||
src = open("/app/backend/transcode.py").read()
|
||||
assert "\"nice\", \"-n\", \"19\"" in src
|
||||
# appears in both quick and abr
|
||||
assert src.count("\"nice\", \"-n\", \"19\"") >= 2
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Trakt.tv API client.
|
||||
|
||||
Uses OAuth 2.0 device code flow:
|
||||
1. POST /oauth/device/code with client_id → get device_code + user_code + verification_url
|
||||
2. User visits trakt.tv/activate, enters user_code
|
||||
3. Poll POST /oauth/device/token with device_code until user approves → access_token + refresh_token
|
||||
4. Use access_token as Bearer for all subsequent calls.
|
||||
|
||||
Scrobble: POST /scrobble/{start|pause|stop} with movie/episode + progress (0-100).
|
||||
"""
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
|
||||
BASE = "https://api.trakt.tv"
|
||||
|
||||
|
||||
def _headers(client_id: str, access_token: Optional[str] = None) -> dict:
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"trakt-api-version": "2",
|
||||
"trakt-api-key": client_id,
|
||||
}
|
||||
if access_token:
|
||||
h["Authorization"] = f"Bearer {access_token}"
|
||||
return h
|
||||
|
||||
|
||||
def device_code(client_id: str) -> Dict:
|
||||
r = requests.post(f"{BASE}/oauth/device/code", json={"client_id": client_id}, timeout=15)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
return {
|
||||
"device_code": d["device_code"],
|
||||
"user_code": d["user_code"],
|
||||
"verification_url": d["verification_url"],
|
||||
"expires_in": d["expires_in"],
|
||||
"interval": d["interval"],
|
||||
}
|
||||
|
||||
|
||||
def poll_token(client_id: str, client_secret: str, device_code_val: str) -> Optional[Dict]:
|
||||
"""Returns access_token dict on success, None if still pending, raises on error."""
|
||||
r = requests.post(f"{BASE}/oauth/device/token", json={
|
||||
"code": device_code_val,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
}, timeout=15)
|
||||
if r.status_code == 200:
|
||||
return r.json() # {access_token, refresh_token, expires_in, created_at, ...}
|
||||
if r.status_code in (400, 404): # pending / expired
|
||||
return None
|
||||
r.raise_for_status()
|
||||
return None
|
||||
|
||||
|
||||
def refresh(client_id: str, client_secret: str, refresh_token: str) -> Dict:
|
||||
r = requests.post(f"{BASE}/oauth/token", json={
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"grant_type": "refresh_token",
|
||||
}, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def user_settings(client_id: str, access_token: str) -> Dict:
|
||||
r = requests.get(f"{BASE}/users/settings", headers=_headers(client_id, access_token), timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _scrobble(action: str, client_id: str, access_token: str, payload: dict):
|
||||
r = requests.post(f"{BASE}/scrobble/{action}",
|
||||
headers=_headers(client_id, access_token),
|
||||
json=payload, timeout=15)
|
||||
if r.status_code in (409, 429): # duplicate / rate-limit
|
||||
return None
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def scrobble_movie(client_id: str, access_token: str, tmdb_id: int, progress: float, action: str = "stop"):
|
||||
return _scrobble(action, client_id, access_token, {
|
||||
"movie": {"ids": {"tmdb": int(tmdb_id)}},
|
||||
"progress": max(0.0, min(100.0, float(progress))),
|
||||
})
|
||||
|
||||
|
||||
def scrobble_episode(client_id: str, access_token: str, tvdb_id: Optional[int], tmdb_id: Optional[int],
|
||||
season: int, episode: int, progress: float, action: str = "stop"):
|
||||
ids = {}
|
||||
if tvdb_id: ids["tvdb"] = int(tvdb_id)
|
||||
if tmdb_id: ids["tmdb"] = int(tmdb_id)
|
||||
if not ids:
|
||||
return None
|
||||
return _scrobble(action, client_id, access_token, {
|
||||
"show": {"ids": ids},
|
||||
"episode": {"season": int(season), "number": int(episode)},
|
||||
"progress": max(0.0, min(100.0, float(progress))),
|
||||
})
|
||||
+105
-10
@@ -13,6 +13,11 @@ from typing import Optional, List, Tuple, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger("kino.transcode")
|
||||
|
||||
# Moderate priority (was 19, the absolute lowest) and a thread cap so an ABR encode can't
|
||||
# starve the rest of the stack (Mongo, *arr apps, the API itself) on this 8-core host.
|
||||
TRANSCODE_NICE = 10
|
||||
TRANSCODE_THREADS = 6
|
||||
|
||||
|
||||
async def probe_video(source: Path) -> Tuple[int, int]:
|
||||
"""Return (width, height) using ffprobe; (0, 0) on failure."""
|
||||
@@ -32,6 +37,23 @@ async def probe_video(source: Path) -> Tuple[int, int]:
|
||||
return (0, 0)
|
||||
|
||||
|
||||
async def probe_duration(source: Path) -> float:
|
||||
"""Return source duration in seconds using ffprobe; 0 on failure."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe", "-v", "error", "-show_entries", "format=duration",
|
||||
"-of", "json", str(source),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return 0.0
|
||||
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
|
||||
return float((data.get("format") or {}).get("duration") or 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
|
||||
ALL_VARIANTS = [
|
||||
(1080, "5000k", "5500k", "7500k", "192k"),
|
||||
@@ -42,13 +64,38 @@ ALL_VARIANTS = [
|
||||
|
||||
|
||||
def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]:
|
||||
"""Pick variants ≤ source height. Always include at least one (smallest)."""
|
||||
"""Pick up to 2 variants ≤ source height (the two highest applicable renditions —
|
||||
ALL_VARIANTS is ordered highest-to-lowest, so slicing keeps the best pair). Capped at 2
|
||||
instead of the full ladder to roughly halve ABR encode time on this host's aging CPU, while
|
||||
still giving a real high/low adaptive-bitrate pair for most connections."""
|
||||
if src_height <= 0:
|
||||
return [ALL_VARIANTS[2]] # safe default 480p
|
||||
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
|
||||
if not chosen:
|
||||
chosen = [ALL_VARIANTS[-1]]
|
||||
return chosen
|
||||
return chosen[:2]
|
||||
|
||||
|
||||
async def transcode_audio_fix(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
"""Video stream-copied untouched, audio re-encoded to AAC — fixes browsers' inability to
|
||||
play AC3/DTS/EAC3 (common on BDRips) without paying for a full video re-encode. Same
|
||||
H.264-source assumption as transcode_quick; HEVC/x265 sources still need the full ABR path
|
||||
since the video itself isn't browser-playable either in that case."""
|
||||
if not source.is_file():
|
||||
await on_status("failed", error=f"Source missing: {source}")
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"nice", "-n", str(TRANSCODE_NICE),
|
||||
"ffmpeg", "-y", "-i", str(source),
|
||||
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ac", "2",
|
||||
"-bsf:v", "h264_mp4toannexb",
|
||||
"-f", "hls", "-hls_time", "6",
|
||||
"-hls_list_size", "0", "-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(out_dir / "playlist.m3u8"),
|
||||
]
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
|
||||
|
||||
|
||||
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
@@ -58,6 +105,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"nice", "-n", str(TRANSCODE_NICE),
|
||||
"ffmpeg", "-y", "-i", str(source),
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-bsf:v", "h264_mp4toannexb",
|
||||
@@ -66,7 +114,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(out_dir / "playlist.m3u8"),
|
||||
]
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8")
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8", source)
|
||||
|
||||
|
||||
async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
@@ -87,13 +135,19 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
|
||||
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
|
||||
filter_complex = ";".join(fc_parts)
|
||||
|
||||
cmd: List[str] = ["ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex]
|
||||
# Per-variant thread count, not the full cap each — x264 threads are set per encoder
|
||||
# instance, so giving every one of the (up to 2) variants the full TRANSCODE_THREADS
|
||||
# budget lets them all run near-full-tilt simultaneously and still saturate the host.
|
||||
per_variant_threads = max(1, TRANSCODE_THREADS // n)
|
||||
|
||||
cmd: List[str] = ["nice", "-n", str(TRANSCODE_NICE), "ffmpeg", "-y", "-threads", str(per_variant_threads), "-i", str(source), "-filter_complex", filter_complex]
|
||||
|
||||
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
|
||||
cmd += [
|
||||
"-map", f"[v{i}out]",
|
||||
f"-c:v:{i}", "libx264",
|
||||
f"-preset:v:{i}", "veryfast",
|
||||
f"-preset:v:{i}", "superfast",
|
||||
f"-threads:v:{i}", str(per_variant_threads),
|
||||
f"-profile:v:{i}", "main",
|
||||
f"-pix_fmt:v:{i}", "yuv420p",
|
||||
f"-b:v:{i}", vb,
|
||||
@@ -124,23 +178,64 @@ async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Aw
|
||||
for i in range(n):
|
||||
(out_dir / f"v{i}").mkdir(exist_ok=True)
|
||||
|
||||
await _run(cmd, on_status, out_dir, "master.m3u8")
|
||||
await _run(cmd, on_status, out_dir, "master.m3u8", source)
|
||||
|
||||
|
||||
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None:
|
||||
await on_status("running")
|
||||
def _parse_out_time_seconds(line: str) -> Optional[float]:
|
||||
"""Parse an `out_time=HH:MM:SS.microseconds` line from ffmpeg's -progress output."""
|
||||
_, _, value = line.partition("=")
|
||||
value = value.strip()
|
||||
if not value or value == "N/A":
|
||||
return None
|
||||
try:
|
||||
h, m, s = value.split(":")
|
||||
return int(h) * 3600 + int(m) * 60 + float(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str, source: Optional[Path] = None) -> None:
|
||||
duration = await probe_duration(source) if source else 0.0
|
||||
await on_status("running", progress=0.0)
|
||||
# Machine-readable progress on stdout, separate from ffmpeg's normal stderr logging.
|
||||
# Insert right after the "ffmpeg" token, not cmd[0] — cmd is prefixed with `nice -n N`,
|
||||
# and these flags belong to ffmpeg, not to nice.
|
||||
ffmpeg_idx = cmd.index("ffmpeg")
|
||||
cmd = cmd[:ffmpeg_idx + 1] + ["-progress", "pipe:1", "-nostats"] + cmd[ffmpeg_idx + 1:]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_stdout, stderr = await proc.communicate()
|
||||
|
||||
stderr_chunks: List[bytes] = []
|
||||
|
||||
async def _drain_stderr():
|
||||
async for line in proc.stderr:
|
||||
stderr_chunks.append(line)
|
||||
|
||||
stderr_task = asyncio.create_task(_drain_stderr())
|
||||
|
||||
last_reported = -1.0
|
||||
async for raw in proc.stdout:
|
||||
line = raw.decode("utf-8", errors="ignore").strip()
|
||||
if line.startswith("out_time=") and duration > 0:
|
||||
seconds = _parse_out_time_seconds(line)
|
||||
if seconds is not None:
|
||||
pct = round(min(99.0, seconds / duration * 100), 1)
|
||||
if pct != last_reported:
|
||||
last_reported = pct
|
||||
await on_status("running", progress=pct)
|
||||
|
||||
await proc.wait()
|
||||
await stderr_task
|
||||
stderr = b"".join(stderr_chunks)
|
||||
if proc.returncode != 0:
|
||||
err = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
logger.error(f"ffmpeg failed: {err}")
|
||||
await on_status("failed", error=err[:200])
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
return
|
||||
await on_status("done", entry=entry_filename)
|
||||
await on_status("done", entry=entry_filename, progress=100.0)
|
||||
except FileNotFoundError:
|
||||
await on_status("failed", error="ffmpeg not installed")
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# StreamHoard — Docker Compose deployment
|
||||
#
|
||||
# Runs alongside existing LAMP stacks without conflicts:
|
||||
# - Apache keeps port 80/443
|
||||
# - MySQL is untouched (StreamHoard uses its own MongoDB in a container)
|
||||
# - StreamHoard exposes a single configurable port (default 8080)
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.compose.example .env
|
||||
# # edit .env — set JWT_SECRET, ADMIN_PASSWORD, KINO_HTTP_PORT, MEDIA_ROOT_HOST
|
||||
# docker compose up -d
|
||||
# # visit http://<proxmox-ip>:8080 (or whatever KINO_HTTP_PORT you set)
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:4.4
|
||||
container_name: streamhoard-mongo
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
networks:
|
||||
- streamhoard
|
||||
# No ports exposed to host — only reachable inside streamhoard network
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: streamhoard-backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- mongo
|
||||
environment:
|
||||
MONGO_URL: mongodb://mongo:27017
|
||||
DB_NAME: ${DB_NAME:-kino}
|
||||
CORS_ORIGINS: "*"
|
||||
JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env}
|
||||
JWT_ALG: HS256
|
||||
JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-720}
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@streamhoard.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
||||
ADMIN_NAME: ${ADMIN_NAME:-Admin}
|
||||
MEDIA_ROOT: /media
|
||||
volumes:
|
||||
# Host media directory (movies, HLS, subtitles) — point at your NAS/ZFS mount
|
||||
- ${MEDIA_ROOT_HOST:-./media}:/media
|
||||
# Radarr/Sonarr report absolute file paths under this same path — must match exactly
|
||||
- ${MEDIA_NAS_ROOT:-/mnt/nas/video}:/mnt/nas/video:ro
|
||||
# NAS music library — used by the admin Library Scan (movies/tv/music), read-only
|
||||
- ${MEDIA_NAS_MUSIC_ROOT:-/mnt/nas/music}:/mnt/nas/music:ro
|
||||
# Docker socket — lets the admin Services panel check/restart sibling containers
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- streamhoard
|
||||
# No direct host port — frontend container reverse-proxies /api → backend:8001
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: streamhoard-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
# Only StreamHoard's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
|
||||
- "${KINO_HTTP_PORT:-8080}:80"
|
||||
networks:
|
||||
- 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:
|
||||
streamhoard:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mongo-data:
|
||||
driver: local
|
||||
portainer-data:
|
||||
driver: local
|
||||
@@ -0,0 +1,29 @@
|
||||
# Kino frontend — React build + nginx reverse proxy
|
||||
# Multi-stage: build with Node, serve with nginx
|
||||
|
||||
# ---------- Build stage ----------
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# When BACKEND_URL is empty, frontend uses relative /api which nginx below proxies.
|
||||
# This keeps auth cookies / CORS trivial and works behind any reverse proxy.
|
||||
ENV REACT_APP_BACKEND_URL=""
|
||||
|
||||
COPY package.json yarn.lock* ./
|
||||
RUN yarn install --frozen-lockfile --network-timeout 600000
|
||||
|
||||
COPY . .
|
||||
RUN yarn build
|
||||
|
||||
# ---------- Serve stage ----------
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
# Custom config: SPA fallback + /api reverse proxy + large uploads + streaming timeouts
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Static build
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -2,10 +2,6 @@
|
||||
const path = require("path");
|
||||
require("dotenv").config();
|
||||
|
||||
// Check if we're in development/preview mode (not production build)
|
||||
// Craco sets NODE_ENV=development for start, NODE_ENV=production for build
|
||||
const isDevServer = process.env.NODE_ENV !== "production";
|
||||
|
||||
// Environment variable overrides
|
||||
const config = {
|
||||
enableHealthCheck: process.env.ENABLE_HEALTH_CHECK === "true",
|
||||
@@ -81,20 +77,4 @@ webpackConfig.devServer = (devServerConfig) => {
|
||||
return devServerConfig;
|
||||
};
|
||||
|
||||
// Wrap with visual edits (automatically adds babel plugin, dev server, and overlay in dev mode)
|
||||
if (isDevServer) {
|
||||
try {
|
||||
const { withVisualEdits } = require("@emergentbase/visual-edits/craco");
|
||||
webpackConfig = withVisualEdits(webpackConfig);
|
||||
} catch (err) {
|
||||
if (err.code === 'MODULE_NOT_FOUND' && err.message.includes('@emergentbase/visual-edits/craco')) {
|
||||
console.warn(
|
||||
"[visual-edits] @emergentbase/visual-edits not installed — visual editing disabled."
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = webpackConfig;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Allow multi-GB movie uploads
|
||||
client_max_body_size 20g;
|
||||
|
||||
# Long timeouts for streaming + transcode responses
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_connect_timeout 60s;
|
||||
|
||||
# gzip static
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Reverse proxy /api → backend service on the compose network
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Streaming needs unbuffered responses for Range requests
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# SPA static files with client-side routing fallback
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets aggressively (hashed filenames from CRA)
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,6 @@
|
||||
"devDependencies": {
|
||||
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@emergentbase/visual-edits": "https://assets.emergent.sh/npm/emergentbase-visual-edits-1.0.8.tgz",
|
||||
"@eslint/js": "9.23.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "9.23.0",
|
||||
|
||||
+2
-119
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="A product of emergent.sh" />
|
||||
<meta name="description" content="StreamHoard - self-hosted personal media server" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@600&display=swap" rel="stylesheet" />
|
||||
@@ -21,9 +21,8 @@
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Emergent | Fullstack App</title>
|
||||
<title>StreamHoard</title>
|
||||
<script>window.addEventListener("error",function(e){if(e.error instanceof DOMException&&e.error.name==="DataCloneError"&&e.message&&e.message.includes("PerformanceServerTiming")){e.stopImmediatePropagation();e.preventDefault()}},true);</script>
|
||||
<script src="https://assets.emergent.sh/scripts/emergent-main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
@@ -38,121 +37,5 @@
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<a
|
||||
id="emergent-badge"
|
||||
target="_blank"
|
||||
href="https://app.emergent.sh/?utm_source=emergent-badge"
|
||||
style="
|
||||
display: inline-flex !important;
|
||||
box-sizing: border-box;
|
||||
width: 178px;
|
||||
height: 40px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
align-items: center !important;
|
||||
gap: 8px;
|
||||
border-radius: 50px !important;
|
||||
background: #000 !important;
|
||||
position: fixed !important;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
text-decoration: none;
|
||||
font-family: -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Open Sans", "Helvetica Neue",
|
||||
sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
z-index: 9999 !important;
|
||||
"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M15.5702 8.13142C15.7729 8.0412 16.0007 8.18878 15.9892 8.4103C15.8374 11.3192 14.0965 14.0405 11.2531 15.3065C8.40964 16.5725 5.2224 16.0453 2.95912 14.2117C2.78676 14.072 2.82955 13.804 3.03219 13.7137L4.95677 12.8568C5.04866 12.8159 5.15446 12.823 5.24204 12.8725C6.73377 13.7153 8.59176 13.8649 10.2772 13.1145C11.9626 12.3641 13.0947 10.8833 13.4665 9.21075C13.4883 9.11256 13.5539 9.02918 13.6457 8.98827L15.5702 8.13142Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3066 4.74698L15.5067 5.19653C15.5759 5.35178 15.5061 5.53366 15.3508 5.60278L1.29992 11.8586C1.14467 11.9278 0.962794 11.8579 0.893675 11.7027L0.701732 11.2716L0.693457 11.2531C-1.10317 7.21778 0.711626 2.49007 4.74692 0.693443C8.78221 -1.10318 13.51 0.711693 15.3066 4.74698ZM2.82356 8.55367C2.63552 8.63739 2.41991 8.51617 2.40853 8.31065C2.28373 6.05724 3.53858 3.85787 5.72286 2.88536C7.90715 1.91286 10.3813 2.45199 11.9724 4.05256C12.1175 4.19854 12.0633 4.43988 11.8753 4.5236L2.82356 8.55367Z" fill="white"/>
|
||||
</svg>
|
||||
<p
|
||||
style="
|
||||
color: #FFF !important;
|
||||
font-family: 'Inter', sans-serif !important;
|
||||
font-size: 13px !important;
|
||||
font-style: normal !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 20px !important;
|
||||
margin: 0 !important;
|
||||
white-space: nowrap !important;
|
||||
"
|
||||
>
|
||||
Made with Emergent
|
||||
</p>
|
||||
</a>
|
||||
<script>
|
||||
!(function (t, e) {
|
||||
var o, n, p, r;
|
||||
e.__SV ||
|
||||
((window.posthog = e),
|
||||
(e._i = []),
|
||||
(e.init = function (i, s, a) {
|
||||
function g(t, e) {
|
||||
var o = e.split(".");
|
||||
2 == o.length && ((t = t[o[0]]), (e = o[1])),
|
||||
(t[e] = function () {
|
||||
t.push(
|
||||
[e].concat(
|
||||
Array.prototype.slice.call(
|
||||
arguments,
|
||||
0,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
((p = t.createElement("script")).type =
|
||||
"text/javascript"),
|
||||
(p.crossOrigin = "anonymous"),
|
||||
(p.async = !0),
|
||||
(p.src =
|
||||
s.api_host.replace(
|
||||
".i.posthog.com",
|
||||
"-assets.i.posthog.com",
|
||||
) + "/static/array.js"),
|
||||
(r =
|
||||
t.getElementsByTagName(
|
||||
"script",
|
||||
)[0]).parentNode.insertBefore(p, r);
|
||||
var u = e;
|
||||
for (
|
||||
void 0 !== a ? (u = e[a] = []) : (a = "posthog"),
|
||||
u.people = u.people || [],
|
||||
u.toString = function (t) {
|
||||
var e = "posthog";
|
||||
return (
|
||||
"posthog" !== a && (e += "." + a),
|
||||
t || (e += " (stub)"),
|
||||
e
|
||||
);
|
||||
},
|
||||
u.people.toString = function () {
|
||||
return u.toString(1) + ".people (stub)";
|
||||
},
|
||||
o =
|
||||
"init me ws ys ps bs capture je Di ks register register_once register_for_session unregister unregister_for_session Ps getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSurveysLoaded onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey canRenderSurveyAsync identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty Es $s createPersonProfile Is opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing Ss debug xs getPageViewId captureTraceFeedback captureTraceMetric".split(
|
||||
" ",
|
||||
),
|
||||
n = 0;
|
||||
n < o.length;
|
||||
n++
|
||||
)
|
||||
g(u, o[n]);
|
||||
e._i.push([i, s, a]);
|
||||
}),
|
||||
(e.__SV = 1));
|
||||
})(document, window.posthog || []);
|
||||
posthog.init("phc_xAvL2Iq4tFmANRE7kzbKwaSqp1HJjN7x48s3vr0CMjs", {
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only", // or 'always' to create profiles for anonymous users as well,
|
||||
session_recording: {
|
||||
recordCrossOriginIframes: true,
|
||||
capturePerformance: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+33
-9
@@ -2,9 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-route
|
||||
import { Toaster } from "sonner";
|
||||
import { AuthProvider, useAuth } from "./lib/auth";
|
||||
import { ProfileProvider, useProfile } from "./lib/profile";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { MusicPlayerProvider } from "./lib/musicPlayer";
|
||||
import Navbar from "./components/Navbar";
|
||||
import GrainOverlay from "./components/GrainOverlay";
|
||||
import ServiceStatus from "./components/ServiceStatus";
|
||||
import MusicPlayerBar from "./components/MusicPlayerBar";
|
||||
import Login from "./pages/Login";
|
||||
import Register from "./pages/Register";
|
||||
import Browse from "./pages/Browse";
|
||||
@@ -16,17 +18,26 @@ import Admin from "./pages/Admin";
|
||||
import AdminUpload from "./pages/AdminUpload";
|
||||
import Settings from "./pages/Settings";
|
||||
import RadarrImport from "./pages/RadarrImport";
|
||||
import ProfileSelect from "./pages/ProfileSelect";
|
||||
import TranscodeQueue from "./pages/TranscodeQueue";
|
||||
import Shows from "./pages/Shows";
|
||||
import ShowDetail from "./pages/ShowDetail";
|
||||
import EpisodePlayer from "./pages/EpisodePlayer";
|
||||
import SonarrImport from "./pages/SonarrImport";
|
||||
import AdminShowEpisodes from "./pages/AdminShowEpisodes";
|
||||
import Users from "./pages/Users";
|
||||
import Music from "./pages/Music";
|
||||
import AlbumDetail from "./pages/AlbumDetail";
|
||||
import LibraryScan from "./pages/LibraryScan";
|
||||
import LibraryCleanup from "./pages/LibraryCleanup";
|
||||
|
||||
const ProfileGate = ({ children }) => {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { active, loading: profLoading } = useProfile();
|
||||
const loc = useLocation();
|
||||
if (authLoading || profLoading) {
|
||||
if (authLoading || profLoading || (user && !active)) {
|
||||
return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
}
|
||||
if (!user) return <Navigate to="/login" state={{ from: loc.pathname }} replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
@@ -42,11 +53,13 @@ const Shell = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const { active } = useProfile();
|
||||
const loc = useLocation();
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register" || loc.pathname === "/profile";
|
||||
const hideChrome = loc.pathname.startsWith("/watch") || loc.pathname === "/login" || loc.pathname === "/register";
|
||||
return (
|
||||
<>
|
||||
{!hideChrome && user && active && <Navbar />}
|
||||
{!hideChrome && user?.is_admin && active && <ServiceStatus />}
|
||||
{children}
|
||||
{!hideChrome && user && active && <MusicPlayerBar />}
|
||||
<GrainOverlay />
|
||||
</>
|
||||
);
|
||||
@@ -54,10 +67,9 @@ const Shell = ({ children }) => {
|
||||
|
||||
const RootRedirect = () => {
|
||||
const { user, loading } = useAuth();
|
||||
const { active, loading: pl } = useProfile();
|
||||
const { loading: pl } = useProfile();
|
||||
if (loading || pl) return <div className="min-h-screen flex items-center justify-center bg-[#050505] text-[#8A8A8A]">Loading…</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (!active) return <Navigate to="/profile" replace />;
|
||||
return <Navigate to="/browse" replace />;
|
||||
};
|
||||
|
||||
@@ -67,27 +79,39 @@ function App() {
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<ProfileProvider>
|
||||
<MusicPlayerProvider>
|
||||
<Shell>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
|
||||
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
|
||||
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
|
||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||
<Route path="/requests" element={<ProfileGate><Requests /></ProfileGate>} />
|
||||
<Route path="/settings" element={<ProfileGate><Settings /></ProfileGate>} />
|
||||
<Route path="/music" element={<ProfileGate><Music /></ProfileGate>} />
|
||||
<Route path="/music/album/:artist/:album" element={<ProfileGate><AlbumDetail /></ProfileGate>} />
|
||||
<Route path="/admin" element={<AdminGate><Admin /></AdminGate>} />
|
||||
<Route path="/admin/users" element={<AdminGate><Users /></AdminGate>} />
|
||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
||||
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
|
||||
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||
<Route path="/admin/scan" element={<AdminGate><LibraryScan /></AdminGate>} />
|
||||
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
|
||||
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Shell>
|
||||
<Toaster position="bottom-right" theme="dark" toastOptions={{
|
||||
style: { background: "#0F0F0F", border: "1px solid #222", color: "#F2F2F2" },
|
||||
}} />
|
||||
</MusicPlayerProvider>
|
||||
</ProfileProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Shows real album art when a URL is available; otherwise a branded placeholder instead of an
|
||||
// empty box. Once musicbrainz enrichment (or a manual edit) sets album_art_url, callers just
|
||||
// pass the new url and this swaps over automatically — no separate "loaded" state to manage.
|
||||
export default function AlbumArt({ url, size = "lg", className = "", loading }) {
|
||||
if (url) {
|
||||
return <img src={url} alt="" loading={loading} className={`w-full h-full object-cover ${className}`} />;
|
||||
}
|
||||
const small = size === "sm";
|
||||
return (
|
||||
<div className={`w-full h-full flex items-center justify-center bg-gradient-to-br from-[#161616] to-[#0a0a0a] ${className}`}>
|
||||
{small ? (
|
||||
<span className="font-display font-black text-[#3a3a3a] text-lg select-none">S<span className="text-[#D9381E]">.</span></span>
|
||||
) : (
|
||||
<span className="font-display font-black tracking-tighter text-[#3a3a3a] text-sm sm:text-base select-none text-center px-2">
|
||||
Stream<span className="text-[#D9381E]">hoard</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
||||
|
||||
<div className="relative h-full max-w-[1500px] mx-auto px-6 md:px-12 flex flex-col justify-end pb-24 md:pb-32 fade-up">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-4" data-testid="hero-eyebrow">
|
||||
Featured
|
||||
In the Spotlight
|
||||
</span>
|
||||
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
|
||||
data-testid="hero-title">
|
||||
@@ -51,7 +51,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
||||
data-testid="hero-play-button"
|
||||
>
|
||||
<Play size={18} fill="white" strokeWidth={0} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Watch</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onMore?.(movie)}
|
||||
@@ -59,7 +59,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
||||
data-testid="hero-more-info-button"
|
||||
>
|
||||
<Info size={18} strokeWidth={1.5} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">More Info</span>
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Details</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onAddList?.(movie)}
|
||||
@@ -67,7 +67,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
||||
data-testid="hero-add-list-button"
|
||||
>
|
||||
<Plus size={18} strokeWidth={1.5} />
|
||||
<span className="text-xs uppercase tracking-[0.2em]">My List</span>
|
||||
<span className="text-xs uppercase tracking-[0.2em]">Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Play } from "lucide-react";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
export const MovieCard = ({ movie, onClick, progress }) => {
|
||||
const pct = progress?.duration_seconds
|
||||
@@ -10,13 +11,7 @@ export const MovieCard = ({ movie, onClick, progress }) => {
|
||||
className="group relative shrink-0 w-[180px] md:w-[220px] aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-all duration-300 hover:scale-105 hover:z-10 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`movie-card-${movie.id}`}
|
||||
>
|
||||
<img
|
||||
src={movie.poster_url || movie.backdrop_url}
|
||||
alt={movie.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => { e.currentTarget.style.opacity = 0.3; }}
|
||||
/>
|
||||
<AlbumArt url={movie.poster_url || movie.backdrop_url} loading="lazy" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/30 to-transparent opacity-90 md:opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 translate-y-2 md:translate-y-0 md:opacity-0 group-hover:opacity-100 group-hover:translate-y-0 transition-all duration-300">
|
||||
<h3 className="font-display text-lg font-bold tracking-tight text-white leading-none line-clamp-2">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => {
|
||||
const nav = useNavigate();
|
||||
@@ -30,11 +31,11 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
||||
if (inList) {
|
||||
await api.delete(`/watchlist/${movie.id}`);
|
||||
setInList(false);
|
||||
toast.success("Removed from My List");
|
||||
toast.success("Removed from the shelf");
|
||||
} else {
|
||||
await api.post(`/watchlist/${movie.id}`);
|
||||
setInList(true);
|
||||
toast.success("Added to My List");
|
||||
toast.success("Saved to your shelf");
|
||||
}
|
||||
onWatchlistChange?.();
|
||||
} catch {
|
||||
@@ -64,11 +65,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
||||
</button>
|
||||
|
||||
<div className="relative h-[280px] md:h-[440px]">
|
||||
<img
|
||||
src={movie.backdrop_url || movie.poster_url}
|
||||
alt={movie.title}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
<AlbumArt url={movie.backdrop_url || movie.poster_url} className="absolute inset-0" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0A0A0A] via-[#0A0A0A]/40 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-10">
|
||||
<h2 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white" data-testid="modal-movie-title">
|
||||
@@ -87,7 +84,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
||||
data-testid="modal-play-button"
|
||||
>
|
||||
<Play size={16} fill="white" strokeWidth={0} />
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
|
||||
<span className="text-sm uppercase tracking-[0.2em] font-medium">Watch</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggle}
|
||||
@@ -95,7 +92,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
||||
data-testid="modal-watchlist-button"
|
||||
>
|
||||
{inList ? <Check size={16} strokeWidth={1.5} /> : <Plus size={16} strokeWidth={1.5} />}
|
||||
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "In My List" : "My List"}</span>
|
||||
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "On the Shelf" : "Save to Shelf"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Play, Pause, SkipBack, SkipForward, Speaker } from "lucide-react";
|
||||
import { useMusicPlayer } from "../lib/musicPlayer";
|
||||
import AlbumArt from "./AlbumArt";
|
||||
|
||||
const fmt = (s) => {
|
||||
if (!s || !isFinite(s)) return "0:00";
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60).toString().padStart(2, "0");
|
||||
return `${m}:${sec}`;
|
||||
};
|
||||
|
||||
export default function MusicPlayerBar() {
|
||||
const {
|
||||
current, isPlaying, progress, togglePlay, next, prev, seek, hasNext, hasPrev,
|
||||
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
|
||||
} = useMusicPlayer();
|
||||
const [showOutputs, setShowOutputs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (showOutputs) refreshOutputDevices();
|
||||
}, [showOutputs, refreshOutputDevices]);
|
||||
|
||||
if (!current) return null;
|
||||
|
||||
const pct = progress.duration ? (progress.position / progress.duration) * 100 : 0;
|
||||
|
||||
const onSeek = (e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = (e.clientX - rect.left) / rect.width;
|
||||
seek(ratio * (progress.duration || 0));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-[#0a0a0a]/95 backdrop-blur border-t border-[#222] px-4 md:px-8 py-3" data-testid="music-player-bar">
|
||||
<div className="max-w-[1500px] mx-auto flex items-center gap-4">
|
||||
<div className="w-10 h-10 shrink-0 bg-[#0F0F0F] overflow-hidden">
|
||||
<AlbumArt url={current.album_art_url} size="sm" />
|
||||
</div>
|
||||
<div className="min-w-0 w-40 md:w-56">
|
||||
<p className="text-white text-sm truncate">{current.title}</p>
|
||||
<p className="text-[#8A8A8A] text-xs truncate">{current.artist}</p>
|
||||
</div>
|
||||
<button onClick={prev} disabled={!hasPrev} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-prev">
|
||||
<SkipBack size={16} />
|
||||
</button>
|
||||
<button onClick={togglePlay} className="w-8 h-8 flex items-center justify-center bg-[#D9381E] hover:bg-[#ED4B32] text-white" data-testid="music-play-pause">
|
||||
{isPlaying ? <Pause size={14} fill="currentColor" /> : <Play size={14} fill="currentColor" />}
|
||||
</button>
|
||||
<button onClick={next} disabled={!hasNext} className="text-[#8A8A8A] hover:text-white disabled:opacity-30" data-testid="music-next">
|
||||
<SkipForward size={16} />
|
||||
</button>
|
||||
<span className="text-[10px] text-[#8A8A8A] w-9 text-right shrink-0">{fmt(progress.position)}</span>
|
||||
<div className="flex-1 h-1 bg-white/10 cursor-pointer hidden sm:block" onClick={onSeek} data-testid="music-seek">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-[10px] text-[#8A8A8A] w-9 shrink-0">{fmt(progress.duration)}</span>
|
||||
|
||||
{sinkSupported && (
|
||||
<div className="relative shrink-0">
|
||||
<button onClick={() => setShowOutputs((v) => !v)} className="text-[#8A8A8A] hover:text-white" title="Output device" data-testid="music-output-toggle">
|
||||
<Speaker size={16} />
|
||||
</button>
|
||||
{showOutputs && (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-56 bg-[#0F0F0F] border border-[#222] py-1" data-testid="music-output-list">
|
||||
{outputDevices.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-[#8A8A8A]">No output devices found</p>
|
||||
)}
|
||||
{outputDevices.map((d) => (
|
||||
<button key={d.deviceId} onClick={() => { selectOutput(d.deviceId); setShowOutputs(false); }}
|
||||
className={`w-full text-left px-3 py-2 text-xs truncate ${sinkId === d.deviceId ? "text-[#D9381E]" : "text-white hover:bg-white/5"}`}
|
||||
data-testid={`music-output-${d.deviceId}`}>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { Link, NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { useProfile } from "../lib/profile";
|
||||
import { Search, LogOut, Upload, Users, Settings as SettingsIcon } from "lucide-react";
|
||||
import { Search, LogOut, Upload, Settings as SettingsIcon, Menu, X } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../lib/api";
|
||||
|
||||
export const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { active, clearActive } = useProfile();
|
||||
const { active } = useProfile();
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [version, setVersion] = useState("");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 20);
|
||||
@@ -16,27 +20,35 @@ export const Navbar = () => {
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/version").then(({ data }) => setVersion(data.version)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Close the mobile menu on navigation so it never lingers open over the next page.
|
||||
useEffect(() => { setMobileOpen(false); }, [location.pathname]);
|
||||
|
||||
const linkClass = ({ isActive }) =>
|
||||
`text-sm tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A] hover:text-white"}`;
|
||||
|
||||
const switchProfile = () => {
|
||||
clearActive();
|
||||
nav("/profile");
|
||||
};
|
||||
const mobileLinkClass = ({ isActive }) =>
|
||||
`block py-3 text-base tracking-wide transition-colors duration-300 ${isActive ? "text-white" : "text-[#8A8A8A]"}`;
|
||||
|
||||
return (
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled ? "glass border-b" : ""}`} data-testid="main-navbar">
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${scrolled || mobileOpen ? "glass border-b" : ""}`} data-testid="main-navbar">
|
||||
<div className="px-6 md:px-12 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-10">
|
||||
<Link to="/browse" className="flex items-center gap-2" data-testid="nav-logo">
|
||||
<span className="font-display text-2xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-2xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-2xl leading-none">.</span>
|
||||
{version && <span className="text-[10px] text-[#8A8A8A] self-start mt-1" data-testid="nav-version">v{version}</span>}
|
||||
</Link>
|
||||
{user && (
|
||||
<nav className="hidden md:flex items-center gap-7">
|
||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
|
||||
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
|
||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</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="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
|
||||
<NavLink to="/music" className={linkClass} data-testid="nav-music">Music</NavLink>
|
||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
|
||||
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
|
||||
</nav>
|
||||
)}
|
||||
@@ -48,39 +60,60 @@ export const Navbar = () => {
|
||||
<Search size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
{user.is_admin && (
|
||||
<>
|
||||
<button onClick={() => nav("/admin/upload")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-upload-button">
|
||||
<Upload size={14} strokeWidth={1.5} /> Upload
|
||||
</button>
|
||||
<button onClick={() => nav("/admin/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
)}
|
||||
<button onClick={() => nav("/settings")} className="hidden sm:flex items-center gap-2 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-settings-button">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-3 pl-4 border-l border-[#222]">
|
||||
<button onClick={switchProfile} className="flex items-center gap-2 group" data-testid="nav-switch-profile" title="Switch profile">
|
||||
{active && (
|
||||
<>
|
||||
<div className="hidden sm:flex flex-col items-end leading-tight">
|
||||
<span className="text-xs text-white">{active.name}</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] group-hover:text-white transition-colors">switch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" data-testid="account-avatar">
|
||||
<span className="hidden sm:inline text-xs text-white">{active.name}</span>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-white text-xs font-medium" style={{ backgroundColor: active.avatar_color || "#D9381E" }}>
|
||||
{active.name?.[0]?.toUpperCase() || "U"}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{!active && <Users size={18} strokeWidth={1.5} className="text-[#8A8A8A]" />}
|
||||
</button>
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="hidden md:block text-[#8A8A8A] hover:text-white transition-colors duration-300" data-testid="nav-logout-button" aria-label="Logout">
|
||||
<LogOut size={16} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
className="md:hidden text-white -mr-2 p-2"
|
||||
data-testid="nav-mobile-toggle"
|
||||
aria-label={mobileOpen ? "Close menu" : "Open menu"}
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
{mobileOpen ? <X size={22} strokeWidth={1.5} /> : <Menu size={22} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link to="/login" className="text-sm tracking-wide bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 transition-colors duration-300" data-testid="nav-sign-in">Sign in</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user && mobileOpen && (
|
||||
<nav className="md:hidden glass border-t border-[#222] px-6 pb-6" data-testid="nav-mobile-menu">
|
||||
<NavLink to="/browse" className={mobileLinkClass} data-testid="nav-mobile-browse">Library</NavLink>
|
||||
<NavLink to="/shows" className={mobileLinkClass} data-testid="nav-mobile-shows">Series</NavLink>
|
||||
<NavLink to="/my-list" className={mobileLinkClass} data-testid="nav-mobile-my-list">Shelf</NavLink>
|
||||
<NavLink to="/music" className={mobileLinkClass} data-testid="nav-mobile-music">Music</NavLink>
|
||||
<NavLink to="/requests" className={mobileLinkClass} data-testid="nav-mobile-requests">Wishlist</NavLink>
|
||||
{user.is_admin && <NavLink to="/admin" className={mobileLinkClass} data-testid="nav-mobile-admin">Admin</NavLink>}
|
||||
<div className="mt-2 pt-3 border-t border-[#222] flex flex-col gap-1">
|
||||
{user.is_admin && (
|
||||
<NavLink to="/admin/upload" className={mobileLinkClass} data-testid="nav-mobile-upload">Upload</NavLink>
|
||||
)}
|
||||
<NavLink to="/settings" className={mobileLinkClass} data-testid="nav-mobile-settings">Settings</NavLink>
|
||||
<button onClick={() => { logout(); nav("/login"); }} className="text-left py-3 text-base tracking-wide text-[#8A8A8A]" data-testid="nav-mobile-logout">
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "../lib/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center text-[#8A8A8A]" data-testid="auth-loading">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
if (adminOnly && !user.is_admin) return <Navigate to="/browse" replace />;
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCw } from "lucide-react";
|
||||
|
||||
export default function ServiceStatus() {
|
||||
const [services, setServices] = useState([]);
|
||||
const [restarting, setRestarting] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get("/services");
|
||||
setServices(data);
|
||||
} catch {
|
||||
setServices([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 15000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
const restart = async (key, label) => {
|
||||
setRestarting(key);
|
||||
try {
|
||||
await api.post(`/services/${key}/restart`);
|
||||
toast.success(`${label} restarting…`);
|
||||
setTimeout(load, 4000);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || `Could not restart ${label}`);
|
||||
} finally {
|
||||
setRestarting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!services.length) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-16 left-0 right-0 z-40 bg-[#0a0a0a]/95 backdrop-blur border-b border-[#222] px-6 md:px-12 py-2"
|
||||
data-testid="service-status"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{services.map((s) => (
|
||||
<div
|
||||
key={s.key}
|
||||
className="flex items-center gap-2 bg-[#0F0F0F] border border-[#222] px-3 py-1.5 text-xs"
|
||||
data-testid={`service-${s.key}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block w-2 h-2 rounded-full ${s.running ? "bg-[#86efac]" : "bg-[#fca5a5]"}`}
|
||||
/>
|
||||
<span className="text-[#8A8A8A]">{s.label}</span>
|
||||
{!s.running && (
|
||||
<button
|
||||
onClick={() => restart(s.key, s.label)}
|
||||
disabled={restarting === s.key}
|
||||
className="ml-1 flex items-center gap-1 text-[#D9381E] hover:text-[#ED4B32] disabled:opacity-50"
|
||||
data-testid={`service-restart-${s.key}`}
|
||||
>
|
||||
<RotateCw size={12} className={restarting === s.key ? "animate-spin" : ""} />
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,12 +8,6 @@ const instance = axios.create({ baseURL: API });
|
||||
instance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("kino_token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
// Active profile id (set by ProfileProvider via localStorage)
|
||||
const userId = JSON.parse(localStorage.getItem("kino_user_id") || "null");
|
||||
if (userId) {
|
||||
const pid = localStorage.getItem(`kino_profile_${userId}`);
|
||||
if (pid) config.headers["X-Profile-Id"] = pid;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
@@ -25,7 +19,7 @@ export const getStreamUrl = (movie) => {
|
||||
if (movie.hls_status === "done" && movie.hls_path) {
|
||||
return `${API}/movies/${movie.id}/hls/${movie.hls_path}?auth=${encodeURIComponent(token)}`;
|
||||
}
|
||||
if (movie.storage_type === "local" || movie.storage_type === "radarr") {
|
||||
if (movie.storage_type === "local" || movie.storage_type === "radarr" || movie.storage_type === "scan") {
|
||||
return `${API}/stream/${movie.id}?auth=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return movie.video_url;
|
||||
@@ -37,3 +31,9 @@ export const getSubtitleUrl = (sub) => {
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
return `${API}/subtitles/${sub.id}/file?auth=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
export const getTrackStreamUrl = (track) => {
|
||||
if (!track) return "";
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
return `${API}/stream/track/${track.id}?auth=${encodeURIComponent(token)}`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createContext, useContext, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { getTrackStreamUrl } from "./api";
|
||||
|
||||
const MusicPlayerCtx = createContext(null);
|
||||
|
||||
export const MusicPlayerProvider = ({ children }) => {
|
||||
const audioRef = useRef(new Audio());
|
||||
const [queue, setQueue] = useState([]);
|
||||
const [index, setIndex] = useState(-1);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState({ position: 0, duration: 0 });
|
||||
const [outputDevices, setOutputDevices] = useState([]);
|
||||
const [sinkId, setSinkId] = useState("default");
|
||||
const sinkSupported = typeof audioRef.current.setSinkId === "function";
|
||||
|
||||
const current = index >= 0 ? queue[index] : null;
|
||||
|
||||
const refreshOutputDevices = useCallback(async () => {
|
||||
if (!navigator.mediaDevices?.enumerateDevices) return;
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const outs = devices.filter((d) => d.kind === "audiooutput");
|
||||
setOutputDevices(outs.map((d, i) => ({ deviceId: d.deviceId, label: d.label || `Output ${i + 1}` })));
|
||||
} catch { /* enumeration unsupported/denied — picker just won't populate */ }
|
||||
}, []);
|
||||
|
||||
const selectOutput = useCallback(async (deviceId) => {
|
||||
if (!sinkSupported) return;
|
||||
try {
|
||||
await audioRef.current.setSinkId(deviceId);
|
||||
setSinkId(deviceId);
|
||||
} catch { /* device may have disappeared (e.g. BT speaker turned off) — keep current output */ }
|
||||
}, [sinkSupported]);
|
||||
|
||||
const playAt = useCallback((newQueue, startIndex) => {
|
||||
setQueue(newQueue);
|
||||
setIndex(startIndex);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!current) { audio.pause(); return; }
|
||||
audio.src = getTrackStreamUrl(current);
|
||||
audio.play().catch(() => {}); // isPlaying reflects reality via the native play/pause listeners below
|
||||
}, [current]);
|
||||
|
||||
// isPlaying always mirrors the audio element's own state, so it stays correct through anything
|
||||
// that changes playback outside togglePlay — a stall, an OS media-key pause, autoplay being blocked.
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
const onTime = () => setProgress({ position: audio.currentTime, duration: audio.duration || 0 });
|
||||
const onEnded = () => {
|
||||
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
audio.addEventListener("timeupdate", onTime);
|
||||
audio.addEventListener("ended", onEnded);
|
||||
audio.addEventListener("play", onPlay);
|
||||
audio.addEventListener("pause", onPause);
|
||||
return () => {
|
||||
audio.removeEventListener("timeupdate", onTime);
|
||||
audio.removeEventListener("ended", onEnded);
|
||||
audio.removeEventListener("play", onPlay);
|
||||
audio.removeEventListener("pause", onPause);
|
||||
};
|
||||
}, [index, queue]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!current) return;
|
||||
if (audio.paused) audio.play().catch(() => {});
|
||||
else audio.pause();
|
||||
}, [current]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
if (index >= 0 && index < queue.length - 1) setIndex(index + 1);
|
||||
}, [index, queue]);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
if (index > 0) setIndex(index - 1);
|
||||
}, [index]);
|
||||
|
||||
const seek = useCallback((seconds) => {
|
||||
audioRef.current.currentTime = seconds;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MusicPlayerCtx.Provider value={{
|
||||
current, isPlaying, progress, playAt, togglePlay, next, prev, seek,
|
||||
hasNext: index < queue.length - 1, hasPrev: index > 0,
|
||||
outputDevices, sinkId, sinkSupported, refreshOutputDevices, selectOutput,
|
||||
}}>
|
||||
{children}
|
||||
</MusicPlayerCtx.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMusicPlayer = () => useContext(MusicPlayerCtx);
|
||||
@@ -6,19 +6,15 @@ const ProfileCtx = createContext(null);
|
||||
|
||||
export const ProfileProvider = ({ children }) => {
|
||||
const { user } = useAuth();
|
||||
const [profiles, setProfiles] = useState([]);
|
||||
const [active, setActive] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!user) { setProfiles([]); setActive(null); return; }
|
||||
if (!user) { setActive(null); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get("/profiles");
|
||||
setProfiles(data);
|
||||
const stored = localStorage.getItem(`kino_profile_${user.id}`);
|
||||
const found = data.find((p) => p.id === stored);
|
||||
setActive(found || null);
|
||||
setActive(data[0] || null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -26,20 +22,8 @@ export const ProfileProvider = ({ children }) => {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
const switchTo = (p) => {
|
||||
if (!user) return;
|
||||
localStorage.setItem(`kino_profile_${user.id}`, p.id);
|
||||
setActive(p);
|
||||
};
|
||||
|
||||
const clearActive = () => {
|
||||
if (!user) return;
|
||||
localStorage.removeItem(`kino_profile_${user.id}`);
|
||||
setActive(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ProfileCtx.Provider value={{ profiles, active, loading, refresh, switchTo, clearActive }}>
|
||||
<ProfileCtx.Provider value={{ active, loading, refresh }}>
|
||||
{children}
|
||||
</ProfileCtx.Provider>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw } from "lucide-react";
|
||||
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
|
||||
export default function Admin() {
|
||||
const nav = useNavigate();
|
||||
const [movies, setMovies] = useState([]);
|
||||
const [shows, setShows] = useState([]);
|
||||
const [transcoding, setTranscoding] = useState({});
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/movies");
|
||||
setMovies(data);
|
||||
const [{ data: m }, { data: s }] = await Promise.all([
|
||||
api.get("/movies"),
|
||||
api.get("/shows").catch(() => ({ data: [] })),
|
||||
]);
|
||||
setMovies(m); setShows(s);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
@@ -81,8 +85,17 @@ export default function Admin() {
|
||||
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
|
||||
<Download size={14} strokeWidth={1.5} /> Radarr Import
|
||||
</Link>
|
||||
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
|
||||
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
|
||||
</Link>
|
||||
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
|
||||
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
|
||||
</Link>
|
||||
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
|
||||
</Link>
|
||||
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
|
||||
<SettingsIcon size={14} strokeWidth={1.5} /> Users
|
||||
</Link>
|
||||
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
|
||||
Review Requests
|
||||
@@ -109,12 +122,16 @@ export default function Admin() {
|
||||
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
|
||||
<span className="col-span-2">{hlsStatusBadge(m)}</span>
|
||||
<div className="col-span-2 flex justify-end gap-2">
|
||||
{(m.storage_type === "local" || m.storage_type === "radarr") && m.hls_status !== "running" && m.hls_status !== "pending" && (
|
||||
{(m.storage_type === "local" || m.storage_type === "radarr" || m.storage_type === "scan") && m.hls_status !== "running" && m.hls_status !== "pending" && (
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
|
||||
title="Quick transcode (stream-copy, single bitrate)"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
data-testid={`transcode-quick-${m.id}`}>Quick</button>
|
||||
<button onClick={() => startTranscode(m, "audio")} disabled={transcoding[m.id]}
|
||||
title="Fix audio only (video untouched, audio re-encoded to AAC) — fast, fixes no-sound from AC3/DTS/EAC3"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
data-testid={`transcode-audio-${m.id}`}>Audio</button>
|
||||
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
|
||||
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
|
||||
@@ -131,6 +148,35 @@ export default function Admin() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{shows.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Series</h2>
|
||||
<div className="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-6">Title</span>
|
||||
<span className="col-span-2">Year</span>
|
||||
<span className="col-span-2">Episodes</span>
|
||||
<span className="col-span-2 text-right">Actions</span>
|
||||
</div>
|
||||
{shows.map((s) => (
|
||||
<div key={s.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={`admin-show-row-${s.id}`}>
|
||||
<div className="col-span-6 flex items-center gap-3 min-w-0">
|
||||
<img src={s.poster_url} alt="" className="w-10 h-14 object-cover" />
|
||||
<span className="text-white truncate">{s.title}</span>
|
||||
</div>
|
||||
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.year}</span>
|
||||
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.season_count}s · {s.episode_count}e</span>
|
||||
<div className="col-span-2 flex justify-end">
|
||||
<a href={`/admin/shows/${s.id}/episodes`} className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 transition-colors" data-testid={`manage-episodes-${s.id}`}>
|
||||
Manage Episodes
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, EyeOff, Eye, Trash2, Zap, CheckSquare, Square } from "lucide-react";
|
||||
|
||||
export default function AdminShowEpisodes() {
|
||||
const { id } = useParams();
|
||||
const [show, setShow] = useState(null);
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [season, setSeason] = useState("all");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [{ data: s }, { data: e }] = await Promise.all([
|
||||
api.get(`/shows/${id}`),
|
||||
api.get(`/shows/${id}/episodes?include_hidden=true`),
|
||||
]);
|
||||
setShow(s); setEpisodes(e);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, [id]);
|
||||
|
||||
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
|
||||
const visible = useMemo(() => season === "all" ? episodes : episodes.filter((e) => e.season_number === Number(season)), [episodes, season]);
|
||||
|
||||
const toggle = (eid) => {
|
||||
const s = new Set(selected); s.has(eid) ? s.delete(eid) : s.add(eid); setSelected(s);
|
||||
};
|
||||
const toggleAll = () => {
|
||||
if (visible.every((e) => selected.has(e.id))) {
|
||||
const s = new Set(selected); visible.forEach((e) => s.delete(e.id)); setSelected(s);
|
||||
} else {
|
||||
const s = new Set(selected); visible.forEach((e) => s.add(e.id)); setSelected(s);
|
||||
}
|
||||
};
|
||||
const selectSeason = (n) => {
|
||||
const s = new Set(selected);
|
||||
episodes.filter((e) => e.season_number === n).forEach((e) => s.add(e.id));
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const bulk = async (action) => {
|
||||
if (selected.size === 0) return;
|
||||
const ids = Array.from(selected);
|
||||
try {
|
||||
let msg = "";
|
||||
if (action === "transcode-abr") {
|
||||
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "abr" });
|
||||
msg = `${data.queued} queued for ABR transcode`;
|
||||
} else if (action === "transcode-quick") {
|
||||
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "quick" });
|
||||
msg = `${data.queued} queued for Quick transcode`;
|
||||
} else if (action === "hide") {
|
||||
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: true });
|
||||
msg = `${data.modified} hidden`;
|
||||
} else if (action === "unhide") {
|
||||
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
|
||||
msg = `${data.modified} unhidden`;
|
||||
} else if (action === "delete") {
|
||||
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but StreamHoard entries and HLS output are removed.`)) return;
|
||||
const { data } = await api.post("/episodes/bulk-delete", { episode_ids: ids });
|
||||
msg = `${data.deleted} deleted`;
|
||||
}
|
||||
toast.success(msg);
|
||||
setSelected(new Set()); load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Bulk action failed");
|
||||
}
|
||||
};
|
||||
|
||||
const statusChip = (ep) => {
|
||||
const s = ep.hls_status;
|
||||
const map = { pending: "text-[#fcd34d]", running: "text-[#fcd34d]", done: "text-[#86efac]", failed: "text-[#fca5a5]" };
|
||||
if (!s) return null;
|
||||
return <span className={`text-[9px] uppercase tracking-[0.2em] ${map[s] || "text-[#8A8A8A]"}`}>HLS {s}</span>;
|
||||
};
|
||||
|
||||
if (!show) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
const allSelected = visible.length > 0 && visible.every((e) => selected.has(e.id));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-32" data-testid={`show-episodes-admin-${show.id}`}>
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<Link to="/admin" className="inline-flex items-center gap-2 text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] mb-6" data-testid="back-to-admin">
|
||||
<ArrowLeft size={14} strokeWidth={1.5} /> Back to Admin
|
||||
</Link>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Season Packs</span>
|
||||
<h1 className="font-display text-4xl md:text-5xl font-black tracking-tighter text-white mt-2" data-testid="admin-show-title">{show.title}</h1>
|
||||
<p className="text-[#8A8A8A] mt-3">{episodes.length} episodes across {seasons.length} season(s). Select any to bulk transcode, hide, or delete.</p>
|
||||
|
||||
{/* Season filter chips */}
|
||||
<div className="mt-8 flex flex-wrap items-center gap-2">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A] mr-2">View</span>
|
||||
<button onClick={() => setSeason("all")} className={`text-xs px-4 py-2 border transition-colors ${season === "all" ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`} data-testid="season-all">All</button>
|
||||
{seasons.map((n) => (
|
||||
<div key={n} className="flex items-center gap-1">
|
||||
<button onClick={() => setSeason(n)} className={`text-xs px-4 py-2 border transition-colors ${season === n ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`} data-testid={`filter-season-${n}`}>Season {n}</button>
|
||||
<button onClick={() => selectSeason(n)} className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E] transition-colors px-1" title={`Select all S${n} episodes`} data-testid={`select-season-${n}`}>+all</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table header */}
|
||||
<div className="mt-8 border border-[#222]">
|
||||
<div className="grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<button onClick={toggleAll} className="col-span-1 flex items-center gap-2 text-[#8A8A8A] hover:text-white" data-testid="toggle-all-visible">
|
||||
{allSelected ? <CheckSquare size={16} strokeWidth={1.5} /> : <Square size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
<span className="col-span-1">S·E</span>
|
||||
<span className="col-span-5">Title</span>
|
||||
<span className="col-span-2">Source</span>
|
||||
<span className="col-span-2">HLS</span>
|
||||
<span className="col-span-1 text-right">State</span>
|
||||
</div>
|
||||
{visible.length === 0 && <div className="p-8 text-center text-[#8A8A8A] text-sm">No episodes.</div>}
|
||||
{visible.map((ep) => (
|
||||
<div key={ep.id} className={`grid grid-cols-12 items-center px-4 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors ${ep.hidden ? "opacity-50" : ""}`} data-testid={`admin-ep-row-${ep.id}`}>
|
||||
<button onClick={() => toggle(ep.id)} className="col-span-1 flex items-center text-[#8A8A8A] hover:text-white" data-testid={`select-ep-${ep.id}`}>
|
||||
{selected.has(ep.id) ? <CheckSquare size={16} strokeWidth={1.5} className="text-[#D9381E]" /> : <Square size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
<span className="col-span-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number}</span>
|
||||
<div className="col-span-5 min-w-0"><p className="text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</p></div>
|
||||
<span className="col-span-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{ep.storage_type}</span>
|
||||
<span className="col-span-2">{statusChip(ep)}</span>
|
||||
<span className="col-span-1 text-right text-[10px] uppercase tracking-[0.2em]">{ep.hidden ? <span className="text-[#fca5a5]">hidden</span> : <span className="text-[#86efac]">live</span>}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-40 bg-[#0F0F0F] border-t border-[#D9381E] px-6 md:px-12 py-4 flex flex-wrap items-center gap-3" data-testid="bulk-action-bar">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{selected.size} selected</span>
|
||||
<div className="flex-1 flex flex-wrap gap-2 justify-end">
|
||||
<button onClick={() => bulk("transcode-quick")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-quick">
|
||||
<Zap size={14} strokeWidth={1.5} /> Quick HLS
|
||||
</button>
|
||||
<button onClick={() => bulk("transcode-abr")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2" data-testid="bulk-transcode-abr">
|
||||
<Zap size={14} strokeWidth={1.5} /> ABR HLS
|
||||
</button>
|
||||
<button onClick={() => bulk("hide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-hide">
|
||||
<EyeOff size={14} strokeWidth={1.5} /> Hide
|
||||
</button>
|
||||
<button onClick={() => bulk("unhide")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="bulk-unhide">
|
||||
<Eye size={14} strokeWidth={1.5} /> Unhide
|
||||
</button>
|
||||
<button onClick={() => bulk("delete")} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2" data-testid="bulk-delete">
|
||||
<Trash2 size={14} strokeWidth={1.5} /> Delete
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white px-4 py-2" data-testid="clear-selection">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +220,11 @@ export default function AdminUpload() {
|
||||
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-4">
|
||||
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
|
||||
</div>
|
||||
|
||||
{form.tmdb_id && (
|
||||
<OpenSubsSearchBox movieId={createdMovieId} tmdbId={form.tmdb_id} onAdded={() => loadSubs(createdMovieId)} />
|
||||
)}
|
||||
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Upload .srt or .vtt files. SRT will be auto-converted.</p>
|
||||
|
||||
<form onSubmit={uploadSub} className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
@@ -269,3 +274,59 @@ const Field = ({ label, value, onChange, type = "text", required, testid, full }
|
||||
data-testid={testid} />
|
||||
</label>
|
||||
);
|
||||
|
||||
const OpenSubsSearchBox = ({ movieId, tmdbId, onAdded }) => {
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/opensubs/search?tmdb_id=${tmdbId}&languages=en`);
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "OpenSubs not configured or search failed");
|
||||
setResults([]);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const pick = async (r) => {
|
||||
try {
|
||||
await api.post("/opensubs/download", {
|
||||
file_id: r.file_id, movie_id: movieId,
|
||||
language: r.language, label: r.label || r.language.toUpperCase(),
|
||||
});
|
||||
toast.success("Subtitle attached");
|
||||
onAdded?.();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Download failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-5 border border-dashed border-[#222] p-4" data-testid="opensubs-box">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-white">Search OpenSubtitles for this movie</p>
|
||||
<button type="button" onClick={search} disabled={loading}
|
||||
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="opensubs-search-button">
|
||||
{loading ? "Searching…" : "Search"}
|
||||
</button>
|
||||
</div>
|
||||
{results && results.length === 0 && <p className="text-xs text-[#8A8A8A] mt-3">No results found.</p>}
|
||||
{results && results.length > 0 && (
|
||||
<div className="mt-3 max-h-64 overflow-y-auto border border-[#222]">
|
||||
{results.map((r) => (
|
||||
<button type="button" key={r.file_id} onClick={() => pick(r)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-[#0F0F0F] text-left border-b border-[#222] last:border-b-0"
|
||||
data-testid={`opensubs-result-${r.file_id}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{r.label || r.release || `${r.language.toUpperCase()} subtitle`}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{r.language} · {r.downloads} downloads</p>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#D9381E]">Add →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -58,12 +58,12 @@ export default function Browse() {
|
||||
|
||||
<div className="-mt-20 relative z-10 pb-24">
|
||||
{continueWatching.length > 0 && (
|
||||
<Row title="Continue Watching" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="Resume" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
|
||||
)}
|
||||
<Row title="Trending Now" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="New Releases" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="Popular" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="Fresh Arrivals" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
|
||||
{watchlist.length > 0 && (
|
||||
<Row title="My List" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
|
||||
<Row title="Your Shelf" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
|
||||
)}
|
||||
{genres.slice(0, 6).map((g) => {
|
||||
const list = byGenre(g);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api, { API, isHls, getSubtitleUrl } from "../lib/api";
|
||||
import { ArrowLeft, SkipForward } from "lucide-react";
|
||||
import Hls from "hls.js";
|
||||
|
||||
export default function EpisodePlayer() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [ep, setEp] = useState(null);
|
||||
const [show, setShow] = useState(null);
|
||||
const [nextEp, setNextEp] = useState(null);
|
||||
const [subs, setSubs] = useState([]);
|
||||
const [showNextCta, setShowNextCta] = useState(false);
|
||||
const videoRef = useRef(null);
|
||||
const hlsRef = useRef(null);
|
||||
const lastSent = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [{ data: e }, { data: subList }] = await Promise.all([
|
||||
api.get(`/episodes/${id}`),
|
||||
api.get(`/episodes/${id}/subtitles`).catch(() => ({ data: [] })),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setEp(e);
|
||||
setSubs(subList);
|
||||
const [{ data: s }, { data: siblings }] = await Promise.all([
|
||||
api.get(`/shows/${e.show_id}`),
|
||||
api.get(`/shows/${e.show_id}/episodes`),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setShow(s);
|
||||
const idx = siblings.findIndex((x) => x.id === e.id);
|
||||
setNextEp(idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ep || !videoRef.current) return;
|
||||
const v = videoRef.current;
|
||||
const token = localStorage.getItem("kino_token") || "";
|
||||
const src = ep.storage_type === "external" && ep.video_url
|
||||
? ep.video_url
|
||||
: `${API}/stream/episode/${ep.id}?auth=${encodeURIComponent(token)}`;
|
||||
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
||||
if (isHls(src) && Hls.isSupported() && !v.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(src); hls.attachMedia(v); hlsRef.current = hls;
|
||||
} else { v.src = src; }
|
||||
api.get(`/progress/episode/${id}`).then(({ data }) => {
|
||||
if (data?.position_seconds && v) v.currentTime = data.position_seconds;
|
||||
}).catch(() => {});
|
||||
return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } };
|
||||
}, [ep, id]);
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
const now = Date.now();
|
||||
// Show "Next episode" CTA in last 20s if available
|
||||
if (nextEp && v.duration - v.currentTime <= 20 && !showNextCta) setShowNextCta(true);
|
||||
if (now - lastSent.current < 5000) return;
|
||||
lastSent.current = now;
|
||||
api.post("/progress/episode", {
|
||||
episode_id: id, position_seconds: v.currentTime, duration_seconds: v.duration,
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const onEnded = () => { if (nextEp) nav(`/watch/episode/${nextEp.id}`); };
|
||||
|
||||
if (!ep || !show) return <div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="episode-player-page">
|
||||
<button onClick={() => nav(-1)} className="absolute top-6 left-6 z-10 flex items-center gap-2 text-white/80 hover:text-white bg-black/60 hover:bg-black px-4 py-2 transition-colors" data-testid="ep-player-back">
|
||||
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||
</button>
|
||||
<div className="absolute top-6 right-6 z-10 text-right">
|
||||
<h2 className="font-display text-lg text-white">{show.title}</h2>
|
||||
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number} · {ep.title}</p>
|
||||
</div>
|
||||
<video ref={videoRef} controls autoPlay className="w-full h-full object-contain bg-black"
|
||||
onTimeUpdate={onTimeUpdate} onEnded={onEnded} data-testid="ep-player-video">
|
||||
{subs.map((sub) => (
|
||||
<track key={sub.id} kind="subtitles" src={getSubtitleUrl(sub)}
|
||||
srcLang={sub.language} label={sub.label} default={sub.is_default}
|
||||
data-testid={`ep-player-track-${sub.id}`} />
|
||||
))}
|
||||
</video>
|
||||
|
||||
{showNextCta && nextEp && (
|
||||
<button onClick={() => nav(`/watch/episode/${nextEp.id}`)}
|
||||
className="absolute bottom-24 right-8 z-20 flex items-center gap-3 bg-white/10 hover:bg-[#D9381E] text-white px-6 py-4 backdrop-blur-md border border-white/20 transition-colors duration-300"
|
||||
data-testid="next-episode-cta">
|
||||
<SkipForward size={18} strokeWidth={1.5} />
|
||||
<div className="text-left">
|
||||
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Up next</div>
|
||||
<div className="text-sm">S{nextEp.season_number}·E{nextEp.episode_number} · {nextEp.title}</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -35,12 +35,12 @@ export default function Login() {
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Personal Cinema</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Private Screening Room</span>
|
||||
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
|
||||
Your library,<br/>your way.
|
||||
Your reels,<br/>your rules.
|
||||
</h1>
|
||||
<p className="text-[#8A8A8A] mt-6 max-w-md leading-relaxed">
|
||||
Stream the films you own, the way you remember them — without the noise.
|
||||
Every frame you already own, indexed and ready — no algorithms, no ads, no interruptions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ export default function Login() {
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="login-form">
|
||||
<Link to="/" className="block mb-12" data-testid="login-logo">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Sign in</h2>
|
||||
@@ -93,7 +93,7 @@ export default function Login() {
|
||||
|
||||
<div className="mt-8 p-4 border border-[#222] text-xs text-[#8A8A8A]" data-testid="login-demo-credentials">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] block mb-2">Demo Admin</span>
|
||||
admin@kino.local / kino-admin-2026
|
||||
admin@streamhoard.local / kino-admin-2026
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Music2 } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function Music() {
|
||||
const [tracks, setTracks] = useState([]);
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
api.get("/tracks").then(({ data }) => setTracks(data));
|
||||
}, []);
|
||||
|
||||
const albums = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const t of tracks) {
|
||||
const key = `${t.artist}|||${t.album}`;
|
||||
if (!map.has(key)) map.set(key, { artist: t.artist, album: t.album, album_art_url: t.album_art_url, count: 0 });
|
||||
map.get(key).count += 1;
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => a.artist.localeCompare(b.artist) || a.album.localeCompare(b.album));
|
||||
}, [tracks]);
|
||||
|
||||
if (tracks.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-empty-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
|
||||
<Music2 size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
|
||||
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No music yet</h1>
|
||||
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Run a Library Scan in Admin to import tracks from the NAS.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="music-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Music</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Music</h1>
|
||||
|
||||
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{albums.map((a) => (
|
||||
<button key={`${a.artist}|||${a.album}`}
|
||||
onClick={() => nav(`/music/album/${encodeURIComponent(a.artist)}/${encodeURIComponent(a.album)}`)}
|
||||
className="group relative aspect-square overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`album-card-${a.artist}-${a.album}`}>
|
||||
<AlbumArt url={a.album_art_url} />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{a.album}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] truncate">{a.artist} · {a.count} tracks</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,17 +16,17 @@ export default function MyList() {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="my-list-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Your collection</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Curated for you</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
|
||||
My List
|
||||
Shelf
|
||||
</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-xl">
|
||||
Films you've curated for later. Click any to play or remove.
|
||||
Titles you've set aside for a proper evening. Tap any to start or clear it off the shelf.
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="mt-16 border border-[#222] p-12 text-center" data-testid="my-list-empty">
|
||||
<p className="text-[#8A8A8A]">Your list is empty. Add films from Browse.</p>
|
||||
<p className="text-[#8A8A8A]">Nothing on the shelf yet — save something from the Library.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
|
||||
@@ -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">
|
||||
Who's watching?
|
||||
</h1>
|
||||
|
||||
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
|
||||
{profiles.map((p) => (
|
||||
<div key={p.id} className="flex flex-col items-center gap-3 group" data-testid={`profile-${p.id}`}>
|
||||
<button
|
||||
onClick={() => editing ? null : choose(p)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex items-center justify-center text-3xl font-bold text-white transition-all duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
style={{ backgroundColor: p.avatar_color }}
|
||||
data-testid={`select-profile-${p.id}`}
|
||||
>
|
||||
{p.name?.[0]?.toUpperCase() || "?"}
|
||||
</button>
|
||||
<span className="font-display text-lg text-white">{p.name}</span>
|
||||
<div className="flex gap-2 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{p.is_kids && <span>Kids</span>}
|
||||
{p.max_rating !== "NR" && <span>· Up to {p.max_rating}</span>}
|
||||
</div>
|
||||
{editing && (
|
||||
<button onClick={() => remove(p.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors mt-1" data-testid={`delete-profile-${p.id}`}>
|
||||
<Trash2 size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{profiles.length < 5 && (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="w-28 h-28 md:w-36 md:h-36 flex flex-col items-center justify-center border border-dashed border-[#333] hover:border-[#D9381E] text-[#8A8A8A] hover:text-white transition-colors duration-300"
|
||||
data-testid="add-profile-button"
|
||||
>
|
||||
<Plus size={32} strokeWidth={1.5} />
|
||||
<span className="text-xs uppercase tracking-[0.2em] mt-2">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex gap-4">
|
||||
<button onClick={() => setEditing(!editing)}
|
||||
className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="manage-profiles-button">
|
||||
{editing ? <Check size={14} strokeWidth={1.5} /> : <Pencil size={14} strokeWidth={1.5} />}
|
||||
{editing ? "Done" : "Manage Profiles"}
|
||||
</button>
|
||||
<button onClick={() => { logout(); nav("/login"); }}
|
||||
className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em] transition-colors duration-300"
|
||||
data-testid="profile-logout-button">
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="new-profile-modal">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setCreating(false)} />
|
||||
<form onSubmit={submitNew} className="relative bg-[#0F0F0F] border border-[#222] p-8 w-full max-w-md mx-4 fade-up">
|
||||
<button type="button" onClick={() => setCreating(false)} className="absolute top-4 right-4 text-[#8A8A8A] hover:text-white"><X size={18} /></button>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">New profile</h2>
|
||||
|
||||
<label className="block mt-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Name</span>
|
||||
<input value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
required className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="new-profile-name" />
|
||||
</label>
|
||||
|
||||
<div className="mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Color</span>
|
||||
<div className="mt-2 flex gap-2">
|
||||
{COLORS.map((c) => (
|
||||
<button type="button" key={c} onClick={() => setDraft({ ...draft, avatar_color: c })}
|
||||
className={`w-10 h-10 ${draft.avatar_color === c ? "ring-2 ring-white" : ""}`}
|
||||
style={{ backgroundColor: c }} aria-label={`Color ${c}`}
|
||||
data-testid={`color-${c.replace('#', '')}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 mt-5 cursor-pointer">
|
||||
<input type="checkbox" checked={draft.is_kids}
|
||||
onChange={(e) => setDraft({ ...draft, is_kids: e.target.checked, max_rating: e.target.checked ? "PG" : draft.max_rating })}
|
||||
className="accent-[#D9381E]" data-testid="new-profile-kids" />
|
||||
<span className="text-sm text-[#C8C8C8]">Kids profile (limits content to PG)</span>
|
||||
</label>
|
||||
|
||||
<label className="block mt-5">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Max rating</span>
|
||||
<select value={draft.max_rating} onChange={(e) => setDraft({ ...draft, max_rating: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] text-white px-4 py-3 focus:border-[#D9381E] focus:outline-none"
|
||||
data-testid="new-profile-rating">
|
||||
<option value="G">G</option>
|
||||
<option value="PG">PG</option>
|
||||
<option value="PG-13">PG-13</option>
|
||||
<option value="R">R</option>
|
||||
<option value="NR">No restriction</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="mt-8 w-full bg-[#D9381E] hover:bg-[#ED4B32] text-white py-3 text-sm uppercase tracking-[0.2em]"
|
||||
data-testid="new-profile-submit">Create profile</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export default function Register() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register(email, password, name);
|
||||
toast.success("Welcome to Kino");
|
||||
toast.success("Welcome to StreamHoard");
|
||||
nav("/browse", { replace: true });
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not register");
|
||||
@@ -39,9 +39,9 @@ export default function Register() {
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Join Kino</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Open the doors</span>
|
||||
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
|
||||
Curate.<br/>Stream.<br/>Repeat.
|
||||
Collect.<br/>Cue.<br/>Enjoy.
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -49,7 +49,7 @@ export default function Register() {
|
||||
<div className="flex items-center justify-center px-6 md:px-12 py-12">
|
||||
<form onSubmit={onSubmit} className="w-full max-w-sm fade-up" data-testid="register-form">
|
||||
<Link to="/" className="block mb-12">
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">Kino</span>
|
||||
<span className="font-display text-3xl font-black tracking-tighter text-white">StreamHoard</span>
|
||||
<span className="text-[#D9381E] text-3xl">.</span>
|
||||
</Link>
|
||||
<h2 className="font-display text-3xl font-bold tracking-tight text-white">Create account</h2>
|
||||
|
||||
@@ -3,6 +3,43 @@ import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const STATUS_LABEL = {
|
||||
searching: "Searching…",
|
||||
queued: "Queued",
|
||||
downloading: "Downloading",
|
||||
"partially downloaded": "Partially downloaded",
|
||||
downloaded: "Downloaded",
|
||||
completed: "Downloaded",
|
||||
delay: "Waiting",
|
||||
unknown: "",
|
||||
};
|
||||
|
||||
function LiveStatus({ requestId }) {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const { data } = await api.get(`/requests/${requestId}/status`);
|
||||
if (!cancelled) setStatus(data);
|
||||
} catch { /* transient — next poll will retry */ }
|
||||
};
|
||||
poll();
|
||||
const t = setInterval(poll, 15000);
|
||||
return () => { cancelled = true; clearInterval(t); };
|
||||
}, [requestId]);
|
||||
|
||||
if (!status) return null;
|
||||
const label = STATUS_LABEL[status.state] || status.state;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
{label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Requests() {
|
||||
const { user } = useAuth();
|
||||
const [mine, setMine] = useState([]);
|
||||
@@ -10,6 +47,7 @@ export default function Requests() {
|
||||
const [title, setTitle] = useState("");
|
||||
const [year, setYear] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [approving, setApproving] = useState({});
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/requests/mine");
|
||||
@@ -21,6 +59,19 @@ export default function Requests() {
|
||||
};
|
||||
useEffect(() => { load(); }, [user?.is_admin]);
|
||||
|
||||
const approve = async (id, contentType) => {
|
||||
setApproving((p) => ({ ...p, [id]: true }));
|
||||
try {
|
||||
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
|
||||
toast.success(`Searching for "${data.matched_title || data.title}"`);
|
||||
load();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Could not approve request");
|
||||
} finally {
|
||||
setApproving((p) => ({ ...p, [id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) return;
|
||||
@@ -107,7 +158,8 @@ export default function Requests() {
|
||||
</div>
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
|
||||
r.status === "fulfilled" ? "text-[#86efac]" :
|
||||
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
|
||||
r.status === "rejected" ? "text-[#fca5a5]" :
|
||||
r.status === "approved" ? "text-[#93c5fd]" : "text-[#fcd34d]"
|
||||
}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
@@ -125,13 +177,26 @@ export default function Requests() {
|
||||
) : (
|
||||
<div className="border border-[#222]">
|
||||
{all.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||
<div>
|
||||
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
|
||||
<div key={r.id} className="flex items-center justify-between gap-4 px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
|
||||
<div className="min-w-0">
|
||||
<p className="text-white truncate">
|
||||
{r.matched_title || r.title} {(r.matched_year || r.year) ? <span className="text-[#8A8A8A]">({r.matched_year || r.year})</span> : null}
|
||||
</p>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}</p>
|
||||
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
|
||||
{r.status === "approved" && <div className="mt-1"><LiveStatus requestId={r.id} /></div>}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{r.status === "pending" && (
|
||||
<>
|
||||
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
|
||||
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
|
||||
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={() => updateStatus(r.id, "fulfilled")}
|
||||
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
|
||||
data-testid={`fulfill-${r.id}`}>Fulfilled</button>
|
||||
|
||||
+388
-78
@@ -1,118 +1,428 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, createContext, useContext } from "react";
|
||||
import api from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { toast } from "sonner";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
|
||||
|
||||
const FieldCtx = createContext(null);
|
||||
|
||||
const Badge = ({ on }) => (
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${on ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
||||
{on ? "● Configured" : "○ Not configured"}
|
||||
</span>
|
||||
);
|
||||
|
||||
const Field = ({ label, k, type = "text", placeholder, mask }) => {
|
||||
const { s, setS, show, setShow } = useContext(FieldCtx);
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={mask && !show[k] ? "password" : type}
|
||||
value={s[k]}
|
||||
placeholder={placeholder || ""}
|
||||
onChange={(e) => setS({ ...s, [k]: 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={`settings-${k}`}
|
||||
/>
|
||||
{mask && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShow({ ...show, [k]: !show[k] })}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white"
|
||||
>
|
||||
{show[k] ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "" });
|
||||
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
|
||||
const [showTmdb, setShowTmdb] = useState(false);
|
||||
const [showRadarr, setShowRadarr] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const isAdmin = !!user?.is_admin;
|
||||
const [s, setS] = useState({
|
||||
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||
sonarr_url: "", sonarr_api_key: "",
|
||||
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
|
||||
auto_transcode: "off", queue_paused: false,
|
||||
music_itunes_fallback: true, music_acoustid_enabled: false, acoustid_api_key: "",
|
||||
});
|
||||
const [info, setInfo] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
||||
const [traktModal, setTraktModal] = useState(null);
|
||||
const [show, setShow] = useState({});
|
||||
const [pw, setPw] = useState({ current_password: "", new_password: "", confirm: "" });
|
||||
const [pwShow, setPwShow] = useState(false);
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [enrich, setEnrich] = useState(null);
|
||||
const [identify, setIdentify] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
const { data } = await api.get("/settings");
|
||||
setS({ tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "" });
|
||||
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
|
||||
if (!isAdmin) return;
|
||||
const [{ data }, { data: ts }] = await Promise.all([api.get("/settings"), api.get("/trakt/status").catch(() => ({ data: { connected: false } }))]);
|
||||
setS({
|
||||
tmdb_api_key: data.tmdb_api_key || "", radarr_url: data.radarr_url || "", radarr_api_key: data.radarr_api_key || "",
|
||||
sonarr_url: data.sonarr_url || "", sonarr_api_key: data.sonarr_api_key || "",
|
||||
opensubs_api_key: data.opensubs_api_key || "",
|
||||
trakt_client_id: data.trakt_client_id || "", trakt_client_secret: data.trakt_client_secret || "",
|
||||
auto_transcode: data.auto_transcode || "off", queue_paused: !!data.queue_paused,
|
||||
music_itunes_fallback: data.music_itunes_fallback !== false,
|
||||
music_acoustid_enabled: !!data.music_acoustid_enabled,
|
||||
acoustid_api_key: data.acoustid_api_key || "",
|
||||
});
|
||||
setInfo(data); setTraktStatus(ts);
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const save = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data } = await api.put("/settings", s);
|
||||
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
|
||||
toast.success("Settings saved");
|
||||
} catch {
|
||||
toast.error("Could not save");
|
||||
} finally { setSaving(false); }
|
||||
e.preventDefault(); setSaving(true);
|
||||
try { await api.put("/settings", s); toast.success("Settings saved"); load(); }
|
||||
catch { toast.error("Could not save"); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const testRadarr = async () => {
|
||||
try {
|
||||
const { data } = await api.post("/radarr/test");
|
||||
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.detail || "Test failed");
|
||||
}
|
||||
const testConn = async (kind) => {
|
||||
try { const { data } = await api.post(`/${kind}/test`); data.ok ? toast.success(`${kind} connected`) : toast.error(`${kind} unreachable`); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Test failed"); }
|
||||
};
|
||||
|
||||
const connectTrakt = async () => {
|
||||
try {
|
||||
const { data } = await api.post("/trakt/device-code");
|
||||
setTraktModal(data);
|
||||
// Poll
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(async () => {
|
||||
if (Date.now() - startedAt > data.expires_in * 1000) { clearInterval(interval); setTraktModal(null); toast.error("Trakt code expired"); return; }
|
||||
try {
|
||||
const { data: r } = await api.post("/trakt/poll", { device_code: data.device_code });
|
||||
if (r.ok) { clearInterval(interval); setTraktModal(null); toast.success(`Trakt connected as ${r.username}`); load(); }
|
||||
} catch { clearInterval(interval); setTraktModal(null); toast.error("Trakt polling failed"); }
|
||||
}, (data.interval || 5) * 1000);
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start Trakt flow"); }
|
||||
};
|
||||
|
||||
const disconnectTrakt = async () => {
|
||||
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
|
||||
};
|
||||
|
||||
const changePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
if (pw.new_password.length < 8) { toast.error("New password must be at least 8 characters"); return; }
|
||||
if (pw.new_password !== pw.confirm) { toast.error("New passwords don't match"); return; }
|
||||
setPwSaving(true);
|
||||
try {
|
||||
await api.post("/auth/change-password", { current_password: pw.current_password, new_password: pw.new_password });
|
||||
toast.success("Password changed");
|
||||
setPw({ current_password: "", new_password: "", confirm: "" });
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not change password"); }
|
||||
finally { setPwSaving(false); }
|
||||
};
|
||||
|
||||
const enrichAll = async () => {
|
||||
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
|
||||
try {
|
||||
await api.post("/tmdb/enrich-all");
|
||||
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||
setEnrich(data);
|
||||
toast.success("Enrichment started");
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api.get("/tmdb/enrich-all/status").then(({ data }) => setEnrich(data)).catch(() => {});
|
||||
api.get("/music/identify/status").then(({ data }) => setIdentify(data)).catch(() => {});
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enrich?.running) return;
|
||||
const t = setInterval(async () => {
|
||||
const { data } = await api.get("/tmdb/enrich-all/status");
|
||||
setEnrich(data);
|
||||
if (!data.running) {
|
||||
clearInterval(t);
|
||||
if (data.error) {
|
||||
toast.error(`Enrichment failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success(`Enrichment done — ${data.enriched} enriched, ${data.skipped} already complete, ${data.failed} not found`);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [enrich?.running]);
|
||||
|
||||
const identifyAll = async () => {
|
||||
if (!window.confirm("Fingerprint every track with an unknown artist or album and identify it via AcoustID? This may take a while.")) return;
|
||||
try {
|
||||
await api.post("/music/identify");
|
||||
const { data } = await api.get("/music/identify/status");
|
||||
setIdentify(data);
|
||||
toast.success("Identification started");
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!identify?.running) return;
|
||||
const t = setInterval(async () => {
|
||||
const { data } = await api.get("/music/identify/status");
|
||||
setIdentify(data);
|
||||
if (!data.running) {
|
||||
clearInterval(t);
|
||||
if (data.error) {
|
||||
toast.error(`Identification failed: ${data.error}`);
|
||||
} else {
|
||||
toast.success(`Identification done — ${data.identified} of ${data.total} tracks identified`);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [identify?.running]);
|
||||
|
||||
return (
|
||||
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||
<div className="px-6 md:px-12 max-w-3xl mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">{isAdmin ? "Admin" : "Account"}</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-xl">Connect TMDB for metadata auto-fill and Radarr for library import.</p>
|
||||
|
||||
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2>
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.tmdb_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
||||
{info.tmdb_configured ? "● Connected" : "○ Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Get a free API key at <a className="text-[#D9381E] hover:text-[#ED4B32]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org/settings/api</a>
|
||||
</p>
|
||||
<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]">API key (v3)</span>
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Current password</span>
|
||||
<div className="relative mt-2">
|
||||
<input type={showTmdb ? "text" : "password"}
|
||||
value={s.tmdb_api_key} onChange={(e) => setS({ ...s, tmdb_api_key: e.target.value })}
|
||||
<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="tmdb-key-input" />
|
||||
<button type="button" onClick={() => setShowTmdb(!showTmdb)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
|
||||
{showTmdb ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
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>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2>
|
||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.radarr_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
||||
{info.radarr_configured ? "● Configured" : "○ Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Import your existing Radarr-managed library. Kino must be able to read Radarr's media paths on disk.
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Base URL</span>
|
||||
<input value={s.radarr_url} placeholder="http://192.168.1.10:7878"
|
||||
onChange={(e) => setS({ ...s, radarr_url: e.target.value })}
|
||||
<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="radarr-url-input" />
|
||||
data-testid="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label className="block mt-4">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key</span>
|
||||
<div className="relative mt-2">
|
||||
<input type={showRadarr ? "text" : "password"}
|
||||
value={s.radarr_api_key} onChange={(e) => setS({ ...s, radarr_api_key: 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="radarr-key-input" />
|
||||
<button type="button" onClick={() => setShowRadarr(!showRadarr)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
|
||||
{showRadarr ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Confirm new password</span>
|
||||
<input
|
||||
type={pwShow ? "text" : "password"}
|
||||
value={pw.confirm}
|
||||
onChange={(e) => setPw({ ...pw, confirm: e.target.value })}
|
||||
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
|
||||
data-testid="confirm-password"
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={pwSaving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="change-password-button">
|
||||
{pwSaving ? "Changing…" : "Change Password"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{isAdmin && (
|
||||
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||
{/* TMDB */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2><Badge on={info.tmdb_configured} />
|
||||
</div>
|
||||
</label>
|
||||
<button type="button" onClick={testRadarr}
|
||||
className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors"
|
||||
data-testid="radarr-test-button">
|
||||
Test Connection
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
|
||||
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||
{info.tmdb_configured && (
|
||||
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||
<button type="button" onClick={enrichAll} disabled={enrich?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="enrich-all-button">
|
||||
{enrich?.running ? `Enriching… ${enrich.done}/${enrich.total}` : "Bulk enrich all movies →"}
|
||||
</button>
|
||||
{enrich && !enrich.running && enrich.error && (
|
||||
<span className="text-xs text-[#fca5a5]" data-testid="enrich-all-error">
|
||||
Last run failed: {enrich.error}
|
||||
</span>
|
||||
)}
|
||||
{enrich && !enrich.running && !enrich.error && enrich.total > 0 && (
|
||||
<span className="text-xs text-[#8A8A8A]" data-testid="enrich-all-summary">
|
||||
Last run: {enrich.enriched} enriched · {enrich.skipped} already complete · {enrich.failed} not found on TMDB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<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">
|
||||
{/* Radarr */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2><Badge on={info.radarr_configured} />
|
||||
</div>
|
||||
<Field label="Base URL" k="radarr_url" placeholder="http://192.168.1.10:7878" />
|
||||
<div className="mt-4"><Field label="API key" k="radarr_api_key" mask /></div>
|
||||
<button type="button" onClick={() => testConn("radarr")} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors" data-testid="radarr-test">Test</button>
|
||||
</section>
|
||||
|
||||
{/* Sonarr */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Sonarr</h2><Badge on={info.sonarr_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Import your TV shows library.</p>
|
||||
<Field label="Base URL" k="sonarr_url" placeholder="http://192.168.1.10:8989" />
|
||||
<div className="mt-4"><Field label="API key" k="sonarr_api_key" mask /></div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button type="button" onClick={() => testConn("sonarr")} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors" data-testid="sonarr-test">Test</button>
|
||||
{info.sonarr_configured && <a href="/admin/sonarr" className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors">Import shows →</a>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* OpenSubtitles */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">OpenSubtitles</h2><Badge on={info.opensubs_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">One-click subtitle search per movie/episode. Free key at <a className="text-[#D9381E]" href="https://www.opensubtitles.com/consumer/apps" target="_blank" rel="noreferrer">opensubtitles.com</a></p>
|
||||
<Field label="API key" k="opensubs_api_key" mask />
|
||||
</section>
|
||||
|
||||
{/* Trakt */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white">Trakt.tv</h2><Badge on={info.trakt_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">Mirror watch history to Trakt. Register an app at <a className="text-[#D9381E]" href="https://trakt.tv/oauth/applications" target="_blank" rel="noreferrer">trakt.tv/oauth/applications</a> (use urn:ietf:wg:oauth:2.0:oob as redirect).</p>
|
||||
<Field label="Client ID" k="trakt_client_id" mask />
|
||||
<div className="mt-4"><Field label="Client secret" k="trakt_client_secret" mask /></div>
|
||||
{info.trakt_configured && (
|
||||
<div className="mt-4">
|
||||
{traktStatus.connected ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[#86efac]">● Connected as {traktStatus.username}</span>
|
||||
<button type="button" onClick={disconnectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2" data-testid="trakt-disconnect">
|
||||
<Unlink size={12} /> Disconnect
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={connectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] bg-[#D9381E] hover:bg-[#ED4B32] text-white px-4 py-2" data-testid="trakt-connect">
|
||||
<Link2 size={12} /> Connect Trakt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Transcode Queue */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display text-2xl font-bold text-white flex items-center gap-2">
|
||||
<ListTodo size={20} strokeWidth={1.5} className="text-[#D9381E]" /> Transcode Queue
|
||||
</h2>
|
||||
<a href="/admin/queue" className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] hover:text-[#ED4B32]" data-testid="settings-queue-link">View queue →</a>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Auto-transcode mode</span>
|
||||
<select value={s.auto_transcode} onChange={(e) => setS({ ...s, auto_transcode: 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="auto-transcode-select">
|
||||
<option value="off">Off — manual only</option>
|
||||
<option value="quick">Quick (stream-copy, instant)</option>
|
||||
<option value="abr">ABR (multi-bitrate, slow but adaptive)</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{/* Music Metadata & Artwork */}
|
||||
<section>
|
||||
<h2 className="font-display text-2xl font-bold text-white mb-3">Music Metadata & Artwork</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Album art/tags come from MusicBrainz + Cover Art Archive first (free, no key). These add free fallbacks for what that misses.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.music_itunes_fallback}
|
||||
onChange={(e) => setS({ ...s, music_itunes_fallback: e.target.checked })}
|
||||
className="w-4 h-4 accent-[#D9381E]"
|
||||
data-testid="settings-itunes-fallback"
|
||||
/>
|
||||
<span className="text-sm text-white">Fall back to iTunes Search for artwork MusicBrainz doesn't have</span>
|
||||
</label>
|
||||
<p className="text-xs text-[#8A8A8A] mt-1 ml-7">No API key needed — always free, no signup.</p>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-[#222]">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-white">AcoustID audio fingerprint identification</span>
|
||||
<Badge on={info.acoustid_configured} />
|
||||
</div>
|
||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
||||
Identifies tracks with missing/unknown artist or album by fingerprinting the actual audio file — useful for badly-tagged files with nothing to text-search on. Free key at{" "}
|
||||
<a className="text-[#D9381E]" href="https://acoustid.org/api-key" target="_blank" rel="noreferrer">acoustid.org/api-key</a>.
|
||||
</p>
|
||||
<label className="flex items-center gap-3 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.music_acoustid_enabled}
|
||||
onChange={(e) => setS({ ...s, music_acoustid_enabled: e.target.checked })}
|
||||
className="w-4 h-4 accent-[#D9381E]"
|
||||
data-testid="settings-acoustid-enabled"
|
||||
/>
|
||||
<span className="text-sm text-white">Enable AcoustID identification</span>
|
||||
</label>
|
||||
<Field label="API key" k="acoustid_api_key" mask />
|
||||
{info.acoustid_configured && s.music_acoustid_enabled && (
|
||||
<div className="mt-4 flex items-center gap-4 flex-wrap">
|
||||
<button type="button" onClick={identifyAll} disabled={identify?.running} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50" data-testid="identify-all-button">
|
||||
{identify?.running ? `Identifying… ${identify.done}/${identify.total}` : "Identify unknown tracks →"}
|
||||
</button>
|
||||
{identify && !identify.running && identify.error && (
|
||||
<span className="text-xs text-[#fca5a5]" data-testid="identify-all-error">
|
||||
Last run failed: {identify.error}
|
||||
</span>
|
||||
)}
|
||||
{identify && !identify.running && !identify.error && identify.total > 0 && (
|
||||
<span className="text-xs text-[#8A8A8A]" data-testid="identify-all-summary">
|
||||
Last run: {identify.identified} of {identify.total} identified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
|
||||
{saving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trakt device-code modal */}
|
||||
{traktModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="trakt-modal">
|
||||
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setTraktModal(null)} />
|
||||
<div className="relative bg-[#0F0F0F] border border-[#222] p-8 max-w-md w-full mx-4">
|
||||
<h2 className="font-display text-3xl font-bold text-white">Connect Trakt</h2>
|
||||
<p className="text-sm text-[#8A8A8A] mt-2">Visit the URL below, sign in to Trakt, and enter the code:</p>
|
||||
<a href={traktModal.verification_url} target="_blank" rel="noreferrer" className="block mt-6 text-[#D9381E] hover:text-[#ED4B32] break-all">{traktModal.verification_url}</a>
|
||||
<div className="mt-6 bg-black border border-[#D9381E] px-6 py-4 text-center">
|
||||
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Enter this code</div>
|
||||
<div className="font-display text-4xl font-black tracking-widest text-white mt-2" data-testid="trakt-user-code">{traktModal.user_code}</div>
|
||||
</div>
|
||||
<p className="text-xs text-[#8A8A8A] mt-4">Waiting for approval… this window will close automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FieldCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Play, ArrowLeft } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function ShowDetail() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [show, setShow] = useState(null);
|
||||
const [episodes, setEpisodes] = useState([]);
|
||||
const [season, setSeason] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [s, e] = await Promise.all([api.get(`/shows/${id}`), api.get(`/shows/${id}/episodes`)]);
|
||||
setShow(s.data); setEpisodes(e.data);
|
||||
const firstS = e.data[0]?.season_number || 1;
|
||||
setSeason(firstS);
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
|
||||
const inSeason = useMemo(() => episodes.filter((e) => e.season_number === season), [episodes, season]);
|
||||
|
||||
if (!show) 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]" data-testid={`show-detail-${show.id}`}>
|
||||
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
|
||||
<AlbumArt url={show.backdrop_url || show.poster_url} className="absolute inset-0" />
|
||||
<div className="absolute inset-0 hero-fade" />
|
||||
<button onClick={() => nav(-1)} className="absolute top-24 left-6 md:left-12 flex items-center gap-2 text-white/80 hover:text-white bg-black/40 hover:bg-black/60 px-4 py-2 backdrop-blur transition-colors" data-testid="show-back">
|
||||
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||
</button>
|
||||
<div className="absolute bottom-12 left-6 md:left-12 max-w-2xl">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-2" data-testid="show-title">{show.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
|
||||
<span>{show.year}</span><span>·</span><span>{show.rating}</span><span>·</span>
|
||||
<span>{show.season_count} seasons · {show.episode_count} episodes</span>
|
||||
</div>
|
||||
<p className="text-[#C8C8C8] mt-5 max-w-xl leading-relaxed">{show.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto py-12">
|
||||
<div className="flex items-center gap-3 flex-wrap mb-6">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Seasons</span>
|
||||
{seasons.map((n) => (
|
||||
<button key={n} onClick={() => setSeason(n)}
|
||||
className={`text-sm px-4 py-2 border transition-colors ${season === n ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
|
||||
data-testid={`season-${n}`}>
|
||||
Season {n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{inSeason.map((ep) => (
|
||||
<button key={ep.id} onClick={() => nav(`/watch/episode/${ep.id}`)}
|
||||
className="w-full flex items-center gap-4 p-4 border border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F] transition-colors text-left group"
|
||||
data-testid={`episode-${ep.id}`}>
|
||||
<div className="w-12 h-12 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
|
||||
<Play size={16} strokeWidth={1.5} fill="currentColor" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">E{ep.episode_number}</span>
|
||||
<h3 className="font-display text-lg text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</h3>
|
||||
{ep.duration_minutes > 0 && <span className="text-xs text-[#8A8A8A] ml-auto">{ep.duration_minutes}m</span>}
|
||||
</div>
|
||||
{ep.description && <p className="text-sm text-[#8A8A8A] mt-1 line-clamp-2">{ep.description}</p>}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../lib/api";
|
||||
import { Tv } from "lucide-react";
|
||||
import AlbumArt from "../components/AlbumArt";
|
||||
|
||||
export default function Shows() {
|
||||
const [shows, setShows] = useState([]);
|
||||
const [featured, setFeatured] = useState(null);
|
||||
const [continueEps, setContinueEps] = useState([]);
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [s, f, c] = await Promise.all([
|
||||
api.get("/shows"),
|
||||
api.get("/shows/featured").catch(() => ({ data: null })),
|
||||
api.get("/progress/episodes/continue").catch(() => ({ data: [] })),
|
||||
]);
|
||||
setShows(s.data); setFeatured(f.data); setContinueEps(c.data);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (shows.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-empty-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
|
||||
<Tv 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 TV shows yet</h1>
|
||||
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Configure Sonarr in Settings and import your library.</p>
|
||||
<a href="/admin/sonarr" className="inline-block mt-6 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]" data-testid="go-to-sonarr-import">
|
||||
Sonarr Import
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Series</h1>
|
||||
|
||||
{continueEps.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Resume</h2>
|
||||
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-4">
|
||||
{continueEps.map(({ episode, show, progress }) => {
|
||||
const pct = progress.duration_seconds ? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100) : 0;
|
||||
return (
|
||||
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
|
||||
className="relative shrink-0 w-[260px] group focus:outline-none" data-testid={`continue-ep-${episode.id}`}>
|
||||
<div className="aspect-video overflow-hidden bg-[#0F0F0F] transition-transform duration-300 group-hover:scale-105">
|
||||
<AlbumArt url={show.backdrop_url || show.poster_url} />
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
|
||||
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="mt-2 text-white text-sm truncate">{show.title}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{episode.season_number}·E{episode.episode_number} · {episode.title}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
{shows.map((s) => (
|
||||
<button key={s.id} onClick={() => nav(`/show/${s.id}`)}
|
||||
className="group shrink-0 aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
|
||||
data-testid={`show-card-${s.id}`}>
|
||||
<AlbumArt url={s.poster_url || s.backdrop_url} loading="lazy" />
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<p className="text-white text-sm truncate">{s.title}</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{s.season_count} seasons · {s.episode_count} eps</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { Download, RefreshCcw, Check } from "lucide-react";
|
||||
|
||||
export default function SonarrImport() {
|
||||
const [series, setSeries] = useState([]);
|
||||
const [selected, setSelected] = useState(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { const { data } = await api.get("/sonarr/series"); setSeries(data); }
|
||||
catch (err) { toast.error(err.response?.data?.detail || "Could not load Sonarr series"); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const toggle = (id) => {
|
||||
const s = new Set(selected);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
setSelected(s);
|
||||
};
|
||||
|
||||
const importSelected = async () => {
|
||||
if (selected.size === 0) return;
|
||||
setImporting(true);
|
||||
try {
|
||||
const { data } = await api.post("/sonarr/import", { sonarr_ids: Array.from(selected) });
|
||||
toast.success(`Imported ${data.shows_imported} show(s), ${data.episodes_imported} episode(s)`);
|
||||
setSelected(new Set()); load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Import failed"); }
|
||||
finally { setImporting(false); }
|
||||
};
|
||||
|
||||
const withEpisodes = series.filter((s) => s.episode_file_count > 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="sonarr-import-page">
|
||||
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Sonarr</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import TV Series</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
{series.length === 0 && !loading ? "No series found — configure Sonarr in Settings first." : `${withEpisodes.length} series with episode files available.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex 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-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="sonarr-refresh">
|
||||
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
|
||||
</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="sonarr-import-btn">
|
||||
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{withEpisodes.map((m) => (
|
||||
<button key={m.sonarr_id} onClick={() => toggle(m.sonarr_id)}
|
||||
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.sonarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
|
||||
data-testid={`sonarr-card-${m.sonarr_id}`}>
|
||||
{m.poster_url ? <img src={m.poster_url} alt={m.title} className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-[#8A8A8A] text-xs px-3 text-center">{m.title}</div>}
|
||||
<div className="absolute inset-x-0 bottom-0 bg-black/70 p-2">
|
||||
<p className="text-xs text-white truncate">{m.title}</p>
|
||||
<p className="text-[10px] text-[#8A8A8A]">{m.year} · {m.episode_file_count} eps</p>
|
||||
</div>
|
||||
{selected.has(m.sonarr_id) && (
|
||||
<div className="absolute top-2 right-2 w-7 h-7 bg-[#D9381E] flex items-center justify-center">
|
||||
<Check size={14} strokeWidth={2} color="white" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import api from "../lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2, Volume2 } from "lucide-react";
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pending: "text-[#fcd34d]",
|
||||
running: "text-[#fcd34d]",
|
||||
done: "text-[#86efac]",
|
||||
failed: "text-[#fca5a5]",
|
||||
cancelled: "text-[#8A8A8A]",
|
||||
superseded: "text-[#8A8A8A]",
|
||||
};
|
||||
|
||||
export default function TranscodeQueue() {
|
||||
const [jobs, setJobs] = useState([]);
|
||||
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false, auto_transcode: "off" });
|
||||
const [runningJob, setRunningJob] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [j, s] = await Promise.all([api.get("/transcode/queue"), api.get("/transcode/queue/stats")]);
|
||||
setJobs(j.data);
|
||||
setStats(s.data);
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
// 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 — faster while something is actually running so the
|
||||
// progress bar feels live, slower while only pending jobs are waiting.
|
||||
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(`/transcode/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(`/transcode/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("/transcode/queue/retry-failed");
|
||||
toast.success(`Re-queued ${data.retried} job(s)`);
|
||||
load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
|
||||
};
|
||||
|
||||
const togglePause = async () => {
|
||||
try {
|
||||
const { data } = await api.post("/transcode/queue/pause", { paused: !stats.paused });
|
||||
toast.success(data.paused ? "Queue paused" : "Queue resumed");
|
||||
load();
|
||||
} catch { toast.error("Could not toggle pause"); }
|
||||
};
|
||||
|
||||
const fixAudioAll = async () => {
|
||||
if (!window.confirm(
|
||||
"Cancels every job still pending (not yet started) and replaces it with a fast audio-only fix — video is left untouched, only the audio track is re-encoded to AAC. This is much faster than the full ABR queue and fixes \"no sound\" caused by AC3/DTS/EAC3 audio (common on BDRips), which browsers can't play natively. Continue?"
|
||||
)) return;
|
||||
try {
|
||||
const { data } = await api.post("/transcode/queue/fix-audio-all");
|
||||
toast.success(`Queued audio fix for ${data.queued} item(s), replacing ${data.cancelled} pending job(s)`);
|
||||
load();
|
||||
} catch (err) { toast.error(err.response?.data?.detail || "Could not start"); }
|
||||
};
|
||||
|
||||
const clearFinished = async () => {
|
||||
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
|
||||
try { const { data } = await api.post("/transcode/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
|
||||
catch { toast.error("Could not clear"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
|
||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
|
||||
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||
One job runs at a time, moderate CPU priority, capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
|
||||
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
|
||||
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie — retry re-queues the same file.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{["pending", "running", "done", "failed", "cancelled"].map((k) => (
|
||||
<div key={k} className="border border-[#222] p-4" data-testid={`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="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="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="queue-retry-all">
|
||||
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
|
||||
</button>
|
||||
<button onClick={fixAudioAll} className="flex items-center gap-2 border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-fix-audio-all">
|
||||
<Volume2 size={14} strokeWidth={1.5} /> Fix Audio (Fast, All)
|
||||
</button>
|
||||
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-clear">
|
||||
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{runningJob && (
|
||||
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="now-processing">
|
||||
<div className="flex items-center justify-between text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
|
||||
Now Processing
|
||||
</span>
|
||||
<span>{(runningJob.progress || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
|
||||
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
|
||||
</div>
|
||||
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
{runningJob.quality} · {runningJob.triggered_by}
|
||||
</div>
|
||||
<div className="mt-3 h-1.5 bg-[#1a1a1a] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#D9381E] transition-[width] duration-500 ease-linear rounded-full"
|
||||
style={{ width: `${Math.max(2, runningJob.progress || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border border-[#222]">
|
||||
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
|
||||
<span className="col-span-5">Movie</span>
|
||||
<span className="col-span-1">Quality</span>
|
||||
<span className="col-span-1">Trigger</span>
|
||||
<span className="col-span-2">Status</span>
|
||||
<span className="col-span-2">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="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={`queue-row-${j.id}`}>
|
||||
<div className="col-span-5 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
|
||||
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.quality}</span>
|
||||
<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={`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-2 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleString()}</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={`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={`retry-${j.id}`}>
|
||||
<RotateCcw size={13} strokeWidth={1.5} /> Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# StreamHoard — one-line installer
|
||||
#
|
||||
# Usage (interactive):
|
||||
# curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh | sudo bash
|
||||
#
|
||||
# Usage (non-interactive, override defaults with env):
|
||||
# curl -fsSL .../install.sh | sudo KINO_HTTP_PORT=9000 MEDIA_ROOT_HOST=/mnt/nas/movies bash
|
||||
#
|
||||
# What it does:
|
||||
# 1. Installs Docker + Compose if missing (apt/dnf/yum)
|
||||
# 2. Clones the repo into $KINO_DIR (default /opt/kino)
|
||||
# 3. Generates a strong JWT_SECRET and (if omitted) ADMIN_PASSWORD
|
||||
# 4. Writes .env
|
||||
# 5. Runs `docker compose up -d`
|
||||
# 6. Prints URL + credentials
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- Defaults (override via env) ----------
|
||||
KINO_REPO="${KINO_REPO:-https://github.com/myronblair/kino-app.git}"
|
||||
KINO_BRANCH="${KINO_BRANCH:-main}"
|
||||
KINO_DIR="${KINO_DIR:-/opt/kino}"
|
||||
KINO_HTTP_PORT="${KINO_HTTP_PORT:-8080}"
|
||||
MEDIA_ROOT_HOST="${MEDIA_ROOT_HOST:-${KINO_DIR}/media}"
|
||||
DB_NAME="${DB_NAME:-kino}"
|
||||
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@streamhoard.local}"
|
||||
ADMIN_NAME="${ADMIN_NAME:-Admin}"
|
||||
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
|
||||
JWT_SECRET="${JWT_SECRET:-}"
|
||||
|
||||
# ---------- Pretty output ----------
|
||||
c_red() { printf "\033[31m%s\033[0m" "$*"; }
|
||||
c_grn() { printf "\033[32m%s\033[0m" "$*"; }
|
||||
c_ylw() { printf "\033[33m%s\033[0m" "$*"; }
|
||||
c_cyn() { printf "\033[36m%s\033[0m" "$*"; }
|
||||
say() { printf "%s %s\n" "$(c_cyn '›')" "$*"; }
|
||||
ok() { printf "%s %s\n" "$(c_grn '✓')" "$*"; }
|
||||
warn() { printf "%s %s\n" "$(c_ylw '!')" "$*"; }
|
||||
die() { printf "%s %s\n" "$(c_red '✗')" "$*" >&2; exit 1; }
|
||||
|
||||
# ---------- Preflight ----------
|
||||
[ "$(id -u)" -eq 0 ] || die "Run as root (or via sudo). Try: curl -fsSL ... | sudo bash"
|
||||
|
||||
if [ "$KINO_REPO" = "https://github.com/CHANGE_ME/kino-media-server.git" ]; then
|
||||
die "KINO_REPO placeholder — pass KINO_REPO=... env var to install.sh"
|
||||
fi
|
||||
|
||||
# ---------- OS detection & package manager ----------
|
||||
if [ -r /etc/os-release ]; then . /etc/os-release; fi
|
||||
OS_ID="${ID:-unknown}"
|
||||
say "Detected OS: ${OS_ID} ${VERSION_ID:-}"
|
||||
|
||||
pkg_install() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "$@"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y "$@"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y "$@"
|
||||
else
|
||||
die "No supported package manager (apt/dnf/yum) found."
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- Ensure prerequisites ----------
|
||||
need_pkgs=()
|
||||
command -v curl >/dev/null 2>&1 || need_pkgs+=(curl)
|
||||
command -v git >/dev/null 2>&1 || need_pkgs+=(git)
|
||||
command -v openssl >/dev/null 2>&1 || need_pkgs+=(openssl)
|
||||
command -v ca-certificates >/dev/null 2>&1 || need_pkgs+=(ca-certificates)
|
||||
|
||||
if [ ${#need_pkgs[@]} -gt 0 ]; then
|
||||
say "Installing prerequisites: ${need_pkgs[*]}"
|
||||
pkg_install "${need_pkgs[@]}" >/dev/null
|
||||
ok "Prerequisites installed"
|
||||
fi
|
||||
|
||||
# ---------- Docker ----------
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
say "Installing Docker via official convenience script"
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable --now docker
|
||||
ok "Docker installed"
|
||||
else
|
||||
ok "Docker already installed ($(docker --version))"
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
# docker compose plugin missing — install
|
||||
say "Installing docker compose plugin"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
pkg_install docker-compose-plugin >/dev/null
|
||||
else
|
||||
die "docker compose plugin not found — install it manually for your distro."
|
||||
fi
|
||||
ok "Compose plugin installed"
|
||||
else
|
||||
ok "Docker Compose already available ($(docker compose version --short))"
|
||||
fi
|
||||
|
||||
# ---------- Clone or update repo ----------
|
||||
if [ -d "$KINO_DIR/.git" ]; then
|
||||
say "Updating existing checkout at $KINO_DIR"
|
||||
git -C "$KINO_DIR" fetch --quiet origin "$KINO_BRANCH"
|
||||
git -C "$KINO_DIR" checkout --quiet "$KINO_BRANCH"
|
||||
git -C "$KINO_DIR" pull --quiet --ff-only origin "$KINO_BRANCH" || warn "git pull skipped (local changes?)"
|
||||
else
|
||||
say "Cloning $KINO_REPO → $KINO_DIR"
|
||||
mkdir -p "$(dirname "$KINO_DIR")"
|
||||
git clone --depth 1 --branch "$KINO_BRANCH" "$KINO_REPO" "$KINO_DIR"
|
||||
ok "Repo cloned"
|
||||
fi
|
||||
|
||||
cd "$KINO_DIR"
|
||||
|
||||
# ---------- Media root ----------
|
||||
mkdir -p "$MEDIA_ROOT_HOST"
|
||||
ok "Media dir ready: $MEDIA_ROOT_HOST"
|
||||
|
||||
# ---------- Secrets ----------
|
||||
if [ -z "$JWT_SECRET" ]; then
|
||||
JWT_SECRET="$(openssl rand -hex 32)"
|
||||
ok "Generated random JWT_SECRET"
|
||||
fi
|
||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
||||
ADMIN_PASSWORD="$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20)"
|
||||
GENERATED_PASSWORD=1
|
||||
ok "Generated random ADMIN_PASSWORD"
|
||||
fi
|
||||
|
||||
# ---------- Write .env ----------
|
||||
if [ -f .env ]; then
|
||||
cp .env ".env.bak.$(date +%Y%m%d%H%M%S)"
|
||||
warn ".env exists — backed up. Overwriting with new values."
|
||||
fi
|
||||
|
||||
cat > .env <<EOF
|
||||
# Auto-generated by install.sh on $(date -Iseconds)
|
||||
KINO_HTTP_PORT=${KINO_HTTP_PORT}
|
||||
MEDIA_ROOT_HOST=${MEDIA_ROOT_HOST}
|
||||
DB_NAME=${DB_NAME}
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
JWT_EXPIRE_HOURS=720
|
||||
ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ADMIN_NAME=${ADMIN_NAME}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
ok "Wrote $KINO_DIR/.env (chmod 600)"
|
||||
|
||||
# ---------- Build & start ----------
|
||||
say "Building images (first run may take a few minutes — ffmpeg, node build)"
|
||||
docker compose build --pull
|
||||
say "Starting containers"
|
||||
docker compose up -d
|
||||
sleep 4
|
||||
|
||||
# ---------- Health check ----------
|
||||
HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
[ -z "$HOST_IP" ] && HOST_IP="localhost"
|
||||
URL="http://${HOST_IP}:${KINO_HTTP_PORT}"
|
||||
|
||||
if curl -fsS --max-time 10 "http://localhost:${KINO_HTTP_PORT}/api/" >/dev/null 2>&1; then
|
||||
ok "Backend healthy at $URL/api/"
|
||||
else
|
||||
warn "Backend not responding yet — check: cd $KINO_DIR && docker compose logs -f backend"
|
||||
fi
|
||||
|
||||
# ---------- Done ----------
|
||||
cat <<EOF
|
||||
|
||||
$(c_grn '━━━ StreamHoard is up ━━━')
|
||||
|
||||
URL : $(c_cyn "$URL")
|
||||
Admin : $(c_cyn "$ADMIN_EMAIL")
|
||||
Password : $(c_cyn "$ADMIN_PASSWORD")
|
||||
Config : ${KINO_DIR}/.env
|
||||
Media : ${MEDIA_ROOT_HOST}
|
||||
|
||||
$(c_ylw 'Save the password now — it is only printed once.')
|
||||
|
||||
Manage:
|
||||
cd $KINO_DIR
|
||||
docker compose logs -f backend # live logs
|
||||
docker compose down # stop
|
||||
docker compose up -d # start
|
||||
docker compose pull && docker compose up -d --build # update
|
||||
|
||||
Full docs: $KINO_DIR/DEPLOY.md
|
||||
EOF
|
||||
+33
-7
@@ -44,17 +44,43 @@
|
||||
- **Admin Settings page** for TMDB + Radarr config with masked inputs + connection test
|
||||
|
||||
### Phase 3 (2026-04-29)
|
||||
- **Adaptive bitrate (ABR) HLS**: admin can choose Quick (stream-copy, single-rate, instant) OR ABR (re-encode to 360p/480p/720p/1080p with master playlist)
|
||||
- **Smart variant selection**: ABR ladder auto-trims to source resolution (no upscaling)
|
||||
- **Auth-aware playlist serving**: master.m3u8 and per-variant playlists are rewritten on serve to inject `?auth=token` into all relative URLs, so hls.js segment requests stay authenticated
|
||||
- **Quality badge** in admin row shows whether HLS is `Quick` or `ABR`
|
||||
- **Defensive `-pix_fmt yuv420p`** ensures encoder works across all source chroma formats
|
||||
- Verified end-to-end: 720p source → 3 variants (720p/480p/360p) generated in seconds with proper master playlist
|
||||
- **Adaptive bitrate (ABR) HLS**: admin chooses Quick (stream-copy, instant) or ABR (re-encode to 360p/480p/720p/1080p with master playlist)
|
||||
- Smart variant selection: ladder auto-trims to source resolution (no upscaling)
|
||||
- Auth-aware playlist serving rewrites `?auth=` into every relative URL so hls.js segment requests stay authenticated
|
||||
- Quality badge in admin row shows `HLS done · Quick` or `HLS done · ABR`
|
||||
- Defensive `-pix_fmt yuv420p` ensures encoder works across all source chroma formats
|
||||
|
||||
### Phase 4 (2026-04-29)
|
||||
- **Background Transcode Queue** with persistent storage in MongoDB
|
||||
- Single FIFO worker started at backend boot; processes one job at a time
|
||||
- ffmpeg runs at **low CPU priority** (`nice -n 19`) so streaming + UI stay snappy during transcodes
|
||||
- **Auto-transcode** toggle in Settings: Off / Quick / ABR — applied to every new upload AND Radarr import
|
||||
- **Pause/resume** queue, **cancel** pending jobs, **retry** failed/cancelled jobs, **clear** finished history
|
||||
- **Stat tiles** (pending/running/done/failed/cancelled) on `/admin/queue`
|
||||
- **Crash recovery**: stuck `running` jobs are reset to `failed` on backend restart
|
||||
- Cancel of a pending job clears `hls_path` to avoid stale references
|
||||
|
||||
### Phase 6 (2026-07-23)
|
||||
- **Season packs** — new `/admin/shows/:id/episodes` grid: season filter chips, +all-in-season selector, per-row and select-all checkboxes, sticky bulk action bar
|
||||
- **Bulk actions**: Quick HLS transcode, ABR HLS transcode, Hide, Unhide, Delete (with confirm)
|
||||
- **Episode transcode queue** — extended queue schema with `content_type` so episodes ride the same FIFO worker as movies
|
||||
- **Hidden episodes** — filtered out of `/shows/{id}/episodes` for members
|
||||
- **Admin dashboard** now has a "Series" section listing every imported show with a "Manage Episodes" link
|
||||
- **Auto-subtitles + auto-transcode on Sonarr/Radarr import** — imports are hands-off; bulk actions are for after-the-fact curation
|
||||
|
||||
## Rebrand voice
|
||||
No Netflix-isms — Library / Series / Shelf / Wishlist / Watch / Resume / Popular / Fresh Arrivals / In the Spotlight / Choose a viewer.
|
||||
|
||||
### Phase 5 (2026-07-23)
|
||||
- **Sonarr / TV Shows** — Show → Episode data model, Sonarr library import (multi-select), season/episode picker UI, dedicated `/shows` browse page
|
||||
- **Episode player** with auto-next-episode CTA in last 20s, auto-advance on video end, profile-scoped progress + continue-watching
|
||||
- **Trakt.tv sync** — OAuth device-code flow to connect account, auto-scrobble on every movie AND episode progress update (start/pause/stop based on % watched)
|
||||
- **OpenSubtitles auto-search** — one-click subtitle search by TMDB ID in the upload flow, auto-converts SRT to WEBVTT, attaches directly to movie
|
||||
- **Bulk TMDB enrichment** — sweeps every existing movie with missing metadata (cast/director/description/poster) and backfills from TMDB in one click
|
||||
|
||||
## Backlog (P1)
|
||||
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
|
||||
- Subtitle search via OpenSubtitles API
|
||||
- Multi-bitrate HLS (current is single-rate stream-copy)
|
||||
- Trakt.tv sync (mark watched, ratings)
|
||||
- DLNA/Chromecast casting
|
||||
|
||||
|
||||
Reference in New Issue
Block a user