auto-commit for 0175172f-4f7e-4804-ad7f-68312dcdad0d

This commit is contained in:
emergent-agent-e1
2026-07-23 22:23:41 +00:00
parent 22d8479b39
commit bd8480bd14
11 changed files with 89 additions and 31 deletions
+47
View File
@@ -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():
+4 -4
View File
@@ -17,7 +17,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
<div className="relative h-full max-w-[1500px] mx-auto px-6 md:px-12 flex flex-col justify-end pb-24 md:pb-32 fade-up">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-4" data-testid="hero-eyebrow">
Featured
In the Spotlight
</span>
<h1 className="font-display text-5xl md:text-7xl font-black tracking-tighter leading-none text-white max-w-3xl"
data-testid="hero-title">
@@ -51,7 +51,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
data-testid="hero-play-button"
>
<Play size={18} fill="white" strokeWidth={0} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
<span className="text-sm uppercase tracking-[0.2em] font-medium">Watch</span>
</button>
<button
onClick={() => onMore?.(movie)}
@@ -59,7 +59,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
data-testid="hero-more-info-button"
>
<Info size={18} strokeWidth={1.5} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">More Info</span>
<span className="text-sm uppercase tracking-[0.2em] font-medium">Details</span>
</button>
<button
onClick={() => onAddList?.(movie)}
@@ -67,7 +67,7 @@ export const Hero = ({ movie, onPlay, onMore, onAddList }) => {
data-testid="hero-add-list-button"
>
<Plus size={18} strokeWidth={1.5} />
<span className="text-xs uppercase tracking-[0.2em]">My List</span>
<span className="text-xs uppercase tracking-[0.2em]">Save</span>
</button>
</div>
</div>
+4 -4
View File
@@ -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 size={16} fill="white" strokeWidth={0} />
<span className="text-sm uppercase tracking-[0.2em] font-medium">Play</span>
<span className="text-sm uppercase tracking-[0.2em] font-medium">Watch</span>
</button>
<button
onClick={toggle}
@@ -95,7 +95,7 @@ export const MovieDetailModal = ({ movie, open, onClose, onWatchlistChange }) =>
data-testid="modal-watchlist-button"
>
{inList ? <Check size={16} strokeWidth={1.5} /> : <Plus size={16} strokeWidth={1.5} />}
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "In My List" : "My List"}</span>
<span className="text-xs uppercase tracking-[0.2em]">{inList ? "On the Shelf" : "Save to Shelf"}</span>
</button>
</div>
</div>
+4 -4
View File
@@ -34,10 +34,10 @@ export const Navbar = () => {
</Link>
{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>
<NavLink to="/browse" className={linkClass} data-testid="nav-browse">Library</NavLink>
<NavLink to="/shows" className={linkClass} data-testid="nav-shows">Series</NavLink>
<NavLink to="/my-list" className={linkClass} data-testid="nav-my-list">Shelf</NavLink>
<NavLink to="/requests" className={linkClass} data-testid="nav-requests">Wishlist</NavLink>
{user.is_admin && <NavLink to="/admin" className={linkClass} data-testid="nav-admin">Admin</NavLink>}
</nav>
)}
+4 -4
View File
@@ -58,12 +58,12 @@ export default function Browse() {
<div className="-mt-20 relative z-10 pb-24">
{continueWatching.length > 0 && (
<Row title="Continue Watching" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
<Row title="Resume" movies={continueWatching} onCardClick={handleMore} progressMap={progressMap} />
)}
<Row title="Trending Now" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
<Row title="New Releases" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
<Row title="Popular" movies={trending} onCardClick={handleMore} progressMap={progressMap} />
<Row title="Fresh Arrivals" movies={newReleases} onCardClick={handleMore} progressMap={progressMap} />
{watchlist.length > 0 && (
<Row title="My List" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
<Row title="Your Shelf" movies={watchlist} onCardClick={handleMore} progressMap={progressMap} />
)}
{genres.slice(0, 6).map((g) => {
const list = byGenre(g);
+14 -3
View File
@@ -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() {
<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" />
onTimeUpdate={onTimeUpdate} onEnded={onEnded} data-testid="ep-player-video">
{subs.map((sub) => (
<track key={sub.id} kind="subtitles" src={getSubtitleUrl(sub)}
srcLang={sub.language} label={sub.label} default={sub.is_default}
data-testid={`ep-player-track-${sub.id}`} />
))}
</video>
{showNextCta && nextEp && (
<button onClick={() => nav(`/watch/episode/${nextEp.id}`)}
+3 -3
View File
@@ -35,12 +35,12 @@ export default function Login() {
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
<div className="absolute bottom-12 left-12 right-12 z-10">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Personal Cinema</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Private Screening Room</span>
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
Your library,<br/>your way.
Your reels,<br/>your rules.
</h1>
<p className="text-[#8A8A8A] mt-6 max-w-md leading-relaxed">
Stream the films you own, the way you remember them without the noise.
Every frame you already own, indexed and ready no algorithms, no ads, no interruptions.
</p>
</div>
</div>
+4 -4
View File
@@ -16,17 +16,17 @@ export default function MyList() {
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="my-list-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Your collection</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Curated for you</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">
My List
Shelf
</h1>
<p className="text-[#8A8A8A] mt-4 max-w-xl">
Films you've curated for later. Click any to play or remove.
Titles you've set aside for a proper evening. Tap any to start or clear it off the shelf.
</p>
{items.length === 0 ? (
<div className="mt-16 border border-[#222] p-12 text-center" data-testid="my-list-empty">
<p className="text-[#8A8A8A]">Your list is empty. Add films from Browse.</p>
<p className="text-[#8A8A8A]">Nothing on the shelf yet save something from the Library.</p>
</div>
) : (
<div className="mt-12 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
+1 -1
View File
@@ -48,7 +48,7 @@ export default function ProfileSelect() {
<div className="min-h-screen bg-[#050505] flex flex-col items-center justify-center px-6 py-16" data-testid="profile-select-page">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E] mb-6">Welcome back, {user?.name}</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white text-center">
Who's watching?
Choose a viewer
</h1>
<div className="mt-16 flex flex-wrap justify-center gap-8 max-w-4xl">
+2 -2
View File
@@ -39,9 +39,9 @@ export default function Register() {
/>
<div className="absolute inset-0 bg-gradient-to-r from-[#050505]/60 to-[#050505]" />
<div className="absolute bottom-12 left-12 right-12 z-10">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Join Kino</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Open the doors</span>
<h1 className="font-display text-6xl font-black tracking-tighter text-white mt-4 leading-none">
Curate.<br/>Stream.<br/>Repeat.
Collect.<br/>Cue.<br/>Enjoy.
</h1>
</div>
</div>
+2 -2
View File
@@ -39,11 +39,11 @@ export default function Shows() {
<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>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Your Series</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>
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Resume</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;