mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 05:23:45 -05:00
auto-commit for b082c1c8-f6e3-4cfd-82ee-0396c70d1a00
This commit is contained in:
+121
-2
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Sonarr v3 API client. https://sonarr.tv/docs/api/"""
|
||||
import requests
|
||||
from typing import List, Dict
|
||||
|
||||
|
||||
def _h(api_key: str) -> dict:
|
||||
return {"X-Api-Key": api_key, "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _poster(images):
|
||||
for img in images or []:
|
||||
if img.get("coverType") == "poster":
|
||||
return img.get("remoteUrl") or img.get("url") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def test_connection(base_url: str, api_key: str) -> bool:
|
||||
try:
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
||||
return r.ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def list_series(base_url: str, api_key: str) -> List[Dict]:
|
||||
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series", headers=_h(api_key), timeout=30)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for s in r.json() or []:
|
||||
stats = s.get("statistics") or {}
|
||||
out.append({
|
||||
"sonarr_id": s["id"],
|
||||
"title": s.get("title", ""),
|
||||
"year": s.get("year"),
|
||||
"overview": s.get("overview") or "",
|
||||
"poster_url": _poster(s.get("images")),
|
||||
"tvdb_id": s.get("tvdbId"),
|
||||
"tmdb_id": s.get("tmdbId"),
|
||||
"season_count": stats.get("seasonCount", 0),
|
||||
"episode_file_count": stats.get("episodeFileCount", 0),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def list_episodes(base_url: str, api_key: str, series_id: int) -> List[Dict]:
|
||||
r = requests.get(
|
||||
f"{base_url.rstrip('/')}/api/v3/episode",
|
||||
params={"seriesId": series_id, "includeEpisodeFile": True},
|
||||
headers=_h(api_key), timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
out = []
|
||||
for e in r.json() or []:
|
||||
ef = e.get("episodeFile") or {}
|
||||
out.append({
|
||||
"sonarr_episode_id": e["id"],
|
||||
"season_number": e.get("seasonNumber", 0),
|
||||
"episode_number": e.get("episodeNumber", 0),
|
||||
"title": e.get("title", ""),
|
||||
"description": e.get("overview") or "",
|
||||
"air_date": (e.get("airDate") or ""),
|
||||
"duration_minutes": int((ef.get("runTime") or 0) / 60) if ef.get("runTime") else 0,
|
||||
"has_file": bool(e.get("hasFile")),
|
||||
"file_path": ef.get("path") if ef else None,
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,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))),
|
||||
})
|
||||
Reference in New Issue
Block a user