diff --git a/backend/server.py b/backend/server.py index ccba98b..0702d22 100644 --- a/backend/server.py +++ b/backend/server.py @@ -706,6 +706,12 @@ async def list_subs(movie_id: str, user: dict = Depends(get_current_user)): return docs +@api.get("/episodes/{episode_id}/subtitles", response_model=List[Subtitle]) +async def list_episode_subs(episode_id: str, user: dict = Depends(get_current_user)): + docs = await db.subtitles.find({"episode_id": episode_id}, {"_id": 0}).sort("created_at", 1).to_list(20) + return docs + + @api.post("/movies/{movie_id}/subtitles", response_model=Subtitle) async def upload_sub( movie_id: str, @@ -979,6 +985,8 @@ async def radarr_import(payload: dict, user: dict = Depends(require_admin)): await db.movies.insert_one(movie) if auto in ("quick", "abr"): await _enqueue_transcode(movie["id"], auto, triggered_by="auto") + # Auto-fetch English subtitle (fire and forget) + asyncio.create_task(_auto_fetch_subtitle("movie", movie["id"], movie.get("tmdb_id"))) created += 1 return {"ok": True, "imported": created} @@ -1208,6 +1216,13 @@ async def sonarr_import(payload: dict, user: dict = Depends(require_admin)): sonarr_episode_id=ep["sonarr_episode_id"], ).model_dump() await db.episodes.insert_one(edoc); created_eps += 1 + # Auto-fetch English subtitle for this episode (needs show's tmdb_id + season/episode numbers) + show_doc = await db.shows.find_one({"id": show_id}, {"_id": 0, "tmdb_id": 1}) + if show_doc and show_doc.get("tmdb_id"): + asyncio.create_task(_auto_fetch_subtitle( + "episode", edoc["id"], show_doc["tmdb_id"], + season=ep["season_number"], episode=ep["episode_number"], + )) # Update counts seasons = await db.episodes.distinct("season_number", {"show_id": show_id}) ecount = await db.episodes.count_documents({"show_id": show_id}) @@ -1379,6 +1394,38 @@ async def tmdb_enrich_all(user: dict = Depends(require_admin)): return {"ok": True, "enriched": enriched, "skipped": skipped, "failed": failed} +async def _auto_fetch_subtitle(content_type: str, content_id: str, tmdb_id: Optional[int], + season: Optional[int] = None, episode: Optional[int] = None, + language: str = "en", label: str = "English"): + """Best-effort background: pull first OpenSubs result and attach to movie/episode.""" + if not tmdb_id: return + s = await get_settings() + key = s.get("opensubs_api_key", "") + if not key: return + try: + results = await asyncio.to_thread(opensubs_client.search, key, tmdb_id, None, language, episode, season) + if not results: return + r = results[0] + link = await asyncio.to_thread(opensubs_client.get_download_link, key, r["file_id"]) + srt = await asyncio.to_thread(opensubs_client.download_content, link) + vtt = srt_to_vtt(srt) if "WEBVTT" not in srt[:20] else srt + except Exception as e: + logger.warning(f"Auto-subtitle failed for {content_type} {content_id}: {e}") + return + sid = str(uuid.uuid4()); fname = f"{sid}.vtt" + (SUBS_DIR / fname).write_text(vtt, encoding="utf-8") + doc = { + "id": sid, + "movie_id": content_id if content_type == "movie" else "", + "episode_id": content_id if content_type == "episode" else "", + "language": language, "label": label, "storage_path": fname, + "is_default": True, + "created_at": datetime.now(timezone.utc).isoformat(), + } + await db.subtitles.insert_one(doc) + logger.info(f"Auto-attached subtitle for {content_type} {content_id[:8]}") + + # ============ HEALTH ============ @api.get("/") async def root(): diff --git a/frontend/src/components/Hero.jsx b/frontend/src/components/Hero.jsx index bbe28ad..38df217 100644 --- a/frontend/src/components/Hero.jsx +++ b/frontend/src/components/Hero.jsx @@ -17,7 +17,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
- Featured + In the Spotlight

@@ -51,7 +51,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => { data-testid="hero-play-button" > - Play + Watch

diff --git a/frontend/src/components/MovieDetailModal.jsx b/frontend/src/components/MovieDetailModal.jsx index b10e571..01d8b26 100644 --- a/frontend/src/components/MovieDetailModal.jsx +++ b/frontend/src/components/MovieDetailModal.jsx @@ -30,11 +30,11 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => if (inList) { await api.delete(`/watchlist/${movie.id}`); setInList(false); - toast.success("Removed from My List"); + toast.success("Removed from the shelf"); } else { await api.post(`/watchlist/${movie.id}`); setInList(true); - toast.success("Added to My List"); + toast.success("Saved to your shelf"); } onWatchlistChange?.(); } catch { @@ -87,7 +87,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) => data-testid="modal-play-button" > - Play + Watch diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index f6e48de..9db34d6 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -34,10 +34,10 @@ export const Navbar = () => { {user && ( )} diff --git a/frontend/src/pages/Browse.jsx b/frontend/src/pages/Browse.jsx index 6308e4d..206c510 100644 --- a/frontend/src/pages/Browse.jsx +++ b/frontend/src/pages/Browse.jsx @@ -58,12 +58,12 @@ export default function Browse() {
{continueWatching.length > 0 && ( - + )} - - + + {watchlist.length > 0 && ( - + )} {genres.slice(0, 6).map((g) => { const list = byGenre(g); diff --git a/frontend/src/pages/EpisodePlayer.jsx b/frontend/src/pages/EpisodePlayer.jsx index ca76677..46c3d13 100644 --- a/frontend/src/pages/EpisodePlayer.jsx +++ b/frontend/src/pages/EpisodePlayer.jsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import api, { API, isHls } from "../lib/api"; +import api, { API, isHls, getSubtitleUrl } from "../lib/api"; import { ArrowLeft, SkipForward } from "lucide-react"; import Hls from "hls.js"; @@ -10,6 +10,7 @@ export default function EpisodePlayer() { const [ep, setEp] = useState(null); const [show, setShow] = useState(null); const [nextEp, setNextEp] = useState(null); + const [subs, setSubs] = useState([]); const [showNextCta, setShowNextCta] = useState(false); const videoRef = useRef(null); const hlsRef = useRef(null); @@ -18,9 +19,13 @@ export default function EpisodePlayer() { useEffect(() => { let cancelled = false; (async () => { - const { data: e } = await api.get(`/episodes/${id}`); + const [{ data: e }, { data: subList }] = await Promise.all([ + api.get(`/episodes/${id}`), + api.get(`/episodes/${id}/subtitles`).catch(() => ({ data: [] })), + ]); if (cancelled) return; setEp(e); + setSubs(subList); const [{ data: s }, { data: siblings }] = await Promise.all([ api.get(`/shows/${e.show_id}`), api.get(`/shows/${e.show_id}/episodes`), @@ -78,7 +83,13 @@ export default function EpisodePlayer() {

S{ep.season_number}·E{ep.episode_number} · {ep.title}

{showNextCta && nextEp && (