Compare commits

...

7 Commits

Author SHA1 Message Date
emergent-agent-e1 211d4c23d5 auto-commit for 296e06eb-544f-4710-845e-c5a611b15f0e 2026-07-23 23:03:09 +00:00
emergent-agent-e1 bd8480bd14 auto-commit for 0175172f-4f7e-4804-ad7f-68312dcdad0d 2026-07-23 22:23:41 +00:00
emergent-agent-e1 22d8479b39 auto-commit for b082c1c8-f6e3-4cfd-82ee-0396c70d1a00 2026-07-23 22:18:08 +00:00
emergent-agent-e1 34a052dabf auto-commit for 39cc8e25-6412-479f-a29a-2a2bbf6e58ba 2026-07-23 22:04:55 +00:00
emergent-agent-e1 1471d58385 auto-commit for 28f1e986-c2af-4742-ad64-e0661980843c 2026-07-23 22:01:21 +00:00
emergent-agent-e1 21cdd24116 auto-commit for b6946a78-11ff-4dc8-85b6-12e35b57d256 2026-04-29 16:34:49 +00:00
emergent-agent-e1 da8a2903b4 auto-commit for b5490b7b-d57f-4f53-9a99-bd8ed84027db 2026-04-29 16:33:26 +00:00
36 changed files with 2993 additions and 178 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"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"
"created_at": "2026-07-23T22:57:32.873830+00:00Z"
}
+1
View File
@@ -104,3 +104,4 @@ credentials.json
*.pem
*.key
.credentials
frontend/node_modules/.cache/default-development/0.pack
+189
View File
@@ -0,0 +1,189 @@
# Kino — Deployment on a Proxmox LAMP host
Kino is FastAPI + MongoDB + React. It runs happily alongside your existing
LAMP stack (Apache/MySQL/PHP) because we run everything in Docker containers
on their own network. Apache keeps port 80/443, MySQL is untouched, Kino
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 Kino 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
| Kino 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 Kino
at it via **Admin → Settings → Radarr**:
- **URL**: `http://<host-ip>:7878` (use the host IP, not `localhost` — containers
can't reach the host's `localhost` unless you add `host.docker.internal`)
- **API key**: from Radarr → Settings → General → Security
**Important**: For Kino to actually play the imported movies, the path Radarr
reports (e.g. `/movies/Inception (2010)/Inception.mkv`) must be **readable from
inside the Kino backend container**. Mount Radarr's media path into Kino:
```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 Kino containers
(easiest: match the paths exactly).
---
## Health check
```bash
curl -s http://localhost:${KINO_HTTP_PORT:-8080}/api/ | jq
# {"app":"Kino","status":"ok"}
```
---
## Firewalling
Kino 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}
```
+26
View File
@@ -3,6 +3,32 @@
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
+23
View File
@@ -0,0 +1,23 @@
# Kino backend — FastAPI + ffmpeg
FROM python:3.11-slim
# ffmpeg for HLS transcoding; util-linux for `nice` (already in base but explicit)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& 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"]
+147 -1
View File
@@ -175,15 +175,161 @@ 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
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
# ---------- 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"
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 ----------
+62
View File
@@ -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")
+699 -20
View File
@@ -23,11 +23,17 @@ from models import (
RequestCreate, RequestUpdate, MovieRequest,
AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle,
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
)
from auth import hash_password, verify_password, create_token, decode_token
from seed import SAMPLE_MOVIES
import tmdb as tmdb_client
import radarr as radarr_client
import sonarr as sonarr_client
import trakt as trakt_client
import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, srt_to_vtt
@@ -158,6 +164,16 @@ async def on_startup():
except Exception as e:
logger.warning(f"progress unique index skipped: {e}")
# Reset any stuck "running" jobs from a previous backend crash
await db.transcode_queue.update_many(
{"status": "running"},
{"$set": {"status": "failed", "error": "Backend restarted while running"}},
)
await db.transcode_queue.create_index([("status", 1), ("created_at", 1)])
# Start the background transcode worker
asyncio.create_task(_transcode_worker())
@app.on_event("shutdown")
async def on_shutdown():
@@ -394,6 +410,12 @@ async def upload_video(
)
doc = movie.model_dump()
await db.movies.insert_one(doc)
# Auto-enqueue transcode if enabled
s = await get_settings()
auto = s.get("auto_transcode", "off")
if auto in ("quick", "abr"):
await _enqueue_transcode(doc["id"], auto, triggered_by="auto")
return _strip(doc)
@@ -469,27 +491,107 @@ async def _set_hls_status(movie_id: str, status: str, path: Optional[str] = None
logger.warning(f"HLS {movie_id}: {error}")
async def _run_transcode(movie_id: str, source: Path, quality: str):
out_dir = HLS_DIR / movie_id
# Clear any prior output before re-encoding
async def _process_job(job: dict):
"""Run a transcode job. Handles both movies and episodes."""
content_type = job.get("content_type", "movie")
coll = db.movies if content_type == "movie" else db.episodes
doc = await coll.find_one({"id": job["movie_id"]}, {"_id": 0})
if not doc:
await db.transcode_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": f"{content_type} not found", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
if doc.get("storage_type") not in ("local", "radarr", "sonarr") or not doc.get("storage_path"):
await db.transcode_queue.update_one(
{"id": job["id"]},
{"$set": {"status": "failed", "error": "Not a local file", "finished_at": datetime.now(timezone.utc).isoformat()}},
)
return
source = (
Path(doc["storage_path"]) if doc["storage_type"] in ("radarr", "sonarr")
else VIDEOS_DIR / doc["storage_path"]
)
out_dir = HLS_DIR / job["movie_id"]
import shutil as _sh
_sh.rmtree(out_dir, ignore_errors=True)
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
if status == "done" and entry:
await _set_hls_status(movie_id, "done", path=entry)
else:
await _set_hls_status(movie_id, status, error=error)
last_error = {"msg": ""}
if quality == "abr":
await transcode_abr(source, out_dir, cb)
else:
await transcode_quick(source, out_dir, cb)
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
update = {"hls_status": status}
if status == "done" and entry: update["hls_path"] = entry
await coll.update_one({"id": job["movie_id"]}, {"$set": update})
if error: last_error["msg"] = error
try:
if job["quality"] == "abr":
await transcode_abr(source, out_dir, cb)
else:
await transcode_quick(source, out_dir, cb)
except Exception as e:
logger.exception("transcode crashed")
last_error["msg"] = str(e)
await coll.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": "failed"}})
after = await coll.find_one({"id": job["movie_id"]}, {"_id": 0, "hls_status": 1})
final_status = "done" if after and after.get("hls_status") == "done" else "failed"
await db.transcode_queue.update_one(
{"id": job["id"]},
{"$set": {"status": final_status, "error": last_error["msg"], "finished_at": datetime.now(timezone.utc).isoformat()}},
)
async def _transcode_worker():
"""Single FIFO worker. Polls for pending jobs and runs them serially."""
logger.info("Transcode worker started")
while True:
try:
settings = await get_settings()
if settings.get("queue_paused", False):
await asyncio.sleep(15)
continue
job = await db.transcode_queue.find_one_and_update(
{"status": "pending"},
{"$set": {"status": "running", "started_at": datetime.now(timezone.utc).isoformat()}},
sort=[("created_at", 1)],
projection={"_id": 0},
return_document=True,
)
if not job:
await asyncio.sleep(8)
continue
await _set_hls_status(job["movie_id"], "running")
await _process_job(job)
except Exception:
logger.exception("Transcode worker error")
await asyncio.sleep(10)
async def _enqueue_transcode(content_id: str, quality: str, triggered_by: str = "manual", content_type: str = "movie") -> dict:
"""Add a job to the queue. Returns the job document. Works for movies + episodes."""
coll = db.movies if content_type == "movie" else db.episodes
doc = await coll.find_one({"id": content_id}, {"_id": 0, "title": 1})
title = doc.get("title", "") if doc else ""
existing = await db.transcode_queue.find_one(
{"movie_id": content_id, "status": {"$in": ["pending", "running"]}},
{"_id": 0},
)
if existing:
return existing
job = TranscodeJob(
movie_id=content_id, movie_title=title, quality=quality,
triggered_by=triggered_by, content_type=content_type,
).model_dump()
await db.transcode_queue.insert_one(job)
await coll.update_one({"id": content_id}, {"$set": {"hls_status": "pending"}})
return job
@api.post("/movies/{movie_id}/transcode")
async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
"""Body: {"quality": "quick"|"abr"} — default "quick"."""
"""Body: {"quality": "quick"|"abr"} — default "quick". Adds to background queue."""
quality = (payload or {}).get("quality", "quick")
if quality not in ("quick", "abr"):
raise HTTPException(status_code=400, detail="quality must be 'quick' or 'abr'")
@@ -498,11 +600,66 @@ async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user:
if movie.get("storage_type") not in ("local", "radarr") or not movie.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
if movie.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding")
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"]
await _set_hls_status(movie_id, "pending")
asyncio.create_task(_run_transcode(movie_id, source, quality))
return {"ok": True, "status": "pending", "quality": quality}
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
return {"ok": True, "status": "pending", "quality": quality, "job_id": job["id"]}
# ============ Transcode Queue endpoints ============
@api.get("/transcode/queue", response_model=List[TranscodeJob])
async def list_queue(status: Optional[str] = None, user: dict = Depends(require_admin)):
q: dict = {}
if status:
q["status"] = {"$in": status.split(",")}
rows = await db.transcode_queue.find(q, {"_id": 0}).sort("created_at", -1).to_list(500)
return rows
@api.get("/transcode/queue/stats")
async def queue_stats(user: dict = Depends(require_admin)):
pipeline = [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]
out = {"pending": 0, "running": 0, "done": 0, "failed": 0, "cancelled": 0}
async for row in db.transcode_queue.aggregate(pipeline):
out[row["_id"]] = row["n"]
s = await get_settings()
out["paused"] = bool(s.get("queue_paused", False))
out["auto_transcode"] = s.get("auto_transcode", "off")
return out
@api.delete("/transcode/queue/{job_id}")
async def cancel_job(job_id: str, user: dict = Depends(require_admin)):
job = await db.transcode_queue.find_one({"id": job_id}, {"_id": 0})
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] == "running":
raise HTTPException(status_code=400, detail="Cannot cancel a running job")
await db.transcode_queue.update_one({"id": job_id}, {"$set": {"status": "cancelled", "finished_at": datetime.now(timezone.utc).isoformat()}})
if job["status"] == "pending":
# Reset movie hls_status AND clear stale hls_path so the row no longer shows as transcoded
await db.movies.update_one({"id": job["movie_id"]}, {"$set": {"hls_status": None, "hls_path": None}})
return {"ok": True}
@api.post("/transcode/queue/{job_id}/retry")
async def retry_job(job_id: str, user: dict = Depends(require_admin)):
job = await db.transcode_queue.find_one({"id": job_id}, {"_id": 0})
if not job: raise HTTPException(status_code=404, detail="Job not found")
if job["status"] not in ("failed", "cancelled"):
raise HTTPException(status_code=400, detail="Only failed/cancelled jobs can be retried")
new_job = await _enqueue_transcode(job["movie_id"], job["quality"], triggered_by=job.get("triggered_by", "manual"))
return {"ok": True, "job_id": new_job["id"]}
@api.post("/transcode/queue/clear")
async def clear_finished(user: dict = Depends(require_admin)):
res = await db.transcode_queue.delete_many({"status": {"$in": ["done", "failed", "cancelled"]}})
return {"ok": True, "deleted": res.deleted_count}
@api.post("/transcode/queue/pause")
async def pause_queue(payload: QueuePauseToggle, user: dict = Depends(require_admin)):
await db.settings.update_one({"id": "app"}, {"$set": {"queue_paused": payload.paused}}, upsert=True)
return {"ok": True, "paused": payload.paused}
@api.get("/movies/{movie_id}/hls/{filename:path}")
@@ -549,6 +706,12 @@ async def list_subs(movie_id: str, user: dict = Depends(get_current_user)):
return docs
@api.get("/episodes/{episode_id}/subtitles", response_model=List[Subtitle])
async def list_episode_subs(episode_id: str, user: dict = Depends(get_current_user)):
docs = await db.subtitles.find({"episode_id": episode_id}, {"_id": 0}).sort("created_at", 1).to_list(20)
return docs
@api.post("/movies/{movie_id}/subtitles", response_model=Subtitle)
async def upload_sub(
movie_id: str,
@@ -645,6 +808,10 @@ async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_a
"updated_at": now,
}}, upsert=True,
)
# Trakt scrobble hook (fire and forget)
movie = await db.movies.find_one({"id": payload.movie_id}, {"_id": 0, "tmdb_id": 1})
if movie:
asyncio.create_task(_trakt_scrobble_movie(profile["user_id"], movie, payload.position_seconds, payload.duration_seconds))
return {"ok": True}
@@ -703,9 +870,19 @@ async def read_settings(user: dict = Depends(require_admin)):
return AppSettingsPublic(
tmdb_configured=bool(s.get("tmdb_api_key")),
radarr_configured=bool(s.get("radarr_api_key") and s.get("radarr_url")),
sonarr_configured=bool(s.get("sonarr_api_key") and s.get("sonarr_url")),
opensubs_configured=bool(s.get("opensubs_api_key")),
trakt_configured=bool(s.get("trakt_client_id") and s.get("trakt_client_secret")),
tmdb_api_key=s.get("tmdb_api_key", ""),
radarr_url=s.get("radarr_url", ""),
radarr_api_key=s.get("radarr_api_key", ""),
sonarr_url=s.get("sonarr_url", ""),
sonarr_api_key=s.get("sonarr_api_key", ""),
opensubs_api_key=s.get("opensubs_api_key", ""),
trakt_client_id=s.get("trakt_client_id", ""),
trakt_client_secret=s.get("trakt_client_secret", ""),
auto_transcode=s.get("auto_transcode", "off"),
queue_paused=bool(s.get("queue_paused", False)),
)
@@ -717,9 +894,19 @@ async def update_settings(payload: AppSettings, user: dict = Depends(require_adm
return AppSettingsPublic(
tmdb_configured=bool(doc.get("tmdb_api_key")),
radarr_configured=bool(doc.get("radarr_api_key") and doc.get("radarr_url")),
sonarr_configured=bool(doc.get("sonarr_api_key") and doc.get("sonarr_url")),
opensubs_configured=bool(doc.get("opensubs_api_key")),
trakt_configured=bool(doc.get("trakt_client_id") and doc.get("trakt_client_secret")),
tmdb_api_key=doc.get("tmdb_api_key", ""),
radarr_url=doc.get("radarr_url", ""),
radarr_api_key=doc.get("radarr_api_key", ""),
sonarr_url=doc.get("sonarr_url", ""),
sonarr_api_key=doc.get("sonarr_api_key", ""),
opensubs_api_key=doc.get("opensubs_api_key", ""),
trakt_client_id=doc.get("trakt_client_id", ""),
trakt_client_secret=doc.get("trakt_client_secret", ""),
auto_transcode=doc.get("auto_transcode", "off"),
queue_paused=bool(doc.get("queue_paused", False)),
)
@@ -780,10 +967,10 @@ async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
movies = await asyncio.to_thread(radarr_client.list_movies, s["radarr_url"], s["radarr_api_key"])
by_id = {m["radarr_id"]: m for m in movies}
created = 0
auto = s.get("auto_transcode", "off")
for rid in radarr_ids:
m = by_id.get(int(rid))
if not m or not m.get("has_file") or not m.get("file_path"): continue
# Skip if already imported
if await db.movies.find_one({"radarr_id": m["radarr_id"]}, {"_id": 1}): continue
movie = Movie(
title=m["title"], description=m.get("overview", ""),
@@ -792,14 +979,506 @@ async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
poster_url=m.get("poster_url", ""),
backdrop_url=m.get("poster_url", ""),
video_url="", storage_type="radarr",
storage_path=m["file_path"], # absolute path on Radarr-mounted storage
storage_path=m["file_path"],
radarr_id=m["radarr_id"], tmdb_id=m.get("tmdb_id"),
).model_dump()
await db.movies.insert_one(movie)
if auto in ("quick", "abr"):
await _enqueue_transcode(movie["id"], auto, triggered_by="auto")
# Auto-fetch English subtitle (fire and forget)
asyncio.create_task(_auto_fetch_subtitle("movie", movie["id"], movie.get("tmdb_id")))
created += 1
return {"ok": True, "imported": created}
# ============ SHOWS (TV) ============
@api.get("/shows", response_model=List[Show])
async def list_shows(profile: dict = Depends(get_active_profile)):
query = _movie_filter_for_profile(profile)
docs = await db.shows.find(query, {"_id": 0}).sort("created_at", -1).to_list(500)
return docs
@api.get("/shows/featured", response_model=Show)
async def show_featured(profile: dict = Depends(get_active_profile)):
base = _movie_filter_for_profile(profile)
doc = await db.shows.find_one({**base, "featured": True}, {"_id": 0})
if not doc:
doc = await db.shows.find_one(base, {"_id": 0})
if not doc:
raise HTTPException(status_code=404, detail="No shows")
return doc
@api.get("/shows/{show_id}", response_model=Show)
async def get_show(show_id: str, user: dict = Depends(get_current_user)):
doc = await db.shows.find_one({"id": show_id}, {"_id": 0})
if not doc: raise HTTPException(status_code=404, detail="Show not found")
return doc
@api.post("/shows", response_model=Show)
async def create_show(payload: ShowCreate, user: dict = Depends(require_admin)):
doc = Show(**payload.model_dump()).model_dump()
await db.shows.insert_one(doc)
return _strip(doc)
@api.patch("/shows/{show_id}", response_model=Show)
async def update_show(show_id: str, payload: ShowUpdate, user: dict = Depends(require_admin)):
updates = {k: v for k, v in payload.model_dump().items() if v is not None}
if not updates: raise HTTPException(status_code=400, detail="No fields")
res = await db.shows.find_one_and_update({"id": show_id}, {"$set": updates}, projection={"_id": 0}, return_document=True)
if not res: raise HTTPException(status_code=404, detail="Not found")
return res
@api.delete("/shows/{show_id}")
async def delete_show(show_id: str, user: dict = Depends(require_admin)):
await db.shows.delete_one({"id": show_id})
await db.episodes.delete_many({"show_id": show_id})
await db.episode_progress.delete_many({"show_id": show_id})
return {"ok": True}
@api.get("/shows/{show_id}/episodes", response_model=List[Episode])
async def list_episodes(show_id: str, include_hidden: bool = False, user: dict = Depends(get_current_user)):
q: dict = {"show_id": show_id}
if not include_hidden or not user.get("is_admin"):
q["hidden"] = {"$ne": True}
docs = await db.episodes.find(q, {"_id": 0}).sort([("season_number", 1), ("episode_number", 1)]).to_list(2000)
return docs
@api.get("/episodes/{episode_id}", response_model=Episode)
async def get_episode(episode_id: str, user: dict = Depends(get_current_user)):
doc = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not doc: raise HTTPException(status_code=404, detail="Episode not found")
return doc
@api.post("/episodes", response_model=Episode)
async def create_episode(payload: EpisodeCreate, user: dict = Depends(require_admin)):
if not await db.shows.find_one({"id": payload.show_id}, {"_id": 1}):
raise HTTPException(status_code=404, detail="Show not found")
doc = Episode(**payload.model_dump()).model_dump()
await db.episodes.insert_one(doc)
# Refresh show counts
seasons = await db.episodes.distinct("season_number", {"show_id": payload.show_id})
ecount = await db.episodes.count_documents({"show_id": payload.show_id})
await db.shows.update_one({"id": payload.show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}})
return _strip(doc)
# ============ Episode bulk actions (admin) ============
@api.post("/episodes/bulk-transcode")
async def bulk_transcode_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
quality = payload.get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
queued = 0
for eid in ids:
ep = await db.episodes.find_one({"id": eid}, {"_id": 0})
if not ep or ep.get("hls_status") in ("running", "pending"): continue
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"): continue
await _enqueue_transcode(eid, quality, triggered_by="manual", content_type="episode")
queued += 1
return {"ok": True, "queued": queued}
@api.post("/episodes/bulk-hide")
async def bulk_hide_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
hidden = bool(payload.get("hidden", True))
res = await db.episodes.update_many({"id": {"$in": ids}}, {"$set": {"hidden": hidden}})
return {"ok": True, "modified": res.modified_count}
@api.post("/episodes/bulk-delete")
async def bulk_delete_eps(payload: dict, user: dict = Depends(require_admin)):
ids = payload.get("episode_ids") or []
import shutil as _sh
for eid in ids:
_sh.rmtree(HLS_DIR / eid, ignore_errors=True)
res = await db.episodes.delete_many({"id": {"$in": ids}})
await db.episode_progress.delete_many({"episode_id": {"$in": ids}})
await db.subtitles.delete_many({"episode_id": {"$in": ids}})
return {"ok": True, "deleted": res.deleted_count}
@api.post("/episodes/{episode_id}/transcode")
async def transcode_episode(episode_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
quality = (payload or {}).get("quality", "abr")
if quality not in ("quick", "abr"): raise HTTPException(status_code=400, detail="Invalid quality")
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Only local/sonarr episodes can be transcoded")
if ep.get("hls_status") in ("running", "pending"):
raise HTTPException(status_code=409, detail="Already transcoding or queued")
job = await _enqueue_transcode(episode_id, quality, triggered_by="manual", content_type="episode")
return {"ok": True, "job_id": job["id"]}
@api.get("/stream/episode/{episode_id}")
async def stream_episode(
episode_id: str, request: Request,
auth: Optional[str] = Query(None),
authorization: Optional[str] = Header(None),
):
token = authorization.split(" ", 1)[1].strip() if authorization and authorization.lower().startswith("bearer ") else auth
if not token: raise HTTPException(status_code=401, detail="Not authenticated")
decode_token(token)
ep = await db.episodes.find_one({"id": episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
if ep.get("storage_type") not in ("local", "sonarr") or not ep.get("storage_path"):
raise HTTPException(status_code=400, detail="Episode has no local file")
file_path = Path(ep["storage_path"]) if ep["storage_type"] == "sonarr" else VIDEOS_DIR / ep["storage_path"]
if not file_path.is_file(): raise HTTPException(status_code=404, detail="File missing")
file_size = file_path.stat().st_size
content_type = mimetypes.guess_type(str(file_path))[0] or "video/mp4"
range_header = request.headers.get("range") or request.headers.get("Range")
if range_header and range_header.startswith("bytes="):
try:
start_str, end_str = range_header.replace("bytes=", "").split("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
except ValueError:
raise HTTPException(status_code=416, detail="Invalid range")
end = min(end, file_size - 1); length = end - start + 1
def iter_file():
with file_path.open("rb") as f:
f.seek(start); remaining = length
while remaining > 0:
chunk = f.read(min(CHUNK_SIZE, remaining))
if not chunk: break
remaining -= len(chunk); yield chunk
return StreamingResponse(iter_file(), status_code=206, media_type=content_type, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes", "Content-Length": str(length), "Content-Type": content_type,
})
return FileResponse(str(file_path), media_type=content_type, headers={"Accept-Ranges": "bytes"})
# Episode progress
@api.post("/progress/episode")
async def upsert_episode_progress(payload: EpisodeProgressUpsert, profile: dict = Depends(get_active_profile)):
ep = await db.episodes.find_one({"id": payload.episode_id}, {"_id": 0})
if not ep: raise HTTPException(status_code=404, detail="Episode not found")
now = datetime.now(timezone.utc).isoformat()
await db.episode_progress.update_one(
{"profile_id": profile["id"], "episode_id": payload.episode_id},
{"$set": {
"profile_id": profile["id"], "user_id": profile["user_id"],
"episode_id": payload.episode_id, "show_id": ep["show_id"],
"position_seconds": payload.position_seconds,
"duration_seconds": payload.duration_seconds,
"updated_at": now,
}}, upsert=True,
)
# Trakt scrobble hook (fire and forget)
asyncio.create_task(_trakt_scrobble_episode(profile["user_id"], ep, payload.position_seconds, payload.duration_seconds))
return {"ok": True}
@api.get("/progress/episode/{episode_id}")
async def get_episode_progress(episode_id: str, profile: dict = Depends(get_active_profile)):
p = await db.episode_progress.find_one({"profile_id": profile["id"], "episode_id": episode_id}, {"_id": 0})
return p or {"position_seconds": 0, "duration_seconds": 0}
@api.get("/progress/episodes/continue")
async def continue_watching_episodes(profile: dict = Depends(get_active_profile)):
rows = await db.episode_progress.find({"profile_id": profile["id"]}, {"_id": 0}).sort("updated_at", -1).to_list(30)
rows = [r for r in rows if r.get("duration_seconds", 0) == 0 or r["position_seconds"] / max(r["duration_seconds"], 1) < 0.95]
if not rows: return []
ep_ids = [r["episode_id"] for r in rows]
eps = await db.episodes.find({"id": {"$in": ep_ids}}, {"_id": 0}).to_list(30)
ep_by_id = {e["id"]: e for e in eps}
show_ids = list({e["show_id"] for e in eps})
shows = await db.shows.find({"id": {"$in": show_ids}}, {"_id": 0}).to_list(30)
show_by_id = {s["id"]: s for s in shows}
out = []
for r in rows:
e = ep_by_id.get(r["episode_id"]); s = show_by_id.get(e["show_id"]) if e else None
if e and s: out.append({"episode": e, "show": s, "progress": r})
return out
# ============ SONARR ============
@api.post("/sonarr/test")
async def sonarr_test(user: dict = Depends(require_admin)):
s = await get_settings()
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
ok = await asyncio.to_thread(sonarr_client.test_connection, s["sonarr_url"], s["sonarr_api_key"])
return {"ok": ok}
@api.get("/sonarr/series", response_model=List[SonarrSeriesDTO])
async def sonarr_series(user: dict = Depends(require_admin)):
s = await get_settings()
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
return await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr error: {e}")
@api.post("/sonarr/import")
async def sonarr_import(payload: dict, user: dict = Depends(require_admin)):
"""Body: {sonarr_ids: [int]} — imports full series + all episodes with files."""
sonarr_ids = payload.get("sonarr_ids") or []
if not sonarr_ids: raise HTTPException(status_code=400, detail="No sonarr_ids")
s = await get_settings()
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
all_series = await asyncio.to_thread(sonarr_client.list_series, s["sonarr_url"], s["sonarr_api_key"])
by_id = {x["sonarr_id"]: x for x in all_series}
created_shows = 0; created_eps = 0
for sid in sonarr_ids:
m = by_id.get(int(sid))
if not m: continue
existing = await db.shows.find_one({"sonarr_id": m["sonarr_id"]}, {"_id": 0})
if existing:
show_id = existing["id"]
else:
show_doc = Show(
title=m["title"], description=m.get("overview", ""),
year=m.get("year") or 2024, rating="NR",
poster_url=m.get("poster_url", ""), backdrop_url=m.get("poster_url", ""),
sonarr_id=m["sonarr_id"], tvdb_id=m.get("tvdb_id"), tmdb_id=m.get("tmdb_id"),
).model_dump()
await db.shows.insert_one(show_doc)
show_id = show_doc["id"]; created_shows += 1
# Fetch episodes
try:
eps = await asyncio.to_thread(sonarr_client.list_episodes, s["sonarr_url"], s["sonarr_api_key"], m["sonarr_id"])
except Exception:
continue
for ep in eps:
if not ep.get("has_file") or not ep.get("file_path"): continue
if await db.episodes.find_one({"sonarr_episode_id": ep["sonarr_episode_id"]}, {"_id": 1}): continue
edoc = Episode(
show_id=show_id, season_number=ep["season_number"], episode_number=ep["episode_number"],
title=ep.get("title", ""), description=ep.get("description", ""),
air_date=ep.get("air_date", ""), duration_minutes=ep.get("duration_minutes", 0),
storage_type="sonarr", storage_path=ep["file_path"],
sonarr_episode_id=ep["sonarr_episode_id"],
).model_dump()
await db.episodes.insert_one(edoc); created_eps += 1
# Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers)
show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1})
if show_doc and show_doc.get("tmdb_id"):
asyncio.create_task(_auto_fetch_subtitle(
"episode", edoc["id"], show_doc["tmdb_id"],
season=ep["season_number"], episode=ep["episode_number"],
))
# Update counts
seasons = await db.episodes.distinct("season_number", {"show_id": show_id})
ecount = await db.episodes.count_documents({"show_id": show_id})
await db.shows.update_one({"id": show_id}, {"$set": {"season_count": len(seasons), "episode_count": ecount}})
return {"ok": True, "shows_imported": created_shows, "episodes_imported": created_eps}
# ============ TRAKT ============
async def _trakt_get_token(user_id: str) -> Optional[dict]:
return await db.trakt_tokens.find_one({"user_id": user_id}, {"_id": 0})
async def _trakt_scrobble_movie(user_id: str, movie: dict, position: float, duration: float):
if not movie.get("tmdb_id") or duration <= 0: return
tok = await _trakt_get_token(user_id)
if not tok: return
s = await get_settings()
if not s.get("trakt_client_id"): return
progress = (position / duration) * 100.0
action = "stop" if progress >= 80 else ("pause" if progress > 0 else "start")
try:
await asyncio.to_thread(trakt_client.scrobble_movie, s["trakt_client_id"], tok["access_token"], movie["tmdb_id"], progress, action)
except Exception as e:
logger.warning(f"Trakt scrobble movie failed: {e}")
async def _trakt_scrobble_episode(user_id: str, ep: dict, position: float, duration: float):
if duration <= 0: return
tok = await _trakt_get_token(user_id)
if not tok: return
s = await get_settings()
if not s.get("trakt_client_id"): return
show = await db.shows.find_one({"id": ep["show_id"]}, {"_id": 0})
if not show or (not show.get("tvdb_id") and not show.get("tmdb_id")): return
progress = (position / duration) * 100.0
action = "stop" if progress >= 80 else ("pause" if progress > 0 else "start")
try:
await asyncio.to_thread(trakt_client.scrobble_episode, s["trakt_client_id"], tok["access_token"],
show.get("tvdb_id"), show.get("tmdb_id"),
ep["season_number"], ep["episode_number"], progress, action)
except Exception as e:
logger.warning(f"Trakt scrobble episode failed: {e}")
@api.post("/trakt/device-code", response_model=TraktDeviceCodeResponse)
async def trakt_device_code(user: dict = Depends(get_current_user)):
s = await get_settings()
if not s.get("trakt_client_id"): raise HTTPException(status_code=400, detail="Trakt not configured (admin sets client_id/secret)")
try:
return await asyncio.to_thread(trakt_client.device_code, s["trakt_client_id"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Trakt error: {e}")
@api.post("/trakt/poll")
async def trakt_poll(payload: dict, user: dict = Depends(get_current_user)):
device_code_val = payload.get("device_code")
if not device_code_val: raise HTTPException(status_code=400, detail="device_code required")
s = await get_settings()
if not s.get("trakt_client_id") or not s.get("trakt_client_secret"):
raise HTTPException(status_code=400, detail="Trakt not configured")
try:
tok = await asyncio.to_thread(trakt_client.poll_token, s["trakt_client_id"], s["trakt_client_secret"], device_code_val)
except Exception as e:
raise HTTPException(status_code=502, detail=f"Trakt error: {e}")
if not tok:
return {"ok": False, "pending": True}
try:
us = await asyncio.to_thread(trakt_client.user_settings, s["trakt_client_id"], tok["access_token"])
username = ((us or {}).get("user") or {}).get("username", "")
except Exception:
username = ""
await db.trakt_tokens.update_one(
{"user_id": user["id"]},
{"$set": {"user_id": user["id"], "access_token": tok["access_token"],
"refresh_token": tok.get("refresh_token", ""),
"expires_in": tok.get("expires_in", 0), "username": username,
"created_at": datetime.now(timezone.utc).isoformat()}},
upsert=True,
)
return {"ok": True, "username": username}
@api.get("/trakt/status", response_model=TraktStatus)
async def trakt_status(user: dict = Depends(get_current_user)):
tok = await _trakt_get_token(user["id"])
if not tok: return TraktStatus(connected=False)
return TraktStatus(connected=True, username=tok.get("username", ""))
@api.delete("/trakt/disconnect")
async def trakt_disconnect(user: dict = Depends(get_current_user)):
await db.trakt_tokens.delete_one({"user_id": user["id"]})
return {"ok": True}
# ============ OPENSUBTITLES ============
@api.get("/opensubs/search", response_model=List[OpenSubsResult])
async def opensubs_search(tmdb_id: Optional[int] = None, q: Optional[str] = None,
languages: str = "en", season: Optional[int] = None, episode: Optional[int] = None,
user: dict = Depends(require_admin)):
s = await get_settings()
if not s.get("opensubs_api_key"): raise HTTPException(status_code=400, detail="OpenSubs not configured")
try:
return await asyncio.to_thread(opensubs_client.search, s["opensubs_api_key"], tmdb_id, q, languages, episode, season)
except Exception as e:
raise HTTPException(status_code=502, detail=f"OpenSubs error: {e}")
@api.post("/opensubs/download")
async def opensubs_download(payload: dict, user: dict = Depends(require_admin)):
"""Body: {file_id, movie_id?, episode_id?, language, label}"""
file_id = payload.get("file_id")
if not file_id: raise HTTPException(status_code=400, detail="file_id required")
movie_id = payload.get("movie_id"); episode_id = payload.get("episode_id")
if not movie_id and not episode_id: raise HTTPException(status_code=400, detail="movie_id or episode_id required")
lang = payload.get("language", "en"); label = payload.get("label", "English")
s = await get_settings()
if not s.get("opensubs_api_key"): raise HTTPException(status_code=400, detail="OpenSubs not configured")
try:
link = await asyncio.to_thread(opensubs_client.get_download_link, s["opensubs_api_key"], file_id)
srt = await asyncio.to_thread(opensubs_client.download_content, link)
vtt = srt_to_vtt(srt) if "WEBVTT" not in srt[:20] else srt
except Exception as e:
raise HTTPException(status_code=502, detail=f"OpenSubs download error: {e}")
sid = str(uuid.uuid4()); fname = f"{sid}.vtt"
(SUBS_DIR / fname).write_text(vtt, encoding="utf-8")
sub_doc = {
"id": sid, "movie_id": movie_id or "", "episode_id": episode_id or "",
"language": lang, "label": label, "storage_path": fname, "is_default": False,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.subtitles.insert_one(sub_doc)
_strip(sub_doc)
return {"ok": True, "subtitle": sub_doc}
# ============ BULK TMDB ENRICHMENT ============
@api.post("/tmdb/enrich-all")
async def tmdb_enrich_all(user: dict = Depends(require_admin)):
"""Sweep movies with missing metadata and fill from TMDB (by title match)."""
s = await get_settings()
key = s.get("tmdb_api_key", "")
if not key: raise HTTPException(status_code=400, detail="TMDB not configured")
enriched = 0; skipped = 0; failed = 0
async for m in db.movies.find({}, {"_id": 0}):
needs = not m.get("cast") or not m.get("director") or not m.get("description") or not m.get("poster_url")
if not needs:
skipped += 1; continue
try:
if m.get("tmdb_id"):
data = await asyncio.to_thread(tmdb_client.get_movie, key, m["tmdb_id"])
else:
results = await asyncio.to_thread(tmdb_client.search_movies, key, m["title"])
if not results: failed += 1; continue
data = await asyncio.to_thread(tmdb_client.get_movie, key, results[0]["tmdb_id"])
updates = {}
for field in ("description", "poster_url", "backdrop_url", "director", "duration_minutes", "rating"):
if data.get(field) and not m.get(field): updates[field] = data[field]
if data.get("cast") and not m.get("cast"): updates["cast"] = data["cast"]
if data.get("genres") and not m.get("genres"): updates["genres"] = data["genres"]
if data.get("tmdb_id"): updates["tmdb_id"] = data["tmdb_id"]
if updates:
await db.movies.update_one({"id": m["id"]}, {"$set": updates})
enriched += 1
except Exception as e:
logger.warning(f"Enrich failed for {m.get('title')}: {e}")
failed += 1
return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed}
async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int],
season: Optional[int] = None, episode: Optional[int] = None,
language: str = "en", label: str = "English"):
"""Best-effort background: pull first OpenSubs result and attach to movie/episode."""
if not tmdb_id: return
s = await get_settings()
key = s.get("opensubs_api_key", "")
if not key: return
try:
results = await asyncio.to_thread(opensubs_client.search, key, tmdb_id, None, language, episode, season)
if not results: return
r = results[0]
link = await asyncio.to_thread(opensubs_client.get_download_link, key, r["file_id"])
srt = await asyncio.to_thread(opensubs_client.download_content, link)
vtt = srt_to_vtt(srt) if "WEBVTT" not in srt[:20] else srt
except Exception as e:
logger.warning(f"Auto-subtitle failed for {content_type} {content_id}: {e}")
return
sid = str(uuid.uuid4()); fname = f"{sid}.vtt"
(SUBS_DIR / fname).write_text(vtt, encoding="utf-8")
doc = {
"id": sid,
"movie_id": content_id if content_type == "movie" else "",
"episode_id": content_id if content_type == "episode" else "",
"language": language, "label": label, "storage_path": fname,
"is_default": True,
"created_at": datetime.now(timezone.utc).isoformat(),
}
await db.subtitles.insert_one(doc)
logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}")
# ============ HEALTH ============
@api.get("/")
async def root():
+66
View File
@@ -0,0 +1,66 @@
"""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 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
+329
View File
@@ -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@kino.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]}@kino.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
+101
View File
@@ -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))),
})
+2 -1
View File
@@ -58,6 +58,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
return
out_dir.mkdir(parents=True, exist_ok=True)
cmd = [
"nice", "-n", "19",
"ffmpeg", "-y", "-i", str(source),
"-c:v", "copy", "-c:a", "copy",
"-bsf:v", "h264_mp4toannexb",
@@ -87,7 +88,7 @@ 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]
cmd: List[str] = ["nice", "-n", "19", "ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex]
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
cmd += [
+71
View File
@@ -0,0 +1,71 @@
# Kino — Docker Compose deployment
#
# Runs alongside existing LAMP stacks without conflicts:
# - Apache keeps port 80/443
# - MySQL is untouched (Kino uses its own MongoDB in a container)
# - Kino exposes a single configurable port (default 8080)
#
# 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:7
container_name: kino-mongo
restart: unless-stopped
volumes:
- mongo-data:/data/db
networks:
- kino
# No ports exposed to host — only reachable inside kino network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: kino-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@kino.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
networks:
- kino
# No direct host port — frontend container reverse-proxies /api → backend:8001
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: kino-frontend
restart: unless-stopped
depends_on:
- backend
ports:
# Only Kino's external port. Change KINO_HTTP_PORT to avoid clashes with Apache.
- "${KINO_HTTP_PORT:-8080}:80"
networks:
- kino
networks:
kino:
driver: bridge
volumes:
mongo-data:
driver: local
+29
View File
@@ -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;"]
+45
View File
@@ -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";
}
}
+1 -46
View File
@@ -38,52 +38,7 @@
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,
&quot;Segoe UI&quot;, Roboto, Oxygen, Ubuntu, Cantarell,
&quot;Open Sans&quot;, &quot;Helvetica Neue&quot;,
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>
<script>
!(function (t, e) {
var o, n, p, r;
e.__SV ||
+12
View File
@@ -17,6 +17,12 @@ 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";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -74,6 +80,9 @@ function App() {
<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>} />
@@ -82,6 +91,9 @@ function App() {
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
+4 -4
View File
@@ -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>
+4 -4
View File
@@ -30,11 +30,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 {
@@ -87,7 +87,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 +95,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>
+4 -3
View File
@@ -34,9 +34,10 @@ export const Navbar = () => {
</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="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
</nav>
)}
+38 -2
View File
@@ -7,11 +7,15 @@ 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,6 +85,9 @@ 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/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/settings" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-settings-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
</Link>
@@ -131,6 +138,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>
);
+162
View File
@@ -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 Kino 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>
);
}
+61
View File
@@ -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>
);
};
+4 -4
View File
@@ -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);
+107
View File
@@ -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>
);
}
+3 -3
View File
@@ -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>
+4 -4
View File
@@ -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 -1
View File
@@ -48,7 +48,7 @@ export default function ProfileSelect() {
<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?
Choose a viewer
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
+2 -2
View File
@@ -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>
+189 -75
View File
@@ -1,118 +1,232 @@
import { useEffect, useState } from "react";
import { useEffect, useState, createContext, useContext } from "react";
import api from "../lib/api";
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 [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,
});
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 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 });
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,
});
setInfo(data); setTraktStatus(ts);
};
useEffect(() => { load(); }, []);
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 () => {
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("/radarr/test");
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
} catch (err) {
toast.error(err.response?.data?.detail || "Test failed");
}
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 enrichAll = async () => {
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
};
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>
<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">
{/* TMDB */}
<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>
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2><Badge on={info.tmdb_configured} />
</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>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key (v3)</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 })}
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} />}
</button>
</div>
</label>
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
<Field label="API key (v3)" k="tmdb_api_key" mask />
{info.tmdb_configured && (
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
Bulk enrich all movies
</button>
)}
</section>
{/* Radarr */}
<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>
<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>
<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]">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="radarr-url-input" />
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>
<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} />}
</button>
</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
</button>
</section>
<button type="submit" disabled={saving}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="settings-save-button">
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
{saving ? "Saving…" : "Save Settings"}
</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>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Play, ArrowLeft } from "lucide-react";
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">
<img src={show.backdrop_url || show.poster_url} alt="" className="absolute inset-0 w-full h-full object-cover" />
<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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Tv } from "lucide-react";
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">
<img src={show.backdrop_url || show.poster_url} alt="" className="w-full h-full object-cover" />
</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}`}>
<img src={s.poster_url || s.backdrop_url} alt={s.title} loading="lazy" className="w-full h-full object-cover" />
<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>
);
}
+78
View File
@@ -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>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
};
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 [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); }
};
useEffect(() => { load(); }, []);
// Poll while jobs are active
useEffect(() => {
const active = jobs.some((j) => j.status === "running" || j.status === "pending");
if (!active) return;
const t = setInterval(load, 4000);
return () => clearInterval(t);
}, [jobs]);
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 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 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 at low CPU priority. 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>
</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={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>
</div>
<div className="mt-10 border border-[#222]">
<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>
{jobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="queue-empty">No jobs yet.</div>
) : jobs.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 === "running" && (
<button disabled className="text-[#444] cursor-not-allowed" title="Cannot cancel a running job" aria-label="Running">
<X size={14} strokeWidth={1.5} />
</button>
)}
{j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} />
</button>
)}
{(j.status === "failed" || j.status === "cancelled") && (
<button onClick={() => retry(j.id)} className="text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`retry-${j.id}`} aria-label="Retry">
<RotateCcw size={14} strokeWidth={1.5} />
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
Executable
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env bash
#
# Kino — 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@kino.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 '━━━ Kino 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
View File
@@ -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