auto-commit for b082c1c8-f6e3-4cfd-82ee-0396c70d1a00

This commit is contained in:
emergent-agent-e1
2026-07-23 22:18:08 +00:00
parent 34a052dabf
commit 22d8479b39
16 changed files with 1364 additions and 94 deletions
+3 -3
View File
@@ -6,10 +6,10 @@ Built with FastAPI + React + MongoDB.
## ⚡ One-line install (Proxmox / any Linux with root)
```bash
curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh | sudo bash
curl -fsSL https://raw.githubusercontent.com/myronblair/kino-app/main/install.sh | sudo bash
```
Replace `YOU` with your GitHub username. The installer:
The installer:
1. Installs Docker + Compose if missing
2. Clones the repo into `/opt/kino`
3. Generates a strong `JWT_SECRET` and admin password
@@ -18,7 +18,7 @@ Replace `YOU` with your GitHub username. The installer:
Non-interactive with overrides:
```bash
curl -fsSL https://raw.githubusercontent.com/YOU/kino-media-server/main/install.sh \
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' \
+121 -2
View File
@@ -175,21 +175,140 @@ class AppSettings(BaseModel):
tmdb_api_key: str = ""
radarr_url: str = ""
radarr_api_key: str = ""
auto_transcode: str = "off" # off | quick | abr
sonarr_url: str = ""
sonarr_api_key: str = ""
opensubs_api_key: str = ""
trakt_client_id: str = ""
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
class AppSettingsPublic(BaseModel):
"""Settings visible to admin UI (does not redact, since admin already has access)."""
tmdb_configured: bool = False
radarr_configured: bool = False
sonarr_configured: bool = False
opensubs_configured: bool = False
trakt_configured: bool = False
tmdb_api_key: str = ""
radarr_url: str = ""
radarr_api_key: str = ""
sonarr_url: str = ""
sonarr_api_key: str = ""
opensubs_api_key: str = ""
trakt_client_id: str = ""
trakt_client_secret: str = ""
auto_transcode: str = "off"
queue_paused: bool = False
# ---------- Shows / Episodes (Sonarr) ----------
class ShowBase(BaseModel):
title: str
description: str = ""
year: int = 2024
rating: str = "NR"
genres: List[str] = []
cast: List[str] = []
creator: str = ""
poster_url: str = ""
backdrop_url: str = ""
featured: bool = False
tvdb_id: Optional[int] = None
tmdb_id: Optional[int] = None
sonarr_id: Optional[int] = None
class Show(ShowBase):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
episode_count: int = 0
season_count: int = 0
created_at: str = Field(default_factory=_now_iso)
class ShowCreate(ShowBase):
pass
class ShowUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
year: Optional[int] = None
rating: Optional[str] = None
genres: Optional[List[str]] = None
poster_url: Optional[str] = None
backdrop_url: Optional[str] = None
featured: Optional[bool] = None
class EpisodeBase(BaseModel):
show_id: str
season_number: int
episode_number: int
title: str
description: str = ""
duration_minutes: int = 0
air_date: str = ""
still_url: str = ""
storage_type: str = "external" # local | sonarr | external
storage_path: Optional[str] = None
video_url: str = ""
class Episode(EpisodeBase):
model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sonarr_episode_id: Optional[int] = None
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")
+62
View File
@@ -0,0 +1,62 @@
"""OpenSubtitles.com REST API v1 client.
Auth: Api-Key header (from opensubtitles.com/consumer/apps).
Endpoints used:
GET /api/v1/subtitles?tmdb_id=…&languages=…
POST /api/v1/download {file_id} → returns temporary download link
"""
import requests
from typing import List, Dict, Optional
BASE = "https://api.opensubtitles.com/api/v1"
UA = "Kino/1.0"
def _headers(api_key: str) -> dict:
return {
"Api-Key": api_key,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": UA,
}
def search(api_key: str, tmdb_id: Optional[int] = None, query: Optional[str] = None,
languages: str = "en", episode_number: Optional[int] = None,
season_number: Optional[int] = None) -> List[Dict]:
params = {"languages": languages}
if tmdb_id: params["tmdb_id"] = tmdb_id
if query: params["query"] = query
if season_number is not None: params["season_number"] = season_number
if episode_number is not None: params["episode_number"] = episode_number
r = requests.get(f"{BASE}/subtitles", params=params, headers=_headers(api_key), timeout=20)
r.raise_for_status()
out = []
for item in (r.json() or {}).get("data", [])[:30]:
attrs = item.get("attributes") or {}
files = attrs.get("files") or []
if not files: continue
f = files[0]
out.append({
"file_id": f.get("file_id"),
"language": attrs.get("language", ""),
"label": attrs.get("release", "") or f.get("file_name", ""),
"release": attrs.get("release", ""),
"fps": float(attrs.get("fps") or 0),
"downloads": int(attrs.get("download_count") or 0),
})
return out
def get_download_link(api_key: str, file_id: int) -> str:
r = requests.post(f"{BASE}/download", json={"file_id": int(file_id)}, headers=_headers(api_key), timeout=20)
r.raise_for_status()
return (r.json() or {}).get("link", "")
def download_content(link: str) -> str:
"""Fetch subtitle file content as text."""
r = requests.get(link, timeout=30, headers={"User-Agent": UA})
r.raise_for_status()
# OpenSubtitles serves .srt files (usually)
return r.content.decode("utf-8", errors="ignore")
+421
View File
@@ -24,11 +24,16 @@ from models import (
AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle,
Show, ShowCreate, ShowUpdate, Episode, EpisodeCreate, EpisodeProgressUpsert,
SonarrSeriesDTO, TraktDeviceCodeResponse, TraktStatus, OpenSubsResult,
)
from auth import hash_password, verify_password, create_token, decode_token
from seed import SAMPLE_MOVIES
import tmdb as tmdb_client
import radarr as radarr_client
import sonarr as sonarr_client
import trakt as trakt_client
import opensubtitles as opensubs_client
from transcode import transcode_quick, transcode_abr, srt_to_vtt
@@ -797,6 +802,10 @@ async def upsert_progress(payload: ProgressUpsert, profile: dict = Depends(get_a
"updated_at": now,
}}, upsert=True,
)
# Trakt scrobble hook (fire and forget)
movie = await db.movies.find_one({"id": payload.movie_id}, {"_id": 0, "tmdb_id": 1})
if movie:
asyncio.create_task(_trakt_scrobble_movie(profile["user_id"], movie, payload.position_seconds, payload.duration_seconds))
return {"ok": True}
@@ -855,9 +864,17 @@ async def read_settings(user: dict = Depends(require_admin)):
return AppSettingsPublic(
tmdb_configured=bool(s.get("tmdb_api_key")),
radarr_configured=bool(s.get("radarr_api_key") and s.get("radarr_url")),
sonarr_configured=bool(s.get("sonarr_api_key") and s.get("sonarr_url")),
opensubs_configured=bool(s.get("opensubs_api_key")),
trakt_configured=bool(s.get("trakt_client_id") and s.get("trakt_client_secret")),
tmdb_api_key=s.get("tmdb_api_key", ""),
radarr_url=s.get("radarr_url", ""),
radarr_api_key=s.get("radarr_api_key", ""),
sonarr_url=s.get("sonarr_url", ""),
sonarr_api_key=s.get("sonarr_api_key", ""),
opensubs_api_key=s.get("opensubs_api_key", ""),
trakt_client_id=s.get("trakt_client_id", ""),
trakt_client_secret=s.get("trakt_client_secret", ""),
auto_transcode=s.get("auto_transcode", "off"),
queue_paused=bool(s.get("queue_paused", False)),
)
@@ -871,9 +888,17 @@ async def update_settings(payload: AppSettings, user: dict = Depends(require_adm
return AppSettingsPublic(
tmdb_configured=bool(doc.get("tmdb_api_key")),
radarr_configured=bool(doc.get("radarr_api_key") and doc.get("radarr_url")),
sonarr_configured=bool(doc.get("sonarr_api_key") and doc.get("sonarr_url")),
opensubs_configured=bool(doc.get("opensubs_api_key")),
trakt_configured=bool(doc.get("trakt_client_id") and doc.get("trakt_client_secret")),
tmdb_api_key=doc.get("tmdb_api_key", ""),
radarr_url=doc.get("radarr_url", ""),
radarr_api_key=doc.get("radarr_api_key", ""),
sonarr_url=doc.get("sonarr_url", ""),
sonarr_api_key=doc.get("sonarr_api_key", ""),
opensubs_api_key=doc.get("opensubs_api_key", ""),
trakt_client_id=doc.get("trakt_client_id", ""),
trakt_client_secret=doc.get("trakt_client_secret", ""),
auto_transcode=doc.get("auto_transcode", "off"),
queue_paused=bool(doc.get("queue_paused", False)),
)
@@ -958,6 +983,402 @@ async def radarr_import(payload: dict, user: dict = Depends(require_admin)):
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, user: dict = Depends(get_current_user)):
docs = await db.episodes.find({"show_id": show_id}, {"_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)
@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
# 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}
# ============ HEALTH ============
@api.get("/")
async def root():
+66
View File
@@ -0,0 +1,66 @@
"""Sonarr v3 API client. https://sonarr.tv/docs/api/"""
import requests
from typing import List, Dict
def _h(api_key: str) -> dict:
return {"X-Api-Key": api_key, "Content-Type": "application/json"}
def _poster(images):
for img in images or []:
if img.get("coverType") == "poster":
return img.get("remoteUrl") or img.get("url") or ""
return ""
def test_connection(base_url: str, api_key: str) -> bool:
try:
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
return r.ok
except Exception:
return False
def list_series(base_url: str, api_key: str) -> List[Dict]:
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series", headers=_h(api_key), timeout=30)
r.raise_for_status()
out = []
for s in r.json() or []:
stats = s.get("statistics") or {}
out.append({
"sonarr_id": s["id"],
"title": s.get("title", ""),
"year": s.get("year"),
"overview": s.get("overview") or "",
"poster_url": _poster(s.get("images")),
"tvdb_id": s.get("tvdbId"),
"tmdb_id": s.get("tmdbId"),
"season_count": stats.get("seasonCount", 0),
"episode_file_count": stats.get("episodeFileCount", 0),
})
return out
def list_episodes(base_url: str, api_key: str, series_id: int) -> List[Dict]:
r = requests.get(
f"{base_url.rstrip('/')}/api/v3/episode",
params={"seriesId": series_id, "includeEpisodeFile": True},
headers=_h(api_key), timeout=30,
)
r.raise_for_status()
out = []
for e in r.json() or []:
ef = e.get("episodeFile") or {}
out.append({
"sonarr_episode_id": e["id"],
"season_number": e.get("seasonNumber", 0),
"episode_number": e.get("episodeNumber", 0),
"title": e.get("title", ""),
"description": e.get("overview") or "",
"air_date": (e.get("airDate") or ""),
"duration_minutes": int((ef.get("runTime") or 0) / 60) if ef.get("runTime") else 0,
"has_file": bool(e.get("hasFile")),
"file_path": ef.get("path") if ef else None,
})
return out
+101
View File
@@ -0,0 +1,101 @@
"""Trakt.tv API client.
Uses OAuth 2.0 device code flow:
1. POST /oauth/device/code with client_id get device_code + user_code + verification_url
2. User visits trakt.tv/activate, enters user_code
3. Poll POST /oauth/device/token with device_code until user approves access_token + refresh_token
4. Use access_token as Bearer for all subsequent calls.
Scrobble: POST /scrobble/{start|pause|stop} with movie/episode + progress (0-100).
"""
import requests
from typing import Dict, Optional
BASE = "https://api.trakt.tv"
def _headers(client_id: str, access_token: Optional[str] = None) -> dict:
h = {
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": client_id,
}
if access_token:
h["Authorization"] = f"Bearer {access_token}"
return h
def device_code(client_id: str) -> Dict:
r = requests.post(f"{BASE}/oauth/device/code", json={"client_id": client_id}, timeout=15)
r.raise_for_status()
d = r.json()
return {
"device_code": d["device_code"],
"user_code": d["user_code"],
"verification_url": d["verification_url"],
"expires_in": d["expires_in"],
"interval": d["interval"],
}
def poll_token(client_id: str, client_secret: str, device_code_val: str) -> Optional[Dict]:
"""Returns access_token dict on success, None if still pending, raises on error."""
r = requests.post(f"{BASE}/oauth/device/token", json={
"code": device_code_val,
"client_id": client_id,
"client_secret": client_secret,
}, timeout=15)
if r.status_code == 200:
return r.json() # {access_token, refresh_token, expires_in, created_at, ...}
if r.status_code in (400, 404): # pending / expired
return None
r.raise_for_status()
return None
def refresh(client_id: str, client_secret: str, refresh_token: str) -> Dict:
r = requests.post(f"{BASE}/oauth/token", json={
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "refresh_token",
}, timeout=15)
r.raise_for_status()
return r.json()
def user_settings(client_id: str, access_token: str) -> Dict:
r = requests.get(f"{BASE}/users/settings", headers=_headers(client_id, access_token), timeout=15)
r.raise_for_status()
return r.json()
def _scrobble(action: str, client_id: str, access_token: str, payload: dict):
r = requests.post(f"{BASE}/scrobble/{action}",
headers=_headers(client_id, access_token),
json=payload, timeout=15)
if r.status_code in (409, 429): # duplicate / rate-limit
return None
r.raise_for_status()
return r.json()
def scrobble_movie(client_id: str, access_token: str, tmdb_id: int, progress: float, action: str = "stop"):
return _scrobble(action, client_id, access_token, {
"movie": {"ids": {"tmdb": int(tmdb_id)}},
"progress": max(0.0, min(100.0, float(progress))),
})
def scrobble_episode(client_id: str, access_token: str, tvdb_id: Optional[int], tmdb_id: Optional[int],
season: int, episode: int, progress: float, action: str = "stop"):
ids = {}
if tvdb_id: ids["tvdb"] = int(tvdb_id)
if tmdb_id: ids["tmdb"] = int(tmdb_id)
if not ids:
return None
return _scrobble(action, client_id, access_token, {
"show": {"ids": ids},
"episode": {"season": int(season), "number": int(episode)},
"progress": max(0.0, min(100.0, float(progress))),
})
+8
View File
@@ -18,6 +18,10 @@ import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import ProfileSelect from "./pages/ProfileSelect";
import TranscodeQueue from "./pages/TranscodeQueue";
import Shows from "./pages/Shows";
import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer";
import SonarrImport from "./pages/SonarrImport";
const ProfileGate = ({ children }) => {
const { user, loading: authLoading } = useAuth();
@@ -75,6 +79,9 @@ function App() {
<Route path="/register" element={<Register />} />
<Route path="/profile" element={<ProtectedRoute><ProfileSelect /></ProtectedRoute>} />
<Route path="/browse" element={<ProfileGate><Browse /></ProfileGate>} />
<Route path="/shows" element={<ProfileGate><Shows /></ProfileGate>} />
<Route path="/show/:id" element={<ProfileGate><ShowDetail /></ProfileGate>} />
<Route path="/watch/episode/:id" element={<ProfileGate><EpisodePlayer /></ProfileGate>} />
<Route path="/my-list" element={<ProfileGate><MyList /></ProfileGate>} />
<Route path="/search" element={<ProfileGate><Search /></ProfileGate>} />
<Route path="/watch/:id" element={<ProfileGate><Player /></ProfileGate>} />
@@ -83,6 +90,7 @@ function App() {
<Route path="/admin/upload" element={<AdminGate><AdminUpload /></AdminGate>} />
<Route path="/admin/settings" element={<AdminGate><Settings /></AdminGate>} />
<Route path="/admin/radarr" element={<AdminGate><RadarrImport /></AdminGate>} />
<Route path="/admin/sonarr" element={<AdminGate><SonarrImport /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
+1
View File
@@ -35,6 +35,7 @@ export const Navbar = () => {
{user && (
<nav className="hidden md:flex items-center gap-7">
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Browse</NavLink>
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Shows</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">My List</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Requests</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
+61
View File
@@ -220,6 +220,11 @@ export default function AdminUpload() {
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E] mb-4">
<Subtitles size={12} strokeWidth={1.5} /> Subtitles
</div>
{form.tmdb_id && (
<OpenSubsSearchBox movieId={createdMovieId} tmdbId={form.tmdb_id} onAdded={() => loadSubs(createdMovieId)} />
)}
<p className="text-sm text-[#8A8A8A] mb-4">Upload .srt or .vtt files. SRT will be auto-converted.</p>
<form onSubmit={uploadSub} className="grid grid-cols-1 sm:grid-cols-3 gap-3">
@@ -269,3 +274,59 @@ const Field = ({ label, value, onChange, type = "text", required, testid, full }
data-testid={testid} />
</label>
);
const OpenSubsSearchBox = ({ movieId, tmdbId, onAdded }) => {
const [results, setResults] = useState(null);
const [loading, setLoading] = useState(false);
const search = async () => {
setLoading(true);
try {
const { data } = await api.get(`/opensubs/search?tmdb_id=${tmdbId}&languages=en`);
setResults(data);
} catch (err) {
toast.error(err.response?.data?.detail || "OpenSubs not configured or search failed");
setResults([]);
} finally { setLoading(false); }
};
const pick = async (r) => {
try {
await api.post("/opensubs/download", {
file_id: r.file_id, movie_id: movieId,
language: r.language, label: r.label || r.language.toUpperCase(),
});
toast.success("Subtitle attached");
onAdded?.();
} catch (err) { toast.error(err.response?.data?.detail || "Download failed"); }
};
return (
<div className="mb-5 border border-dashed border-[#222] p-4" data-testid="opensubs-box">
<div className="flex items-center justify-between">
<p className="text-sm text-white">Search OpenSubtitles for this movie</p>
<button type="button" onClick={search} disabled={loading}
className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors disabled:opacity-50"
data-testid="opensubs-search-button">
{loading ? "Searching…" : "Search"}
</button>
</div>
{results && results.length === 0 && <p className="text-xs text-[#8A8A8A] mt-3">No results found.</p>}
{results && results.length > 0 && (
<div className="mt-3 max-h-64 overflow-y-auto border border-[#222]">
{results.map((r) => (
<button type="button" key={r.file_id} onClick={() => pick(r)}
className="w-full flex items-center justify-between p-3 hover:bg-[#0F0F0F] text-left border-b border-[#222] last:border-b-0"
data-testid={`opensubs-result-${r.file_id}`}>
<div className="flex-1 min-w-0">
<p className="text-white text-sm truncate">{r.label || r.release || `${r.language.toUpperCase()} subtitle`}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{r.language} · {r.downloads} downloads</p>
</div>
<span className="text-[10px] uppercase tracking-[0.2em] text-[#D9381E]">Add </span>
</button>
))}
</div>
)}
</div>
);
};
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api, { API, isHls } 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 [showNextCta, setShowNextCta] = useState(false);
const videoRef = useRef(null);
const hlsRef = useRef(null);
const lastSent = useRef(0);
useEffect(() => {
let cancelled = false;
(async () => {
const { data: e } = await api.get(`/episodes/${id}`);
if (cancelled) return;
setEp(e);
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" />
{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>
);
}
+172 -86
View File
@@ -1,117 +1,190 @@
import { useEffect, useState } from "react";
import { useEffect, useState, createContext, useContext } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Eye, EyeOff, ListTodo } from "lucide-react";
import { Eye, EyeOff, ListTodo, Link2, Unlink } from "lucide-react";
const FieldCtx = createContext(null);
const Badge = ({ on }) => (
<span className={`text-[10px] uppercase tracking-[0.3em] ${on ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{on ? "● Configured" : "○ Not configured"}
</span>
);
const Field = ({ label, k, type = "text", placeholder, mask }) => {
const { s, setS, show, setShow } = useContext(FieldCtx);
return (
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{label}</span>
<div className="relative mt-2">
<input
type={mask && !show[k] ? "password" : type}
value={s[k]}
placeholder={placeholder || ""}
onChange={(e) => setS({ ...s, [k]: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid={`settings-${k}`}
/>
{mask && (
<button
type="button"
onClick={() => setShow({ ...show, [k]: !show[k] })}
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white"
>
{show[k] ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
</button>
)}
</div>
</label>
);
};
export default function Settings() {
const [s, setS] = useState({ tmdb_api_key: "", radarr_url: "", radarr_api_key: "", auto_transcode: "off", queue_paused: false });
const [info, setInfo] = useState({ tmdb_configured: false, radarr_configured: false });
const [showTmdb, setShowTmdb] = useState(false);
const [showRadarr, setShowRadarr] = useState(false);
const [s, setS] = useState({
tmdb_api_key: "", radarr_url: "", radarr_api_key: "",
sonarr_url: "", sonarr_api_key: "",
opensubs_api_key: "", trakt_client_id: "", trakt_client_secret: "",
auto_transcode: "off", queue_paused: false,
});
const [info, setInfo] = useState({});
const [saving, setSaving] = useState(false);
const [traktStatus, setTraktStatus] = useState({ connected: false, username: "" });
const [traktModal, setTraktModal] = useState(null);
const [show, setShow] = useState({});
const load = async () => {
const { data } = await api.get("/settings");
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 || "",
auto_transcode: data.auto_transcode || "off",
queue_paused: !!data.queue_paused,
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({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
setInfo(data); setTraktStatus(ts);
};
useEffect(() => { load(); }, []);
const save = async (e) => {
e.preventDefault();
setSaving(true);
try {
const { data } = await api.put("/settings", s);
setInfo({ tmdb_configured: data.tmdb_configured, radarr_configured: data.radarr_configured });
toast.success("Settings saved");
} catch {
toast.error("Could not save");
} finally { setSaving(false); }
e.preventDefault(); setSaving(true);
try { await api.put("/settings", s); toast.success("Settings saved"); load(); }
catch { toast.error("Could not save"); }
finally { setSaving(false); }
};
const testRadarr = async () => {
const testConn = async (kind) => {
try { const { data } = await api.post(`/${kind}/test`); data.ok ? toast.success(`${kind} connected`) : toast.error(`${kind} unreachable`); }
catch (err) { toast.error(err.response?.data?.detail || "Test failed"); }
};
const connectTrakt = async () => {
try {
const { data } = await api.post("/radarr/test");
data.ok ? toast.success("Radarr connected") : toast.error("Radarr unreachable");
} catch (err) {
toast.error(err.response?.data?.detail || "Test failed");
}
const { data } = await api.post("/trakt/device-code");
setTraktModal(data);
// Poll
const startedAt = Date.now();
const interval = setInterval(async () => {
if (Date.now() - startedAt > data.expires_in * 1000) { clearInterval(interval); setTraktModal(null); toast.error("Trakt code expired"); return; }
try {
const { data: r } = await api.post("/trakt/poll", { device_code: data.device_code });
if (r.ok) { clearInterval(interval); setTraktModal(null); toast.success(`Trakt connected as ${r.username}`); load(); }
} catch { clearInterval(interval); setTraktModal(null); toast.error("Trakt polling failed"); }
}, (data.interval || 5) * 1000);
} catch (err) { toast.error(err.response?.data?.detail || "Could not start Trakt flow"); }
};
const disconnectTrakt = async () => {
await api.delete("/trakt/disconnect"); toast.success("Trakt disconnected"); load();
};
const enrichAll = async () => {
if (!window.confirm("Sweep all movies with missing metadata and fill from TMDB? This may take a while.")) return;
try { const { data } = await api.post("/tmdb/enrich-all"); toast.success(`Enriched ${data.enriched}, skipped ${data.skipped}, failed ${data.failed}`); }
catch (err) { toast.error(err.response?.data?.detail || "Enrichment failed"); }
};
return (
<FieldCtx.Provider value={{ s, setS, show, setShow }}>
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="settings-page">
<div className="px-6 md:px-12 max-w-3xl mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Admin</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Settings</h1>
<p className="text-[#8A8A8A] mt-4 max-w-xl">Connect TMDB for metadata auto-fill and Radarr for library import.</p>
<form onSubmit={save} className="mt-12 space-y-10" data-testid="settings-form">
{/* TMDB */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.tmdb_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.tmdb_configured ? "● Connected" : "○ Not configured"}
</span>
<h2 className="font-display text-2xl font-bold text-white">TMDB</h2><Badge on={info.tmdb_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Get a free API key at <a className="text-[#D9381E] hover:text-[#ED4B32]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org/settings/api</a>
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">API key (v3)</span>
<div className="relative mt-2">
<input type={showTmdb ? "text" : "password"}
value={s.tmdb_api_key} onChange={(e) => setS({ ...s, tmdb_api_key: e.target.value })}
className="w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3 pr-12"
data-testid="tmdb-key-input" />
<button type="button" onClick={() => setShowTmdb(!showTmdb)} className="absolute right-3 top-1/2 -translate-y-1/2 text-[#8A8A8A] hover:text-white">
{showTmdb ? <EyeOff size={16} strokeWidth={1.5} /> : <Eye size={16} strokeWidth={1.5} />}
<p className="text-sm text-[#8A8A8A] mb-4">Free key at <a className="text-[#D9381E]" href="https://www.themoviedb.org/settings/api" target="_blank" rel="noreferrer">themoviedb.org</a></p>
<Field label="API key (v3)" k="tmdb_api_key" mask />
{info.tmdb_configured && (
<button type="button" onClick={enrichAll} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors" data-testid="enrich-all-button">
Bulk enrich all movies
</button>
</div>
</label>
)}
</section>
{/* Radarr */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2>
<span className={`text-[10px] uppercase tracking-[0.3em] ${info.radarr_configured ? "text-[#86efac]" : "text-[#fcd34d]"}`}>
{info.radarr_configured ? "● Configured" : "○ Not configured"}
</span>
<h2 className="font-display text-2xl font-bold text-white">Radarr</h2><Badge on={info.radarr_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Import your existing Radarr-managed library. Kino must be able to read Radarr's media paths on disk.
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Base URL</span>
<input value={s.radarr_url} placeholder="http://192.168.1.10:7878"
onChange={(e) => setS({ ...s, radarr_url: e.target.value })}
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" />
</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>
<Field label="Base URL" k="radarr_url" placeholder="http://192.168.1.10:7878" />
<div className="mt-4"><Field label="API key" k="radarr_api_key" mask /></div>
<button type="button" onClick={() => testConn("radarr")} className="mt-4 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors" data-testid="radarr-test">Test</button>
</section>
{/* Sonarr */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">Sonarr</h2><Badge on={info.sonarr_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">Import your TV shows library.</p>
<Field label="Base URL" k="sonarr_url" placeholder="http://192.168.1.10:8989" />
<div className="mt-4"><Field label="API key" k="sonarr_api_key" mask /></div>
<div className="mt-4 flex gap-2">
<button type="button" onClick={() => testConn("sonarr")} className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 transition-colors" data-testid="sonarr-test">Test</button>
{info.sonarr_configured && <a href="/admin/sonarr" className="text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 transition-colors">Import shows </a>}
</div>
</section>
{/* OpenSubtitles */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">OpenSubtitles</h2><Badge on={info.opensubs_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">One-click subtitle search per movie/episode. Free key at <a className="text-[#D9381E]" href="https://www.opensubtitles.com/consumer/apps" target="_blank" rel="noreferrer">opensubtitles.com</a></p>
<Field label="API key" k="opensubs_api_key" mask />
</section>
{/* Trakt */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white">Trakt.tv</h2><Badge on={info.trakt_configured} />
</div>
<p className="text-sm text-[#8A8A8A] mb-4">Mirror watch history to Trakt. Register an app at <a className="text-[#D9381E]" href="https://trakt.tv/oauth/applications" target="_blank" rel="noreferrer">trakt.tv/oauth/applications</a> (use urn:ietf:wg:oauth:2.0:oob as redirect).</p>
<Field label="Client ID" k="trakt_client_id" mask />
<div className="mt-4"><Field label="Client secret" k="trakt_client_secret" mask /></div>
{info.trakt_configured && (
<div className="mt-4">
{traktStatus.connected ? (
<div className="flex items-center gap-3">
<span className="text-xs text-[#86efac]"> Connected as {traktStatus.username}</span>
<button type="button" onClick={disconnectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2" data-testid="trakt-disconnect">
<Unlink size={12} /> Disconnect
</button>
</div>
) : (
<button type="button" onClick={connectTrakt} className="flex items-center gap-2 text-xs uppercase tracking-[0.2em] bg-[#D9381E] hover:bg-[#ED4B32] text-white px-4 py-2" data-testid="trakt-connect">
<Link2 size={12} /> Connect Trakt
</button>
)}
</div>
)}
</section>
{/* Transcode Queue */}
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="font-display text-2xl font-bold text-white flex items-center gap-2">
@@ -119,28 +192,41 @@ export default function Settings() {
</h2>
<a href="/admin/queue" className="text-[10px] uppercase tracking-[0.3em] text-[#D9381E] hover:text-[#ED4B32]" data-testid="settings-queue-link">View queue </a>
</div>
<p className="text-sm text-[#8A8A8A] mb-4">
Auto-transcode every newly uploaded or imported movie. Runs at low CPU priority, one at a time.
</p>
<label className="block">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Auto-transcode mode</span>
<select value={s.auto_transcode} onChange={(e) => setS({ ...s, auto_transcode: e.target.value })}
className="mt-2 w-full bg-[#0F0F0F] border border-[#222] focus:border-[#D9381E] focus:outline-none text-white px-4 py-3"
data-testid="auto-transcode-select">
<option value="off">Off manual only</option>
<option value="quick">Quick (stream-copy, instant, single-rate)</option>
<option value="abr">ABR (re-encoded multi-bitrate, slow but adaptive)</option>
<option value="quick">Quick (stream-copy, instant)</option>
<option value="abr">ABR (multi-bitrate, slow but adaptive)</option>
</select>
</label>
</section>
<button type="submit" disabled={saving}
className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]"
data-testid="settings-save-button">
<button type="submit" disabled={saving} className="bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-60 text-white px-8 py-3 text-sm uppercase tracking-[0.2em]" data-testid="settings-save-button">
{saving ? "Saving…" : "Save Settings"}
</button>
</form>
</div>
{/* Trakt device-code modal */}
{traktModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center" data-testid="trakt-modal">
<div className="absolute inset-0 bg-black/85 backdrop-blur-md" onClick={() => setTraktModal(null)} />
<div className="relative bg-[#0F0F0F] border border-[#222] p-8 max-w-md w-full mx-4">
<h2 className="font-display text-3xl font-bold text-white">Connect Trakt</h2>
<p className="text-sm text-[#8A8A8A] mt-2">Visit the URL below, sign in to Trakt, and enter the code:</p>
<a href={traktModal.verification_url} target="_blank" rel="noreferrer" className="block mt-6 text-[#D9381E] hover:text-[#ED4B32] break-all">{traktModal.verification_url}</a>
<div className="mt-6 bg-black border border-[#D9381E] px-6 py-4 text-center">
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Enter this code</div>
<div className="font-display text-4xl font-black tracking-widest text-white mt-2" data-testid="trakt-user-code">{traktModal.user_code}</div>
</div>
<p className="text-xs text-[#8A8A8A] mt-4">Waiting for approval this window will close automatically.</p>
</div>
</div>
)}
</div>
</FieldCtx.Provider>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Play, ArrowLeft } from "lucide-react";
export default function ShowDetail() {
const { id } = useParams();
const nav = useNavigate();
const [show, setShow] = useState(null);
const [episodes, setEpisodes] = useState([]);
const [season, setSeason] = useState(1);
useEffect(() => {
(async () => {
const [s, e] = await Promise.all([api.get(`/shows/${id}`), api.get(`/shows/${id}/episodes`)]);
setShow(s.data); setEpisodes(e.data);
const firstS = e.data[0]?.season_number || 1;
setSeason(firstS);
})();
}, [id]);
const seasons = useMemo(() => [...new Set(episodes.map((e) => e.season_number))].sort((a, b) => a - b), [episodes]);
const inSeason = useMemo(() => episodes.filter((e) => e.season_number === season), [episodes, season]);
if (!show) return <div className="min-h-screen bg-[#050505] flex items-center justify-center text-[#8A8A8A]">Loading</div>;
return (
<div className="min-h-screen bg-[#050505]" data-testid={`show-detail-${show.id}`}>
<div className="relative h-[60vh] min-h-[400px] overflow-hidden">
<img src={show.backdrop_url || show.poster_url} alt="" className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 hero-fade" />
<button onClick={() => nav(-1)} className="absolute top-24 left-6 md:left-12 flex items-center gap-2 text-white/80 hover:text-white bg-black/40 hover:bg-black/60 px-4 py-2 backdrop-blur transition-colors" data-testid="show-back">
<ArrowLeft size={16} strokeWidth={1.5} /> <span className="text-xs uppercase tracking-[0.2em]">Back</span>
</button>
<div className="absolute bottom-12 left-6 md:left-12 max-w-2xl">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-2" data-testid="show-title">{show.title}</h1>
<div className="flex items-center gap-3 mt-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A]">
<span>{show.year}</span><span>·</span><span>{show.rating}</span><span>·</span>
<span>{show.season_count} seasons · {show.episode_count} episodes</span>
</div>
<p className="text-[#C8C8C8] mt-5 max-w-xl leading-relaxed">{show.description}</p>
</div>
</div>
<div className="px-6 md:px-12 max-w-[1500px] mx-auto py-12">
<div className="flex items-center gap-3 flex-wrap mb-6">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">Seasons</span>
{seasons.map((n) => (
<button key={n} onClick={() => setSeason(n)}
className={`text-sm px-4 py-2 border transition-colors ${season === n ? "border-[#D9381E] text-white bg-[#D9381E]/10" : "border-[#222] text-[#8A8A8A] hover:text-white"}`}
data-testid={`season-${n}`}>
Season {n}
</button>
))}
</div>
<div className="space-y-3">
{inSeason.map((ep) => (
<button key={ep.id} onClick={() => nav(`/watch/episode/${ep.id}`)}
className="w-full flex items-center gap-4 p-4 border border-[#222] hover:border-[#D9381E] hover:bg-[#0F0F0F] transition-colors text-left group"
data-testid={`episode-${ep.id}`}>
<div className="w-12 h-12 flex items-center justify-center bg-[#0F0F0F] group-hover:bg-[#D9381E] transition-colors shrink-0">
<Play size={16} strokeWidth={1.5} fill="currentColor" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-3">
<span className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">E{ep.episode_number}</span>
<h3 className="font-display text-lg text-white truncate">{ep.title || `Episode ${ep.episode_number}`}</h3>
{ep.duration_minutes > 0 && <span className="text-xs text-[#8A8A8A] ml-auto">{ep.duration_minutes}m</span>}
</div>
{ep.description && <p className="text-sm text-[#8A8A8A] mt-1 line-clamp-2">{ep.description}</p>}
</div>
</button>
))}
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import api from "../lib/api";
import { Tv } from "lucide-react";
export default function Shows() {
const [shows, setShows] = useState([]);
const [featured, setFeatured] = useState(null);
const [continueEps, setContinueEps] = useState([]);
const nav = useNavigate();
useEffect(() => {
(async () => {
const [s, f, c] = await Promise.all([
api.get("/shows"),
api.get("/shows/featured").catch(() => ({ data: null })),
api.get("/progress/episodes/continue").catch(() => ({ data: [] })),
]);
setShows(s.data); setFeatured(f.data); setContinueEps(c.data);
})();
}, []);
if (shows.length === 0) {
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-empty-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto text-center">
<Tv size={48} strokeWidth={1.5} className="mx-auto text-[#8A8A8A]" />
<h1 className="mt-6 font-display text-4xl font-bold tracking-tight text-white">No TV shows yet</h1>
<p className="text-[#8A8A8A] mt-3 max-w-md mx-auto">Configure Sonarr in Settings and import your library.</p>
<a href="/admin/sonarr" className="inline-block mt-6 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-6 py-3 text-sm uppercase tracking-[0.2em]" data-testid="go-to-sonarr-import">
Sonarr Import
</a>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="shows-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Series</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">TV Shows</h1>
{continueEps.length > 0 && (
<div className="mt-12">
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Continue Watching</h2>
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-4">
{continueEps.map(({ episode, show, progress }) => {
const pct = progress.duration_seconds ? Math.min(100, (progress.position_seconds / progress.duration_seconds) * 100) : 0;
return (
<button key={episode.id} onClick={() => nav(`/watch/episode/${episode.id}`)}
className="relative shrink-0 w-[260px] group focus:outline-none" data-testid={`continue-ep-${episode.id}`}>
<div className="aspect-video overflow-hidden bg-[#0F0F0F] transition-transform duration-300 group-hover:scale-105">
<img src={show.backdrop_url || show.poster_url} alt="" className="w-full h-full object-cover" />
</div>
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-white/10">
<div className="h-full bg-[#D9381E]" style={{ width: `${pct}%` }} />
</div>
<p className="mt-2 text-white text-sm truncate">{show.title}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">S{episode.season_number}·E{episode.episode_number} · {episode.title}</p>
</button>
);
})}
</div>
</div>
)}
<div className="mt-16 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{shows.map((s) => (
<button key={s.id} onClick={() => nav(`/show/${s.id}`)}
className="group shrink-0 aspect-[2/3] overflow-hidden bg-[#0F0F0F] transition-transform duration-300 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9381E]"
data-testid={`show-card-${s.id}`}>
<img src={s.poster_url || s.backdrop_url} alt={s.title} loading="lazy" className="w-full h-full object-cover" />
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black to-transparent p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<p className="text-white text-sm truncate">{s.title}</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">{s.season_count} seasons · {s.episode_count} eps</p>
</div>
</button>
))}
</div>
</div>
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Download, RefreshCcw, Check } from "lucide-react";
export default function SonarrImport() {
const [series, setSeries] = useState([]);
const [selected, setSelected] = useState(new Set());
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const load = async () => {
setLoading(true);
try { const { data } = await api.get("/sonarr/series"); setSeries(data); }
catch (err) { toast.error(err.response?.data?.detail || "Could not load Sonarr series"); }
finally { setLoading(false); }
};
useEffect(() => { load(); }, []);
const toggle = (id) => {
const s = new Set(selected);
s.has(id) ? s.delete(id) : s.add(id);
setSelected(s);
};
const importSelected = async () => {
if (selected.size === 0) return;
setImporting(true);
try {
const { data } = await api.post("/sonarr/import", { sonarr_ids: Array.from(selected) });
toast.success(`Imported ${data.shows_imported} show(s), ${data.episodes_imported} episode(s)`);
setSelected(new Set()); load();
} catch (err) { toast.error(err.response?.data?.detail || "Import failed"); }
finally { setImporting(false); }
};
const withEpisodes = series.filter((s) => s.episode_file_count > 0);
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="sonarr-import-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Sonarr</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Import TV Series</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
{series.length === 0 && !loading ? "No series found — configure Sonarr in Settings first." : `${withEpisodes.length} series with episode files available.`}
</p>
<div className="mt-8 flex gap-3">
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-5 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="sonarr-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
</button>
<button onClick={importSelected} disabled={selected.size === 0 || importing} className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] disabled:opacity-50 text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="sonarr-import-btn">
<Download size={14} strokeWidth={1.5} /> Import {selected.size > 0 ? `(${selected.size})` : ""}
</button>
</div>
<div className="mt-10 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{withEpisodes.map((m) => (
<button key={m.sonarr_id} onClick={() => toggle(m.sonarr_id)}
className={`relative aspect-[2/3] overflow-hidden bg-[#0F0F0F] border-2 transition-colors ${selected.has(m.sonarr_id) ? "border-[#D9381E]" : "border-transparent hover:border-white/20"}`}
data-testid={`sonarr-card-${m.sonarr_id}`}>
{m.poster_url ? <img src={m.poster_url} alt={m.title} className="w-full h-full object-cover" /> : <div className="w-full h-full flex items-center justify-center text-[#8A8A8A] text-xs px-3 text-center">{m.title}</div>}
<div className="absolute inset-x-0 bottom-0 bg-black/70 p-2">
<p className="text-xs text-white truncate">{m.title}</p>
<p className="text-[10px] text-[#8A8A8A]">{m.year} · {m.episode_file_count} eps</p>
</div>
{selected.has(m.sonarr_id) && (
<div className="absolute top-2 right-2 w-7 h-7 bg-[#D9381E] flex items-center justify-center">
<Check size={14} strokeWidth={2} color="white" />
</div>
)}
</button>
))}
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -19,7 +19,7 @@
set -euo pipefail
# ---------- Defaults (override via env) ----------
KINO_REPO="${KINO_REPO:-https://github.com/CHANGE_ME/kino-media-server.git}"
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}"
@@ -44,7 +44,7 @@ die() { printf "%s %s\n" "$(c_red '✗')" "$*" >&2; exit 1; }
[ "$(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 — edit install.sh and set your GitHub URL, or pass KINO_REPO=... env var."
die "KINO_REPO placeholder — pass KINO_REPO=... env var to install.sh"
fi
# ---------- OS detection & package manager ----------
+7
View File
@@ -60,6 +60,13 @@
- **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 5 (2026-07-23)
- **Sonarr / TV Shows** — Show → Episode data model, Sonarr library import (multi-select), season/episode picker UI, dedicated `/shows` browse page
- **Episode player** with auto-next-episode CTA in last 20s, auto-advance on video end, profile-scoped progress + continue-watching
- **Trakt.tv sync** — OAuth device-code flow to connect account, auto-scrobble on every movie AND episode progress update (start/pause/stop based on % watched)
- **OpenSubtitles auto-search** — one-click subtitle search by TMDB ID in the upload flow, auto-converts SRT to WEBVTT, attaches directly to movie
- **Bulk TMDB enrichment** — sweeps every existing movie with missing metadata (cast/director/description/poster) and backfills from TMDB in one click
## Backlog (P1)
- Sonarr (TV shows): requires episodes/seasons data model — significant addition
- Subtitle search via OpenSubtitles API