diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index 74f0ada..ba0a808 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -53,12 +53,25 @@ interface JobProgress { dsStatus: string | null; } +interface EpisodeTrack { + episodeNumber: number; + episodeTitle: string; + importedPath: string | null; + subtitlePath: string | null; + subtitleError: string | null; +} + interface JobRow { id: string; nodeId: string; nodeName: string; status: string; libraryTarget?: LibraryTarget; + seriesMode?: "EPISODE" | "SEASON" | null; + seasonNumber?: number | null; + episodeNumber?: number | null; + episodeTitle?: string | null; + episodes?: EpisodeTrack[]; sourceTitle: string; movieTitle: string; movieYear: number | null; @@ -75,6 +88,12 @@ interface JobRow { } type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; +type SeriesMode = "EPISODE" | "SEASON"; + +interface SeasonEpisode { + episodeNumber: number; + title: string; +} const TARGET_OPTIONS: Array<{ id: LibraryTarget; label: string; hint: string }> = [ { id: "FILMS", label: "Films 1080", hint: "1080p → Films-map" }, @@ -86,6 +105,15 @@ function targetLabel(t?: string): string { return TARGET_OPTIONS.find((o) => o.id === t)?.label || t || "4K"; } +function jobDetailLabel(j: JobRow): string { + if (j.libraryTarget !== "SERIES") return targetLabel(j.libraryTarget); + const s = j.seasonNumber != null ? `S${String(j.seasonNumber).padStart(2, "0")}` : ""; + if (j.seriesMode === "EPISODE" && j.episodeNumber != null) { + return `Series · ${s}E${String(j.episodeNumber).padStart(2, "0")}`; + } + return `Series · ${s || "seizoen"}`; +} + function formatSpeed(bps: number | null | undefined): string { if (bps == null || bps < 0) return "—"; if (bps === 0) return "0 MB/s"; @@ -160,6 +188,7 @@ export default function DownloadsPage() { const [query, setQuery] = useState(""); const [libraryTarget, setLibraryTarget] = useState("FOUR_K"); + const [seriesMode, setSeriesMode] = useState("EPISODE"); const [searching, setSearching] = useState(false); const [hits, setHits] = useState([]); const [searchErr, setSearchErr] = useState(null); @@ -169,6 +198,11 @@ export default function DownloadsPage() { const [metaHits, setMetaHits] = useState([]); const [metaBusy, setMetaBusy] = useState(false); const [selectedMeta, setSelectedMeta] = useState(null); + const [seasonCount, setSeasonCount] = useState(0); + const [seasonNumber, setSeasonNumber] = useState(1); + const [seasonEpisodes, setSeasonEpisodes] = useState([]); + const [episodeNumber, setEpisodeNumber] = useState(1); + const [seasonBusy, setSeasonBusy] = useState(false); const [jobBusy, setJobBusy] = useState(false); const [jobErr, setJobErr] = useState(null); const [subs, setSubs] = useState([]); @@ -178,6 +212,7 @@ export default function DownloadsPage() { const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null); const [subsMsg, setSubsMsg] = useState(null); const [subPickJob, setSubPickJob] = useState(null); + const [subPickEpisode, setSubPickEpisode] = useState(null); const [subPickBusy, setSubPickBusy] = useState(false); const [jobs, setJobs] = useState([]); @@ -265,6 +300,10 @@ export default function DownloadsPage() { async function openMatch(hit: SearchHit) { setPick(hit); setSelectedMeta(null); + setSeasonCount(0); + setSeasonEpisodes([]); + setSeasonNumber(1); + setEpisodeNumber(1); setJobErr(null); setSubs([]); setSelectedSubId(null); @@ -280,8 +319,9 @@ export default function DownloadsPage() { if (!term) return; setMetaBusy(true); try { + const type = libraryTarget === "SERIES" ? "series" : "movie"; const res = await fetch( - `/api/v1/admin/metadata/search?type=movie&q=${encodeURIComponent(term)}`, + `/api/v1/admin/metadata/search?type=${type}&q=${encodeURIComponent(term)}`, { credentials: "include" } ); const data = await res.json().catch(() => ({})); @@ -291,7 +331,26 @@ export default function DownloadsPage() { } } - async function loadSubtitles(meta: MetaHit, releaseHint: string) { + async function loadSeason(tmdbId: number, season: number) { + setSeasonBusy(true); + try { + const res = await fetch(`/api/v1/admin/metadata/tv/${tmdbId}/season/${season}`, { + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + const eps: SeasonEpisode[] = data.season?.episodes ?? []; + setSeasonEpisodes(eps); + if (eps[0]) setEpisodeNumber(eps[0].episodeNumber); + } finally { + setSeasonBusy(false); + } + } + + async function loadSubtitles( + meta: MetaHit, + releaseHint: string, + opts?: { seasonNumber?: number; episodeNumber?: number } + ) { setSubsBusy(true); setSubsMsg(null); setSubs([]); @@ -306,6 +365,12 @@ export default function DownloadsPage() { if (meta.year) params.set("movieYear", String(meta.year)); if (meta.imdbId) params.set("imdbId", meta.imdbId); else params.set("tmdbId", String(meta.tmdbId)); + if (opts?.seasonNumber != null && opts?.episodeNumber != null) { + params.set("mediaKind", "episode"); + params.set("seasonNumber", String(opts.seasonNumber)); + params.set("episodeNumber", String(opts.episodeNumber)); + params.set("tmdbId", String(meta.tmdbId)); + } const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, { credentials: "include", }); @@ -322,13 +387,11 @@ export default function DownloadsPage() { const results: SubtitleHit[] = data.results ?? []; setSubs(results); if (results[0]) { - // Voorkeur OS.org — werkt zonder .com-login - const preferred = - results.find((r) => r.source === "org") || results[0]; + const preferred = results.find((r) => r.source === "org") || results[0]; setSelectedSubId(preferred.fileId); setSelectedSubSource(preferred.source === "org" ? "org" : "com"); } else { - setSubsMsg("Geen NL-ondertitels gevonden (alleen IMDb via OS.com + OS.org)"); + setSubsMsg("Geen NL-ondertitels gevonden"); } } finally { setSubsBusy(false); @@ -337,17 +400,81 @@ export default function DownloadsPage() { async function selectMeta(m: MetaHit) { setSelectedMeta(m); - if (pick) await loadSubtitles(m, pick.title); + setSubs([]); + setSelectedSubId(null); + if (libraryTarget !== "SERIES") { + if (pick) await loadSubtitles(m, pick.title); + return; + } + setSeasonBusy(true); + try { + const res = await fetch(`/api/v1/admin/metadata/tv/${m.tmdbId}`, { + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + const n = Math.max(1, data.series?.numberOfSeasons || 1); + setSeasonCount(n); + const season = Math.min(seasonNumber, n) || 1; + setSeasonNumber(season); + await loadSeason(m.tmdbId, season); + } finally { + setSeasonBusy(false); + } } + async function onSeasonChange(season: number) { + setSeasonNumber(season); + if (!selectedMeta) return; + await loadSeason(selectedMeta.tmdbId, season); + setSubs([]); + setSelectedSubId(null); + } + + async function onEpisodeChange(ep: number) { + setEpisodeNumber(ep); + if (!selectedMeta || !pick || seriesMode !== "EPISODE") return; + await loadSubtitles(selectedMeta, pick.title, { + seasonNumber, + episodeNumber: ep, + }); + } + + useEffect(() => { + if ( + libraryTarget === "SERIES" && + seriesMode === "EPISODE" && + selectedMeta && + pick && + seasonEpisodes.length > 0 + ) { + void loadSubtitles(selectedMeta, pick.title, { + seasonNumber, + episodeNumber, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seriesMode, seasonEpisodes, episodeNumber, seasonNumber]); + async function confirmDownload() { if (!pick || !selectedMeta || !pick.uri) { setJobErr("Kies een match en zorg dat er een download-URI is"); return; } + if (libraryTarget === "SERIES") { + if (!seasonNumber) { + setJobErr("Kies een seizoen"); + return; + } + if (seriesMode === "EPISODE" && !episodeNumber) { + setJobErr("Kies een aflevering"); + return; + } + } setJobBusy(true); setJobErr(null); try { + const epTitle = + seasonEpisodes.find((e) => e.episodeNumber === episodeNumber)?.title || undefined; const res = await fetch("/api/v1/admin/downloads/jobs", { method: "POST", credentials: "include", @@ -357,14 +484,38 @@ export default function DownloadsPage() { uri: pick.uri, sourceTitle: pick.title, libraryTarget, + seriesMode: libraryTarget === "SERIES" ? seriesMode : undefined, + seasonNumber: libraryTarget === "SERIES" ? seasonNumber : undefined, + episodeNumber: + libraryTarget === "SERIES" && seriesMode === "EPISODE" ? episodeNumber : undefined, + episodeTitle: + libraryTarget === "SERIES" && seriesMode === "EPISODE" ? epTitle : undefined, + episodes: + libraryTarget === "SERIES" && seriesMode === "SEASON" + ? seasonEpisodes.map((e) => ({ + episodeNumber: e.episodeNumber, + episodeTitle: e.title, + })) + : undefined, tmdbId: selectedMeta.tmdbId, imdbId: selectedMeta.imdbId ?? undefined, movieTitle: selectedMeta.title, movieYear: selectedMeta.year, posterUrl: selectedMeta.posterUrl, - subtitleFileId: selectedSubId, - subtitleLang: selectedSubId ? "nl" : null, - subtitleSource: selectedSubId ? selectedSubSource || "com" : null, + subtitleFileId: + libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId, + subtitleLang: + libraryTarget === "SERIES" && seriesMode === "SEASON" + ? null + : selectedSubId + ? "nl" + : null, + subtitleSource: + libraryTarget === "SERIES" && seriesMode === "SEASON" + ? null + : selectedSubId + ? selectedSubSource || "com" + : null, }), }); const data = await res.json().catch(() => ({})); @@ -422,8 +573,9 @@ export default function DownloadsPage() { loadJobs(); } - async function openSubtitlePicker(job: JobRow) { + async function openSubtitlePicker(job: JobRow, episode?: number) { setSubPickJob(job); + setSubPickEpisode(episode ?? job.episodeNumber ?? null); setSubs([]); setSelectedSubId(null); setSelectedSubSource(null); @@ -431,6 +583,7 @@ export default function DownloadsPage() { setSubsBusy(true); setSubsConfigured(true); try { + const ep = episode ?? job.episodeNumber ?? null; const params = new URLSearchParams({ languages: "nl", releaseHint: job.sourceTitle || job.movieTitle, @@ -438,8 +591,12 @@ export default function DownloadsPage() { }); if (job.movieYear) params.set("movieYear", String(job.movieYear)); if (job.imdbId) params.set("imdbId", job.imdbId); - else if (job.tmdbId) params.set("tmdbId", String(job.tmdbId)); - else { + if (job.tmdbId) params.set("tmdbId", String(job.tmdbId)); + if (job.libraryTarget === "SERIES" && job.seasonNumber != null && ep != null) { + params.set("mediaKind", "episode"); + params.set("seasonNumber", String(job.seasonNumber)); + params.set("episodeNumber", String(ep)); + } else if (!job.imdbId && !job.tmdbId) { setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken"); return; } @@ -453,19 +610,17 @@ export default function DownloadsPage() { } setSubsConfigured(data.configured !== false); const results: SubtitleHit[] = data.results ?? []; - // Toon .org eerst — werkt zonder .com-login results.sort((a, b) => { if (a.source === "org" && b.source !== "org") return -1; if (b.source === "org" && a.source !== "org") return 1; return (b.downloadCount || 0) - (a.downloadCount || 0); }); setSubs(results); - const preferred = results.find((r) => r.source === "org") || results[0]; - if (preferred) { - setSelectedSubId(preferred.fileId); - setSelectedSubSource(preferred.source === "org" ? "org" : "com"); + if (results[0]) { + setSelectedSubId(results[0].fileId); + setSelectedSubSource(results[0].source === "org" ? "org" : "com"); } else { - setSubsMsg("Geen NL-ondertitels gevonden"); + setSubsMsg("Geen ondertitels gevonden"); } } finally { setSubsBusy(false); @@ -487,6 +642,7 @@ export default function DownloadsPage() { subtitleFileId: selectedSubId, subtitleSource: selectedSubSource || "org", subtitleLang: "nl", + episodeNumber: subPickEpisode ?? undefined, }), }); const data = await res.json().catch(() => ({})); @@ -495,6 +651,7 @@ export default function DownloadsPage() { return; } setSubPickJob(null); + setSubPickEpisode(null); loadJobs(); } finally { setSubPickBusy(false); @@ -544,6 +701,30 @@ export default function DownloadsPage() { ))} + {libraryTarget === "SERIES" && ( +
+ + +
+ )}