diff --git a/README.md b/README.md index 2094aeb..99a3ddd 100644 --- a/README.md +++ b/README.md @@ -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' \ diff --git a/backend/models.py b/backend/models.py index 166865d..1400fd9 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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") diff --git a/backend/opensubtitles.py b/backend/opensubtitles.py new file mode 100644 index 0000000..bb992fe --- /dev/null +++ b/backend/opensubtitles.py @@ -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") diff --git a/backend/server.py b/backend/server.py index 2c2f891..ccba98b 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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(): diff --git a/backend/sonarr.py b/backend/sonarr.py new file mode 100644 index 0000000..f0802fa --- /dev/null +++ b/backend/sonarr.py @@ -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 diff --git a/backend/trakt.py b/backend/trakt.py new file mode 100644 index 0000000..9d941d9 --- /dev/null +++ b/backend/trakt.py @@ -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))), + }) diff --git a/frontend/src/App.js b/frontend/src/App.js index 5d0c172..e30ab80 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -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() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> @@ -83,6 +90,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index 630da07..f6e48de 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -35,6 +35,7 @@ export const Navbar = () => { {user && (