mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 21:44:14 -05:00
Compare commits
7 Commits
966ab6a34f
...
211d4c23d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 211d4c23d5 | |||
| bd8480bd14 | |||
| 22d8479b39 | |||
| 34a052dabf | |||
| 1471d58385 | |||
| 21cdd24116 | |||
| da8a2903b4 |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1",
|
"env_image_name": "fastapi_react_mongo_shadcn_base_image_cloud_arm:release-17042026-1",
|
||||||
"job_id": "08d9a7a1-2a0c-4502-9939-fc5d643c04c4",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,3 +104,4 @@ credentials.json
|
|||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
.credentials
|
.credentials
|
||||||
|
frontend/node_modules/.cache/default-development/0.pack
|
||||||
|
|||||||
@@ -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}
|
||||||
|
```
|
||||||
@@ -3,6 +3,32 @@
|
|||||||
A self-hosted Netflix-style streaming app for movies you legally own.
|
A self-hosted Netflix-style streaming app for movies you legally own.
|
||||||
Built with FastAPI + React + MongoDB.
|
Built with FastAPI + React + MongoDB.
|
||||||
|
|
||||||
|
## ⚡ 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
|
## Features
|
||||||
|
|
||||||
- Cinematic browse UI with hero banner, horizontal carousels, hover details
|
- Cinematic browse UI with hero banner, horizontal carousels, hover details
|
||||||
|
|||||||
@@ -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
@@ -175,15 +175,161 @@ class AppSettings(BaseModel):
|
|||||||
tmdb_api_key: str = ""
|
tmdb_api_key: str = ""
|
||||||
radarr_url: str = ""
|
radarr_url: str = ""
|
||||||
radarr_api_key: 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):
|
class AppSettingsPublic(BaseModel):
|
||||||
"""Settings visible to admin UI (does not redact, since admin already has access)."""
|
|
||||||
tmdb_configured: bool = False
|
tmdb_configured: bool = False
|
||||||
radarr_configured: bool = False
|
radarr_configured: bool = False
|
||||||
|
sonarr_configured: bool = False
|
||||||
|
opensubs_configured: bool = False
|
||||||
|
trakt_configured: bool = False
|
||||||
tmdb_api_key: str = ""
|
tmdb_api_key: str = ""
|
||||||
radarr_url: str = ""
|
radarr_url: str = ""
|
||||||
radarr_api_key: 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 ----------
|
# ---------- Auth Tokens ----------
|
||||||
|
|||||||
@@ -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
@@ -23,11 +23,17 @@ from models import (
|
|||||||
RequestCreate, RequestUpdate, MovieRequest,
|
RequestCreate, RequestUpdate, MovieRequest,
|
||||||
AppSettings, AppSettingsPublic,
|
AppSettings, AppSettingsPublic,
|
||||||
TMDBSearchResult, RadarrMovieDTO,
|
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 auth import hash_password, verify_password, create_token, decode_token
|
||||||
from seed import SAMPLE_MOVIES
|
from seed import SAMPLE_MOVIES
|
||||||
import tmdb as tmdb_client
|
import tmdb as tmdb_client
|
||||||
import radarr as radarr_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
|
from transcode import transcode_quick, transcode_abr, srt_to_vtt
|
||||||
|
|
||||||
|
|
||||||
@@ -158,6 +164,16 @@ async def on_startup():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"progress unique index skipped: {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")
|
@app.on_event("shutdown")
|
||||||
async def on_shutdown():
|
async def on_shutdown():
|
||||||
@@ -394,6 +410,12 @@ async def upload_video(
|
|||||||
)
|
)
|
||||||
doc = movie.model_dump()
|
doc = movie.model_dump()
|
||||||
await db.movies.insert_one(doc)
|
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)
|
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}")
|
logger.warning(f"HLS {movie_id}: {error}")
|
||||||
|
|
||||||
|
|
||||||
async def _run_transcode(movie_id: str, source: Path, quality: str):
|
async def _process_job(job: dict):
|
||||||
out_dir = HLS_DIR / movie_id
|
"""Run a transcode job. Handles both movies and episodes."""
|
||||||
# Clear any prior output before re-encoding
|
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
|
import shutil as _sh
|
||||||
_sh.rmtree(out_dir, ignore_errors=True)
|
_sh.rmtree(out_dir, ignore_errors=True)
|
||||||
|
|
||||||
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
|
last_error = {"msg": ""}
|
||||||
if status == "done" and entry:
|
|
||||||
await _set_hls_status(movie_id, "done", path=entry)
|
|
||||||
else:
|
|
||||||
await _set_hls_status(movie_id, status, error=error)
|
|
||||||
|
|
||||||
if quality == "abr":
|
async def cb(status, entry: Optional[str] = None, error: Optional[str] = None):
|
||||||
await transcode_abr(source, out_dir, cb)
|
update = {"hls_status": status}
|
||||||
else:
|
if status == "done" and entry: update["hls_path"] = entry
|
||||||
await transcode_quick(source, out_dir, cb)
|
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")
|
@api.post("/movies/{movie_id}/transcode")
|
||||||
async def trigger_transcode(movie_id: str, payload: Optional[dict] = None, user: dict = Depends(require_admin)):
|
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")
|
quality = (payload or {}).get("quality", "quick")
|
||||||
if quality not in ("quick", "abr"):
|
if quality not in ("quick", "abr"):
|
||||||
raise HTTPException(status_code=400, detail="quality must be 'quick' or '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"):
|
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")
|
raise HTTPException(status_code=400, detail="Only local/radarr movies can be transcoded")
|
||||||
if movie.get("hls_status") in ("running", "pending"):
|
if movie.get("hls_status") in ("running", "pending"):
|
||||||
raise HTTPException(status_code=409, detail="Already transcoding")
|
raise HTTPException(status_code=409, detail="Already transcoding or queued")
|
||||||
source = Path(movie["storage_path"]) if movie["storage_type"] == "radarr" else VIDEOS_DIR / movie["storage_path"]
|
job = await _enqueue_transcode(movie_id, quality, triggered_by="manual")
|
||||||
await _set_hls_status(movie_id, "pending")
|
return {"ok": True, "status": "pending", "quality": quality, "job_id": job["id"]}
|
||||||
asyncio.create_task(_run_transcode(movie_id, source, quality))
|
|
||||||
return {"ok": True, "status": "pending", "quality": quality}
|
|
||||||
|
# ============ 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}")
|
@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
|
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)
|
@api.post("/movies/{movie_id}/subtitles", response_model=Subtitle)
|
||||||
async def upload_sub(
|
async def upload_sub(
|
||||||
movie_id: str,
|
movie_id: str,
|
||||||
@@ -645,6 +808,10 @@ async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_a
|
|||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
}}, upsert=True,
|
}}, 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}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -703,9 +870,19 @@ async def read_settings(user: dict = Depends(require_admin)):
|
|||||||
return AppSettingsPublic(
|
return AppSettingsPublic(
|
||||||
tmdb_configured=bool(s.get("tmdb_api_key")),
|
tmdb_configured=bool(s.get("tmdb_api_key")),
|
||||||
radarr_configured=bool(s.get("radarr_api_key") and s.get("radarr_url")),
|
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", ""),
|
tmdb_api_key=s.get("tmdb_api_key", ""),
|
||||||
radarr_url=s.get("radarr_url", ""),
|
radarr_url=s.get("radarr_url", ""),
|
||||||
radarr_api_key=s.get("radarr_api_key", ""),
|
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(
|
return AppSettingsPublic(
|
||||||
tmdb_configured=bool(doc.get("tmdb_api_key")),
|
tmdb_configured=bool(doc.get("tmdb_api_key")),
|
||||||
radarr_configured=bool(doc.get("radarr_api_key") and doc.get("radarr_url")),
|
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", ""),
|
tmdb_api_key=doc.get("tmdb_api_key", ""),
|
||||||
radarr_url=doc.get("radarr_url", ""),
|
radarr_url=doc.get("radarr_url", ""),
|
||||||
radarr_api_key=doc.get("radarr_api_key", ""),
|
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"])
|
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}
|
by_id = {m["radarr_id"]: m for m in movies}
|
||||||
created = 0
|
created = 0
|
||||||
|
auto = s.get("auto_transcode", "off")
|
||||||
for rid in radarr_ids:
|
for rid in radarr_ids:
|
||||||
m = by_id.get(int(rid))
|
m = by_id.get(int(rid))
|
||||||
if not m or not m.get("has_file") or not m.get("file_path"): continue
|
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
|
if await db.movies.find_one({"radarr_id": m["radarr_id"]}, {"_id": 1}): continue
|
||||||
movie = Movie(
|
movie = Movie(
|
||||||
title=m["title"], description=m.get("overview", ""),
|
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", ""),
|
poster_url=m.get("poster_url", ""),
|
||||||
backdrop_url=m.get("poster_url", ""),
|
backdrop_url=m.get("poster_url", ""),
|
||||||
video_url="", storage_type="radarr",
|
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"),
|
radarr_id=m["radarr_id"], tmdb_id=m.get("tmdb_id"),
|
||||||
).model_dump()
|
).model_dump()
|
||||||
await db.movies.insert_one(movie)
|
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
|
created += 1
|
||||||
return {"ok": True, "imported": created}
|
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 ============
|
# ============ HEALTH ============
|
||||||
@api.get("/")
|
@api.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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))),
|
||||||
|
})
|
||||||
@@ -58,6 +58,7 @@ async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[...,
|
|||||||
return
|
return
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
cmd = [
|
cmd = [
|
||||||
|
"nice", "-n", "19",
|
||||||
"ffmpeg", "-y", "-i", str(source),
|
"ffmpeg", "-y", "-i", str(source),
|
||||||
"-c:v", "copy", "-c:a", "copy",
|
"-c:v", "copy", "-c:a", "copy",
|
||||||
"-bsf:v", "h264_mp4toannexb",
|
"-bsf:v", "h264_mp4toannexb",
|
||||||
@@ -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]")
|
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
|
||||||
filter_complex = ";".join(fc_parts)
|
filter_complex = ";".join(fc_parts)
|
||||||
|
|
||||||
cmd: List[str] = ["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):
|
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
|
||||||
cmd += [
|
cmd += [
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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;"]
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,52 +38,7 @@
|
|||||||
To begin the development, run `npm start` or `yarn start`.
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
-->
|
-->
|
||||||
<a
|
<script>
|
||||||
id="emergent-badge"
|
|
||||||
target="_blank"
|
|
||||||
href="https://app.emergent.sh/?utm_source=emergent-badge"
|
|
||||||
style="
|
|
||||||
display: inline-flex !important;
|
|
||||||
box-sizing: border-box;
|
|
||||||
width: 178px;
|
|
||||||
height: 40px;
|
|
||||||
padding: 8px 12px 8px 12px;
|
|
||||||
align-items: center !important;
|
|
||||||
gap: 8px;
|
|
||||||
border-radius: 50px !important;
|
|
||||||
background: #000 !important;
|
|
||||||
position: fixed !important;
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont,
|
|
||||||
"Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
|
||||||
"Open Sans", "Helvetica Neue",
|
|
||||||
sans-serif !important;
|
|
||||||
font-size: 12px !important;
|
|
||||||
z-index: 9999 !important;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path d="M15.5702 8.13142C15.7729 8.0412 16.0007 8.18878 15.9892 8.4103C15.8374 11.3192 14.0965 14.0405 11.2531 15.3065C8.40964 16.5725 5.2224 16.0453 2.95912 14.2117C2.78676 14.072 2.82955 13.804 3.03219 13.7137L4.95677 12.8568C5.04866 12.8159 5.15446 12.823 5.24204 12.8725C6.73377 13.7153 8.59176 13.8649 10.2772 13.1145C11.9626 12.3641 13.0947 10.8833 13.4665 9.21075C13.4883 9.11256 13.5539 9.02918 13.6457 8.98827L15.5702 8.13142Z" fill="white"/>
|
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.3066 4.74698L15.5067 5.19653C15.5759 5.35178 15.5061 5.53366 15.3508 5.60278L1.29992 11.8586C1.14467 11.9278 0.962794 11.8579 0.893675 11.7027L0.701732 11.2716L0.693457 11.2531C-1.10317 7.21778 0.711626 2.49007 4.74692 0.693443C8.78221 -1.10318 13.51 0.711693 15.3066 4.74698ZM2.82356 8.55367C2.63552 8.63739 2.41991 8.51617 2.40853 8.31065C2.28373 6.05724 3.53858 3.85787 5.72286 2.88536C7.90715 1.91286 10.3813 2.45199 11.9724 4.05256C12.1175 4.19854 12.0633 4.43988 11.8753 4.5236L2.82356 8.55367Z" fill="white"/>
|
|
||||||
</svg>
|
|
||||||
<p
|
|
||||||
style="
|
|
||||||
color: #FFF !important;
|
|
||||||
font-family: 'Inter', sans-serif !important;
|
|
||||||
font-size: 13px !important;
|
|
||||||
font-style: normal !important;
|
|
||||||
font-weight: 600 !important;
|
|
||||||
line-height: 20px !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
white-space: nowrap !important;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
Made with Emergent
|
|
||||||
</p>
|
|
||||||
</a>
|
|
||||||
<script>
|
|
||||||
!(function (t, e) {
|
!(function (t, e) {
|
||||||
var o, n, p, r;
|
var o, n, p, r;
|
||||||
e.__SV ||
|
e.__SV ||
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import AdminUpload from "./pages/AdminUpload";
|
|||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
import RadarrImport from "./pages/RadarrImport";
|
import RadarrImport from "./pages/RadarrImport";
|
||||||
import ProfileSelect from "./pages/ProfileSelect";
|
import 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 ProfileGate = ({ children }) => {
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
@@ -74,6 +80,9 @@ function App() {
|
|||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
|
||||||
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
|
||||||
|
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
|
||||||
|
<Route path="/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="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
|
||||||
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
|
||||||
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
|
||||||
@@ -82,6 +91,9 @@ function App() {
|
|||||||
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
|
||||||
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
|
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
|
||||||
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
|
||||||
|
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
|
||||||
|
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
|
||||||
|
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-4" data-testid="hero-eyebrow">
|
||||||
Featured
|
In the Spotlight
|
||||||
</span>
|
</span>
|
||||||
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
|
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
|
||||||
data-testid="hero-title">
|
data-testid="hero-title">
|
||||||
@@ -51,7 +51,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
|||||||
data-testid="hero-play-button"
|
data-testid="hero-play-button"
|
||||||
>
|
>
|
||||||
<Play size={18} fill="white" strokeWidth={0} />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onMore?.(movie)}
|
onClick={() => onMore?.(movie)}
|
||||||
@@ -59,7 +59,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
|||||||
data-testid="hero-more-info-button"
|
data-testid="hero-more-info-button"
|
||||||
>
|
>
|
||||||
<Info size={18} strokeWidth={1.5} />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onAddList?.(movie)}
|
onClick={() => onAddList?.(movie)}
|
||||||
@@ -67,7 +67,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
|
|||||||
data-testid="hero-add-list-button"
|
data-testid="hero-add-list-button"
|
||||||
>
|
>
|
||||||
<Plus size={18} strokeWidth={1.5} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
|||||||
if (inList) {
|
if (inList) {
|
||||||
await api.delete(`/watchlist/${movie.id}`);
|
await api.delete(`/watchlist/${movie.id}`);
|
||||||
setInList(false);
|
setInList(false);
|
||||||
toast.success("Removed from My List");
|
toast.success("Removed from the shelf");
|
||||||
} else {
|
} else {
|
||||||
await api.post(`/watchlist/${movie.id}`);
|
await api.post(`/watchlist/${movie.id}`);
|
||||||
setInList(true);
|
setInList(true);
|
||||||
toast.success("Added to My List");
|
toast.success("Saved to your shelf");
|
||||||
}
|
}
|
||||||
onWatchlistChange?.();
|
onWatchlistChange?.();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,7 +87,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
|||||||
data-testid="modal-play-button"
|
data-testid="modal-play-button"
|
||||||
>
|
>
|
||||||
<Play size={16} fill="white" strokeWidth={0} />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
@@ -95,7 +95,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
|
|||||||
data-testid="modal-watchlist-button"
|
data-testid="modal-watchlist-button"
|
||||||
>
|
>
|
||||||
{inList ? <Check size={16} strokeWidth={1.5} /> : <Plus size={16} strokeWidth={1.5} />}
|
{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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ export const Navbar = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
{user && (
|
{user && (
|
||||||
<nav className="hidden md:flex items-center gap-7">
|
<nav className="hidden md:flex items-center gap-7">
|
||||||
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
|
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
|
||||||
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
|
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</NavLink>
|
||||||
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</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>}
|
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import { useNavigate, Link } from "react-router-dom";
|
|||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [movies, setMovies] = useState([]);
|
const [movies, setMovies] = useState([]);
|
||||||
|
const [shows, setShows] = useState([]);
|
||||||
const [transcoding, setTranscoding] = useState({});
|
const [transcoding, setTranscoding] = useState({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const { data } = await api.get("/movies");
|
const [{ data: m }, { data: s }] = await Promise.all([
|
||||||
setMovies(data);
|
api.get("/movies"),
|
||||||
|
api.get("/shows").catch(() => ({ data: [] })),
|
||||||
|
]);
|
||||||
|
setMovies(m); setShows(s);
|
||||||
};
|
};
|
||||||
useEffect(() => { load(); }, []);
|
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">
|
<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
|
<Download size={14} strokeWidth={1.5} /> Radarr Import
|
||||||
</Link>
|
</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">
|
<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
|
<SettingsIcon size={14} strokeWidth={1.5} /> Settings
|
||||||
</Link>
|
</Link>
|
||||||
@@ -131,6 +138,35 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useParams, Link } from "react-router-dom";
|
||||||
|
import api from "../lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { ArrowLeft, EyeOff, Eye, Trash2, Zap, CheckSquare, Square } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AdminShowEpisodes() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [show, setShow] = useState(null);
|
||||||
|
const [episodes, setEpisodes] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [season, setSeason] = useState("all");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [{ data: s }, { data: e }] = await Promise.all([
|
||||||
|
api.get(`/shows/${id}`),
|
||||||
|
api.get(`/shows/${id}/episodes?include_hidden=true`),
|
||||||
|
]);
|
||||||
|
setShow(s); setEpisodes(e);
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, [id]);
|
||||||
|
|
||||||
|
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
|
||||||
|
const visible = useMemo(() => season === "all" ? episodes : episodes.filter((e) => e.season_number === Number(season)), [episodes, season]);
|
||||||
|
|
||||||
|
const toggle = (eid) => {
|
||||||
|
const s = new Set(selected); s.has(eid) ? s.delete(eid) : s.add(eid); setSelected(s);
|
||||||
|
};
|
||||||
|
const toggleAll = () => {
|
||||||
|
if (visible.every((e) => selected.has(e.id))) {
|
||||||
|
const s = new Set(selected); visible.forEach((e) => s.delete(e.id)); setSelected(s);
|
||||||
|
} else {
|
||||||
|
const s = new Set(selected); visible.forEach((e) => s.add(e.id)); setSelected(s);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const selectSeason = (n) => {
|
||||||
|
const s = new Set(selected);
|
||||||
|
episodes.filter((e) => e.season_number === n).forEach((e) => s.add(e.id));
|
||||||
|
setSelected(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulk = async (action) => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
const ids = Array.from(selected);
|
||||||
|
try {
|
||||||
|
let msg = "";
|
||||||
|
if (action === "transcode-abr") {
|
||||||
|
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "abr" });
|
||||||
|
msg = `${data.queued} queued for ABR transcode`;
|
||||||
|
} else if (action === "transcode-quick") {
|
||||||
|
const { data } = await api.post("/episodes/bulk-transcode", { episode_ids: ids, quality: "quick" });
|
||||||
|
msg = `${data.queued} queued for Quick transcode`;
|
||||||
|
} else if (action === "hide") {
|
||||||
|
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: true });
|
||||||
|
msg = `${data.modified} hidden`;
|
||||||
|
} else if (action === "unhide") {
|
||||||
|
const { data } = await api.post("/episodes/bulk-hide", { episode_ids: ids, hidden: false });
|
||||||
|
msg = `${data.modified} unhidden`;
|
||||||
|
} else if (action === "delete") {
|
||||||
|
if (!window.confirm(`Delete ${ids.length} episode(s) permanently? Files on disk are kept, but 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-4">
|
||||||
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
|
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
|
||||||
</div>
|
</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>
|
<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">
|
<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} />
|
data-testid={testid} />
|
||||||
</label>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ export default function Browse() {
|
|||||||
|
|
||||||
<div className="-mt-20 relative z-10 pb-24">
|
<div className="-mt-20 relative z-10 pb-24">
|
||||||
{continueWatching.length > 0 && (
|
{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="Popular" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
|
||||||
<Row title="New Releases" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
|
<Row title="Fresh Arrivals" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
|
||||||
{watchlist.length > 0 && (
|
{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) => {
|
{genres.slice(0, 6).map((g) => {
|
||||||
const list = byGenre(g);
|
const list = byGenre(g);
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import api, { API, isHls, getSubtitleUrl } from "../lib/api";
|
||||||
|
import { ArrowLeft, SkipForward } from "lucide-react";
|
||||||
|
import Hls from "hls.js";
|
||||||
|
|
||||||
|
export default function EpisodePlayer() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [ep, setEp] = useState(null);
|
||||||
|
const [show, setShow] = useState(null);
|
||||||
|
const [nextEp, setNextEp] = useState(null);
|
||||||
|
const [subs, setSubs] = useState([]);
|
||||||
|
const [showNextCta, setShowNextCta] = useState(false);
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const hlsRef = useRef(null);
|
||||||
|
const lastSent = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
const [{ data: e }, { data: subList }] = await Promise.all([
|
||||||
|
api.get(`/episodes/${id}`),
|
||||||
|
api.get(`/episodes/${id}/subtitles`).catch(() => ({ data: [] })),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setEp(e);
|
||||||
|
setSubs(subList);
|
||||||
|
const [{ data: s }, { data: siblings }] = await Promise.all([
|
||||||
|
api.get(`/shows/${e.show_id}`),
|
||||||
|
api.get(`/shows/${e.show_id}/episodes`),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setShow(s);
|
||||||
|
const idx = siblings.findIndex((x) => x.id === e.id);
|
||||||
|
setNextEp(idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ep || !videoRef.current) return;
|
||||||
|
const v = videoRef.current;
|
||||||
|
const token = localStorage.getItem("kino_token") || "";
|
||||||
|
const src = ep.storage_type === "external" && ep.video_url
|
||||||
|
? ep.video_url
|
||||||
|
: `${API}/stream/episode/${ep.id}?auth=${encodeURIComponent(token)}`;
|
||||||
|
if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; }
|
||||||
|
if (isHls(src) && Hls.isSupported() && !v.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
|
const hls = new Hls();
|
||||||
|
hls.loadSource(src); hls.attachMedia(v); hlsRef.current = hls;
|
||||||
|
} else { v.src = src; }
|
||||||
|
api.get(`/progress/episode/${id}`).then(({ data }) => {
|
||||||
|
if (data?.position_seconds && v) v.currentTime = data.position_seconds;
|
||||||
|
}).catch(() => {});
|
||||||
|
return () => { if (hlsRef.current) { hlsRef.current.destroy(); hlsRef.current = null; } };
|
||||||
|
}, [ep, id]);
|
||||||
|
|
||||||
|
const onTimeUpdate = () => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
if (!v || !v.duration) return;
|
||||||
|
const now = Date.now();
|
||||||
|
// Show "Next episode" CTA in last 20s if available
|
||||||
|
if (nextEp && v.duration - v.currentTime <= 20 && !showNextCta) setShowNextCta(true);
|
||||||
|
if (now - lastSent.current < 5000) return;
|
||||||
|
lastSent.current = now;
|
||||||
|
api.post("/progress/episode", {
|
||||||
|
episode_id: id, position_seconds: v.currentTime, duration_seconds: v.duration,
|
||||||
|
}).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEnded = () => { if (nextEp) nav(`/watch/episode/${nextEp.id}`); };
|
||||||
|
|
||||||
|
if (!ep || !show) return <div className="min-h-screen bg-black flex items-center justify-center text-[#8A8A8A]">Loading…</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black z-50 flex flex-col" data-testid="episode-player-page">
|
||||||
|
<button onClick={() => nav(-1)} className="absolute top-6 left-6 z-10 flex items-center gap-2 text-white/80 hover:text-white bg-black/60 hover:bg-black px-4 py-2 transition-colors" data-testid="ep-player-back">
|
||||||
|
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
|
||||||
|
</button>
|
||||||
|
<div className="absolute top-6 right-6 z-10 text-right">
|
||||||
|
<h2 className="font-display text-lg text-white">{show.title}</h2>
|
||||||
|
<p className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">S{ep.season_number}·E{ep.episode_number} · {ep.title}</p>
|
||||||
|
</div>
|
||||||
|
<video ref={videoRef} controls autoPlay className="w-full h-full object-contain bg-black"
|
||||||
|
onTimeUpdate={onTimeUpdate} onEnded={onEnded} data-testid="ep-player-video">
|
||||||
|
{subs.map((sub) => (
|
||||||
|
<track key={sub.id} kind="subtitles" src={getSubtitleUrl(sub)}
|
||||||
|
srcLang={sub.language} label={sub.label} default={sub.is_default}
|
||||||
|
data-testid={`ep-player-track-${sub.id}`} />
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{showNextCta && nextEp && (
|
||||||
|
<button onClick={() => nav(`/watch/episode/${nextEp.id}`)}
|
||||||
|
className="absolute bottom-24 right-8 z-20 flex items-center gap-3 bg-white/10 hover:bg-[#D9381E] text-white px-6 py-4 backdrop-blur-md border border-white/20 transition-colors duration-300"
|
||||||
|
data-testid="next-episode-cta">
|
||||||
|
<SkipForward size={18} strokeWidth={1.5} />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Up next</div>
|
||||||
|
<div className="text-sm">S{nextEp.season_number}·E{nextEp.episode_number} · {nextEp.title}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
<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">
|
<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>
|
</h1>
|
||||||
<p className="text-[#8A8A8A] mt-6 max-w-md leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ export default function MyList() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="my-list-page">
|
<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">
|
<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">
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
|
||||||
My List
|
Shelf
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-[#8A8A8A] mt-4 max-w-xl">
|
<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>
|
</p>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="mt-16 border border-[#222] p-12 text-center" data-testid="my-list-empty">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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">
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<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">
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
|
||||||
Who's watching?
|
Choose a viewer
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
|
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
|
||||||
|
|||||||
@@ -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 inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
|
||||||
<div className="absolute bottom-12 left-12 right-12 z-10">
|
<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">
|
<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>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+189
-75
@@ -1,118 +1,232 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, createContext, useContext } from "react";
|
||||||
import api from "../lib/api";
|
import api from "../lib/api";
|
||||||
import { toast } from "sonner";
|
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() {
|
export default function Settings() {
|
||||||
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "" });
|
const [s, setS] = useState({
|
||||||
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
|
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
|
||||||
const [showTmdb, setShowTmdb] = useState(false);
|
sonarr_url: "", sonarr_api_key: "",
|
||||||
const [showRadarr, setShowRadarr] = useState(false);
|
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 [saving, setSaving] = useState(false);
|
||||||
|
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
|
||||||
|
const [traktModal, setTraktModal] = useState(null);
|
||||||
|
const [show, setShow] = useState({});
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const { data } = await api.get("/settings");
|
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 || "" });
|
setS({
|
||||||
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
|
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(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
const save = async (e) => {
|
const save = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); setSaving(true);
|
||||||
setSaving(true);
|
try { await api.put("/settings", s); toast.success("Settings saved"); load(); }
|
||||||
try {
|
catch { toast.error("Could not save"); }
|
||||||
const { data } = await api.put("/settings", s);
|
finally { setSaving(false); }
|
||||||
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
|
|
||||||
toast.success("Settings saved");
|
|
||||||
} 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 {
|
try {
|
||||||
const { data } = await api.post("/radarr/test");
|
const { data } = await api.post("/trakt/device-code");
|
||||||
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
|
setTraktModal(data);
|
||||||
} catch (err) {
|
// Poll
|
||||||
toast.error(err.response?.data?.detail || "Test failed");
|
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 (
|
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="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
|
||||||
<div className="px-6 md:px-12 max-w-3xl mx-auto">
|
<div className="px-6 md:px-12 max-w-3xl mx-auto">
|
||||||
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
|
||||||
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
|
||||||
<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">
|
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
|
||||||
|
{/* TMDB */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2>
|
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2><Badge on={info.tmdb_configured} />
|
||||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.tmdb_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
|
||||||
{info.tmdb_configured ? "● Connected" : "○ Not configured"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-[#8A8A8A] mb-4">
|
<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>
|
||||||
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>
|
<Field label="API key (v3)" k="tmdb_api_key" mask />
|
||||||
</p>
|
{info.tmdb_configured && (
|
||||||
<label className="block">
|
<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">
|
||||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key (v3)</span>
|
Bulk enrich all movies →
|
||||||
<div className="relative mt-2">
|
</button>
|
||||||
<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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Radarr */}
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2>
|
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2><Badge on={info.radarr_configured} />
|
||||||
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.radarr_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
|
</div>
|
||||||
{info.radarr_configured ? "● Configured" : "○ Not configured"}
|
<Field label="Base URL" k="radarr_url" placeholder="http://192.168.1.10:7878" />
|
||||||
</span>
|
<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>
|
</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">
|
<label className="block">
|
||||||
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Base URL</span>
|
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Auto-transcode mode</span>
|
||||||
<input value={s.radarr_url} placeholder="http://192.168.1.10:7878"
|
<select value={s.auto_transcode} onChange={(e) => setS({ ...s, auto_transcode: e.target.value })}
|
||||||
onChange={(e) => setS({ ...s, radarr_url: 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"
|
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>
|
||||||
<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>
|
</section>
|
||||||
|
|
||||||
<button type="submit" disabled={saving}
|
<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">
|
||||||
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
|
|
||||||
data-testid="settings-save-button">
|
|
||||||
{saving ? "Saving…" : "Save Settings"}
|
{saving ? "Saving…" : "Save Settings"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trakt device-code modal */}
|
||||||
|
{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>
|
</div>
|
||||||
|
</FieldCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import api from "../lib/api";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Download, RefreshCcw, Check } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SonarrImport() {
|
||||||
|
const [series, setSeries] = useState([]);
|
||||||
|
const [selected, setSelected] = useState(new Set());
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try { const { data } = await api.get("/sonarr/series"); setSeries(data); }
|
||||||
|
catch (err) { toast.error(err.response?.data?.detail || "Could not load Sonarr series"); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
};
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const toggle = (id) => {
|
||||||
|
const s = new Set(selected);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
setSelected(s);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importSelected = async () => {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setImporting(true);
|
||||||
|
try {
|
||||||
|
const { data } = await api.post("/sonarr/import", { sonarr_ids: Array.from(selected) });
|
||||||
|
toast.success(`Imported ${data.shows_imported} show(s), ${data.episodes_imported} episode(s)`);
|
||||||
|
setSelected(new Set()); load();
|
||||||
|
} catch (err) { toast.error(err.response?.data?.detail || "Import failed"); }
|
||||||
|
finally { setImporting(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const withEpisodes = series.filter((s) => s.episode_file_count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="sonarr-import-page">
|
||||||
|
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
|
||||||
|
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Sonarr</span>
|
||||||
|
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import TV Series</h1>
|
||||||
|
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
|
||||||
|
{series.length === 0 && !loading ? "No series found — configure Sonarr in Settings first." : `${withEpisodes.length} series with episode files available.`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex gap-3">
|
||||||
|
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="sonarr-refresh">
|
||||||
|
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
|
||||||
|
</button>
|
||||||
|
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="sonarr-import-btn">
|
||||||
|
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{withEpisodes.map((m) => (
|
||||||
|
<button key={m.sonarr_id} onClick={() => toggle(m.sonarr_id)}
|
||||||
|
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.sonarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
|
||||||
|
data-testid={`sonarr-card-${m.sonarr_id}`}>
|
||||||
|
{m.poster_url ? <img src={m.poster_url} alt={m.title} className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-[#8A8A8A] text-xs px-3 text-center">{m.title}</div>}
|
||||||
|
<div className="absolute inset-x-0 bottom-0 bg-black/70 p-2">
|
||||||
|
<p className="text-xs text-white truncate">{m.title}</p>
|
||||||
|
<p className="text-[10px] text-[#8A8A8A]">{m.year} · {m.episode_file_count} eps</p>
|
||||||
|
</div>
|
||||||
|
{selected.has(m.sonarr_id) && (
|
||||||
|
<div className="absolute top-2 right-2 w-7 h-7 bg-[#D9381E] flex items-center justify-center">
|
||||||
|
<Check size={14} strokeWidth={2} color="white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
@@ -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
@@ -44,17 +44,43 @@
|
|||||||
- **Admin Settings page** for TMDB + Radarr config with masked inputs + connection test
|
- **Admin Settings page** for TMDB + Radarr config with masked inputs + connection test
|
||||||
|
|
||||||
### Phase 3 (2026-04-29)
|
### 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)
|
- **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**: ABR ladder auto-trims to source resolution (no upscaling)
|
- Smart variant selection: 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
|
- Auth-aware playlist serving rewrites `?auth=` into every relative URL so hls.js segment requests stay authenticated
|
||||||
- **Quality badge** in admin row shows whether HLS is `Quick` or `ABR`
|
- 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
|
- 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
|
|
||||||
|
### 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)
|
## Backlog (P1)
|
||||||
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
|
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
|
||||||
- Subtitle search via OpenSubtitles API
|
- Subtitle search via OpenSubtitles API
|
||||||
- Multi-bitrate HLS (current is single-rate stream-copy)
|
|
||||||
- Trakt.tv sync (mark watched, ratings)
|
- Trakt.tv sync (mark watched, ratings)
|
||||||
- DLNA/Chromecast casting
|
- DLNA/Chromecast casting
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user