mirror of
https://github.com/myronblair/kino-app
synced 2026-07-28 13:33:49 -05:00
auto-commit for 0175172f-4f7e-4804-ad7f-68312dcdad0d
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user