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}
+ onTimeUpdate={onTimeUpdate} onEnded={onEnded} data-testid="ep-player-video">
+ {subs.map((sub) => (
+
+ ))}
+
{showNextCta && nextEp && (