Add full series download flow with episode and season import.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 02:30:40 +02:00
parent e841997399
commit bd21b01956
15 changed files with 1316 additions and 106 deletions

View file

@ -53,12 +53,25 @@ interface JobProgress {
dsStatus: string | null; dsStatus: string | null;
} }
interface EpisodeTrack {
episodeNumber: number;
episodeTitle: string;
importedPath: string | null;
subtitlePath: string | null;
subtitleError: string | null;
}
interface JobRow { interface JobRow {
id: string; id: string;
nodeId: string; nodeId: string;
nodeName: string; nodeName: string;
status: string; status: string;
libraryTarget?: LibraryTarget; libraryTarget?: LibraryTarget;
seriesMode?: "EPISODE" | "SEASON" | null;
seasonNumber?: number | null;
episodeNumber?: number | null;
episodeTitle?: string | null;
episodes?: EpisodeTrack[];
sourceTitle: string; sourceTitle: string;
movieTitle: string; movieTitle: string;
movieYear: number | null; movieYear: number | null;
@ -75,6 +88,12 @@ interface JobRow {
} }
type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; 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 }> = [ const TARGET_OPTIONS: Array<{ id: LibraryTarget; label: string; hint: string }> = [
{ id: "FILMS", label: "Films 1080", hint: "1080p → Films-map" }, { 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"; 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 { function formatSpeed(bps: number | null | undefined): string {
if (bps == null || bps < 0) return "—"; if (bps == null || bps < 0) return "—";
if (bps === 0) return "0 MB/s"; if (bps === 0) return "0 MB/s";
@ -160,6 +188,7 @@ export default function DownloadsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K"); const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K");
const [seriesMode, setSeriesMode] = useState<SeriesMode>("EPISODE");
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const [hits, setHits] = useState<SearchHit[]>([]); const [hits, setHits] = useState<SearchHit[]>([]);
const [searchErr, setSearchErr] = useState<string | null>(null); const [searchErr, setSearchErr] = useState<string | null>(null);
@ -169,6 +198,11 @@ export default function DownloadsPage() {
const [metaHits, setMetaHits] = useState<MetaHit[]>([]); const [metaHits, setMetaHits] = useState<MetaHit[]>([]);
const [metaBusy, setMetaBusy] = useState(false); const [metaBusy, setMetaBusy] = useState(false);
const [selectedMeta, setSelectedMeta] = useState<MetaHit | null>(null); const [selectedMeta, setSelectedMeta] = useState<MetaHit | null>(null);
const [seasonCount, setSeasonCount] = useState(0);
const [seasonNumber, setSeasonNumber] = useState(1);
const [seasonEpisodes, setSeasonEpisodes] = useState<SeasonEpisode[]>([]);
const [episodeNumber, setEpisodeNumber] = useState(1);
const [seasonBusy, setSeasonBusy] = useState(false);
const [jobBusy, setJobBusy] = useState(false); const [jobBusy, setJobBusy] = useState(false);
const [jobErr, setJobErr] = useState<string | null>(null); const [jobErr, setJobErr] = useState<string | null>(null);
const [subs, setSubs] = useState<SubtitleHit[]>([]); const [subs, setSubs] = useState<SubtitleHit[]>([]);
@ -178,6 +212,7 @@ export default function DownloadsPage() {
const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null); const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null);
const [subsMsg, setSubsMsg] = useState<string | null>(null); const [subsMsg, setSubsMsg] = useState<string | null>(null);
const [subPickJob, setSubPickJob] = useState<JobRow | null>(null); const [subPickJob, setSubPickJob] = useState<JobRow | null>(null);
const [subPickEpisode, setSubPickEpisode] = useState<number | null>(null);
const [subPickBusy, setSubPickBusy] = useState(false); const [subPickBusy, setSubPickBusy] = useState(false);
const [jobs, setJobs] = useState<JobRow[]>([]); const [jobs, setJobs] = useState<JobRow[]>([]);
@ -265,6 +300,10 @@ export default function DownloadsPage() {
async function openMatch(hit: SearchHit) { async function openMatch(hit: SearchHit) {
setPick(hit); setPick(hit);
setSelectedMeta(null); setSelectedMeta(null);
setSeasonCount(0);
setSeasonEpisodes([]);
setSeasonNumber(1);
setEpisodeNumber(1);
setJobErr(null); setJobErr(null);
setSubs([]); setSubs([]);
setSelectedSubId(null); setSelectedSubId(null);
@ -280,8 +319,9 @@ export default function DownloadsPage() {
if (!term) return; if (!term) return;
setMetaBusy(true); setMetaBusy(true);
try { try {
const type = libraryTarget === "SERIES" ? "series" : "movie";
const res = await fetch( 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" } { credentials: "include" }
); );
const data = await res.json().catch(() => ({})); 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); setSubsBusy(true);
setSubsMsg(null); setSubsMsg(null);
setSubs([]); setSubs([]);
@ -306,6 +365,12 @@ export default function DownloadsPage() {
if (meta.year) params.set("movieYear", String(meta.year)); if (meta.year) params.set("movieYear", String(meta.year));
if (meta.imdbId) params.set("imdbId", meta.imdbId); if (meta.imdbId) params.set("imdbId", meta.imdbId);
else params.set("tmdbId", String(meta.tmdbId)); 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}`, { const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
credentials: "include", credentials: "include",
}); });
@ -322,13 +387,11 @@ export default function DownloadsPage() {
const results: SubtitleHit[] = data.results ?? []; const results: SubtitleHit[] = data.results ?? [];
setSubs(results); setSubs(results);
if (results[0]) { 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); setSelectedSubId(preferred.fileId);
setSelectedSubSource(preferred.source === "org" ? "org" : "com"); setSelectedSubSource(preferred.source === "org" ? "org" : "com");
} else { } else {
setSubsMsg("Geen NL-ondertitels gevonden (alleen IMDb via OS.com + OS.org)"); setSubsMsg("Geen NL-ondertitels gevonden");
} }
} finally { } finally {
setSubsBusy(false); setSubsBusy(false);
@ -337,17 +400,81 @@ export default function DownloadsPage() {
async function selectMeta(m: MetaHit) { async function selectMeta(m: MetaHit) {
setSelectedMeta(m); setSelectedMeta(m);
setSubs([]);
setSelectedSubId(null);
if (libraryTarget !== "SERIES") {
if (pick) await loadSubtitles(m, pick.title); 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() { async function confirmDownload() {
if (!pick || !selectedMeta || !pick.uri) { if (!pick || !selectedMeta || !pick.uri) {
setJobErr("Kies een match en zorg dat er een download-URI is"); setJobErr("Kies een match en zorg dat er een download-URI is");
return; return;
} }
if (libraryTarget === "SERIES") {
if (!seasonNumber) {
setJobErr("Kies een seizoen");
return;
}
if (seriesMode === "EPISODE" && !episodeNumber) {
setJobErr("Kies een aflevering");
return;
}
}
setJobBusy(true); setJobBusy(true);
setJobErr(null); setJobErr(null);
try { try {
const epTitle =
seasonEpisodes.find((e) => e.episodeNumber === episodeNumber)?.title || undefined;
const res = await fetch("/api/v1/admin/downloads/jobs", { const res = await fetch("/api/v1/admin/downloads/jobs", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@ -357,14 +484,38 @@ export default function DownloadsPage() {
uri: pick.uri, uri: pick.uri,
sourceTitle: pick.title, sourceTitle: pick.title,
libraryTarget, 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, tmdbId: selectedMeta.tmdbId,
imdbId: selectedMeta.imdbId ?? undefined, imdbId: selectedMeta.imdbId ?? undefined,
movieTitle: selectedMeta.title, movieTitle: selectedMeta.title,
movieYear: selectedMeta.year, movieYear: selectedMeta.year,
posterUrl: selectedMeta.posterUrl, posterUrl: selectedMeta.posterUrl,
subtitleFileId: selectedSubId, subtitleFileId:
subtitleLang: selectedSubId ? "nl" : null, libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId,
subtitleSource: selectedSubId ? selectedSubSource || "com" : null, 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(() => ({})); const data = await res.json().catch(() => ({}));
@ -422,8 +573,9 @@ export default function DownloadsPage() {
loadJobs(); loadJobs();
} }
async function openSubtitlePicker(job: JobRow) { async function openSubtitlePicker(job: JobRow, episode?: number) {
setSubPickJob(job); setSubPickJob(job);
setSubPickEpisode(episode ?? job.episodeNumber ?? null);
setSubs([]); setSubs([]);
setSelectedSubId(null); setSelectedSubId(null);
setSelectedSubSource(null); setSelectedSubSource(null);
@ -431,6 +583,7 @@ export default function DownloadsPage() {
setSubsBusy(true); setSubsBusy(true);
setSubsConfigured(true); setSubsConfigured(true);
try { try {
const ep = episode ?? job.episodeNumber ?? null;
const params = new URLSearchParams({ const params = new URLSearchParams({
languages: "nl", languages: "nl",
releaseHint: job.sourceTitle || job.movieTitle, releaseHint: job.sourceTitle || job.movieTitle,
@ -438,8 +591,12 @@ export default function DownloadsPage() {
}); });
if (job.movieYear) params.set("movieYear", String(job.movieYear)); if (job.movieYear) params.set("movieYear", String(job.movieYear));
if (job.imdbId) params.set("imdbId", job.imdbId); if (job.imdbId) params.set("imdbId", job.imdbId);
else if (job.tmdbId) params.set("tmdbId", String(job.tmdbId)); if (job.tmdbId) params.set("tmdbId", String(job.tmdbId));
else { 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"); setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken");
return; return;
} }
@ -453,19 +610,17 @@ export default function DownloadsPage() {
} }
setSubsConfigured(data.configured !== false); setSubsConfigured(data.configured !== false);
const results: SubtitleHit[] = data.results ?? []; const results: SubtitleHit[] = data.results ?? [];
// Toon .org eerst — werkt zonder .com-login
results.sort((a, b) => { results.sort((a, b) => {
if (a.source === "org" && b.source !== "org") return -1; if (a.source === "org" && b.source !== "org") return -1;
if (b.source === "org" && a.source !== "org") return 1; if (b.source === "org" && a.source !== "org") return 1;
return (b.downloadCount || 0) - (a.downloadCount || 0); return (b.downloadCount || 0) - (a.downloadCount || 0);
}); });
setSubs(results); setSubs(results);
const preferred = results.find((r) => r.source === "org") || results[0]; if (results[0]) {
if (preferred) { setSelectedSubId(results[0].fileId);
setSelectedSubId(preferred.fileId); setSelectedSubSource(results[0].source === "org" ? "org" : "com");
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
} else { } else {
setSubsMsg("Geen NL-ondertitels gevonden"); setSubsMsg("Geen ondertitels gevonden");
} }
} finally { } finally {
setSubsBusy(false); setSubsBusy(false);
@ -487,6 +642,7 @@ export default function DownloadsPage() {
subtitleFileId: selectedSubId, subtitleFileId: selectedSubId,
subtitleSource: selectedSubSource || "org", subtitleSource: selectedSubSource || "org",
subtitleLang: "nl", subtitleLang: "nl",
episodeNumber: subPickEpisode ?? undefined,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -495,6 +651,7 @@ export default function DownloadsPage() {
return; return;
} }
setSubPickJob(null); setSubPickJob(null);
setSubPickEpisode(null);
loadJobs(); loadJobs();
} finally { } finally {
setSubPickBusy(false); setSubPickBusy(false);
@ -544,6 +701,30 @@ export default function DownloadsPage() {
</button> </button>
))} ))}
</div> </div>
{libraryTarget === "SERIES" && (
<div className="downloads-target-row downloads-mode-row" role="radiogroup" aria-label="Series-modus">
<button
type="button"
role="radio"
aria-checked={seriesMode === "EPISODE"}
className={`downloads-target ${seriesMode === "EPISODE" ? "on" : ""}`}
onClick={() => setSeriesMode("EPISODE")}
>
<strong>Aflevering</strong>
<span className="muted">1 file · 1 ondertitel</span>
</button>
<button
type="button"
role="radio"
aria-checked={seriesMode === "SEASON"}
className={`downloads-target ${seriesMode === "SEASON" ? "on" : ""}`}
onClick={() => setSeriesMode("SEASON")}
>
<strong>Seizoen</strong>
<span className="muted">pack → alle eps · subs later</span>
</button>
</div>
)}
<div className="downloads-toolbar"> <div className="downloads-toolbar">
<label className="downloads-field"> <label className="downloads-field">
<span>Node</span> <span>Node</span>
@ -656,7 +837,7 @@ export default function DownloadsPage() {
<div className="list-card-top"> <div className="list-card-top">
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
<span className="muted"> <span className="muted">
{targetLabel(j.libraryTarget)} · {j.nodeName} {jobDetailLabel(j)} · {j.nodeName}
</span> </span>
</div> </div>
<div className="downloads-progress-block"> <div className="downloads-progress-block">
@ -718,7 +899,7 @@ export default function DownloadsPage() {
</div> </div>
)} )}
</td> </td>
<td className="muted">{targetLabel(j.libraryTarget)}</td> <td className="muted">{jobDetailLabel(j)}</td>
<td> <td>
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
@ -770,7 +951,7 @@ export default function DownloadsPage() {
<div className="list-card-top"> <div className="list-card-top">
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
<span className="muted"> <span className="muted">
{targetLabel(j.libraryTarget)} · {j.nodeName} {jobDetailLabel(j)} · {j.nodeName}
</span> </span>
</div> </div>
<div className="list-card-title"> <div className="list-card-title">
@ -778,16 +959,44 @@ export default function DownloadsPage() {
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
</div> </div>
<div className="muted downloads-source">{j.sourceTitle}</div> <div className="muted downloads-source">{j.sourceTitle}</div>
{j.subtitlePath && ( {j.error && <div className="muted">{j.error}</div>}
{j.episodes && j.episodes.length > 0 && (
<ul className="downloads-episode-list">
{j.episodes.map((ep) => (
<li key={ep.episodeNumber}>
<span>
E{String(ep.episodeNumber).padStart(2, "0")}
{ep.episodeTitle ? ` · ${ep.episodeTitle}` : ""}
{!ep.importedPath
? " · niet geïmporteerd"
: ep.subtitlePath
? " · sub OK"
: " · sub mist"}
</span>
{ep.importedPath && !ep.subtitlePath && (
<button
type="button"
onClick={() => void openSubtitlePicker(j, ep.episodeNumber)}
>
Sub
</button>
)}
</li>
))}
</ul>
)}
{(!j.episodes || j.episodes.length === 0) && j.subtitlePath && (
<div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div> <div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div>
)} )}
{j.hasSubtitle && !j.subtitlePath && ( {(!j.episodes || j.episodes.length === 0) && j.hasSubtitle && !j.subtitlePath && (
<div className="danger-text"> <div className="danger-text">
{j.subtitleError || j.error || "Ondertitel ontbreekt in map"} {j.subtitleError || j.error || "Ondertitel ontbreekt in map"}
</div> </div>
)} )}
<div className="action-row"> <div className="action-row">
{j.importedPath && !j.subtitlePath && ( {(!j.episodes || j.episodes.length === 0) &&
j.importedPath &&
!j.subtitlePath && (
<button type="button" onClick={() => void openSubtitlePicker(j)}> <button type="button" onClick={() => void openSubtitlePicker(j)}>
Ondertitel kiezen Ondertitel kiezen
</button> </button>
@ -817,7 +1026,7 @@ export default function DownloadsPage() {
<td> <td>
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
</td> </td>
<td className="muted">{targetLabel(j.libraryTarget)}</td> <td className="muted">{jobDetailLabel(j)}</td>
<td> <td>
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
@ -905,7 +1114,52 @@ export default function DownloadsPage() {
))} ))}
</div> </div>
{selectedMeta && ( {selectedMeta && libraryTarget === "SERIES" && (
<div className="downloads-subs">
<h3 className="card-subtitle">Seizoen / aflevering</h3>
{seasonBusy && <p className="muted">Seizoen laden…</p>}
<div className="downloads-toolbar">
<label className="downloads-field">
<span>Seizoen</span>
<select
value={seasonNumber}
onChange={(e) => void onSeasonChange(parseInt(e.target.value, 10))}
disabled={seasonBusy || seasonCount < 1}
>
{Array.from({ length: Math.max(seasonCount, 1) }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
Seizoen {n}
</option>
))}
</select>
</label>
{seriesMode === "EPISODE" && (
<label className="downloads-field downloads-field-grow">
<span>Aflevering</span>
<select
value={episodeNumber}
onChange={(e) => void onEpisodeChange(parseInt(e.target.value, 10))}
disabled={seasonBusy || seasonEpisodes.length === 0}
>
{seasonEpisodes.map((ep) => (
<option key={ep.episodeNumber} value={ep.episodeNumber}>
E{String(ep.episodeNumber).padStart(2, "0")} · {ep.title}
</option>
))}
</select>
</label>
)}
</div>
{seriesMode === "SEASON" && (
<p className="muted">
{seasonEpisodes.length} afleveringen worden verwacht. Ondertitels kies je per
episode ná import (checklist onder Voltooid).
</p>
)}
</div>
)}
{selectedMeta && !(libraryTarget === "SERIES" && seriesMode === "SEASON") && (
<div className="downloads-subs"> <div className="downloads-subs">
<h3 className="card-subtitle">Ondertitel (NL)</h3> <h3 className="card-subtitle">Ondertitel (NL)</h3>
<p className="muted" style={{ marginTop: 0 }}> <p className="muted" style={{ marginTop: 0 }}>
@ -925,7 +1179,7 @@ export default function DownloadsPage() {
> >
<span> <span>
<strong>Geen ondertitel</strong> <strong>Geen ondertitel</strong>
<small className="muted">Alleen film importeren</small> <small className="muted">Alleen video importeren</small>
</span> </span>
</button> </button>
{subs.map((s) => ( {subs.map((s) => (

View file

@ -1418,6 +1418,27 @@ tr.row-selected {
gap: 0.35rem; gap: 0.35rem;
justify-content: flex-end; justify-content: flex-end;
} }
.downloads-mode-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.downloads-episode-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.downloads-episode-list li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
padding: 0.35rem 0.5rem;
border-radius: 6px;
border: 1px solid var(--border-dim);
background: var(--bg-elevated);
}
.downloads-progress-cell { .downloads-progress-cell {
min-width: 7.5rem; min-width: 7.5rem;
max-width: 10rem; max-width: 10rem;

View file

@ -0,0 +1,11 @@
-- CreateEnum
DO $$ BEGIN
CREATE TYPE "DownloadSeriesMode" AS ENUM ('EPISODE', 'SEASON');
EXCEPTION WHEN duplicate_object THEN null; END $$;
-- AlterTable
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "series_mode" "DownloadSeriesMode";
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "season_number" INTEGER;
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_number" INTEGER;
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_title" TEXT;
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episodes_json" TEXT;

View file

@ -599,12 +599,19 @@ enum DownloadLibraryTarget {
SERIES SERIES
} }
enum DownloadSeriesMode {
EPISODE
SEASON
}
model DownloadJob { model DownloadJob {
id String @id @default(uuid()) id String @id @default(uuid())
nodeId String @map("node_id") nodeId String @map("node_id")
status DownloadJobStatus @default(DOWNLOADING) status DownloadJobStatus @default(DOWNLOADING)
/** Where to import after DS finishes */ /** Where to import after DS finishes */
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target") libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
/** Series only: single episode vs whole season pack */
seriesMode DownloadSeriesMode? @map("series_mode")
sourceUri String @map("source_uri") sourceUri String @map("source_uri")
sourceTitle String @map("source_title") sourceTitle String @map("source_title")
dsTaskId String? @map("ds_task_id") dsTaskId String? @map("ds_task_id")
@ -613,6 +620,11 @@ model DownloadJob {
movieTitle String @map("movie_title") movieTitle String @map("movie_title")
movieYear Int? @map("movie_year") movieYear Int? @map("movie_year")
posterUrl String? @map("poster_url") posterUrl String? @map("poster_url")
seasonNumber Int? @map("season_number")
episodeNumber Int? @map("episode_number")
episodeTitle String? @map("episode_title")
/** Season pack tracking JSON */
episodesJson String? @map("episodes_json") @db.Text
destFolderName String? @map("dest_folder_name") destFolderName String? @map("dest_folder_name")
sourcePath String? @map("source_path") sourcePath String? @map("source_path")
importedPath String? @map("imported_path") importedPath String? @map("imported_path")

View file

@ -632,6 +632,40 @@ export async function registerAdminRoutes(app: FastifyInstance, config: Config)
return { results }; return { results };
}); });
app.get("/api/v1/admin/metadata/tv/:tmdbId", { preHandler: requireAdmin }, async (request) => {
const { tmdbId: raw } = request.params as { tmdbId: string };
const tmdbId = parseInt(raw, 10);
if (!Number.isFinite(tmdbId)) throw new AppError("INVALID_REQUEST", "tmdbId ongeldig", 400);
if (!config.TMDB_API_KEY) {
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
}
const series = await metadata.getTvSeriesInfo(tmdbId);
if (!series) throw new AppError("NOT_FOUND", "Serie niet gevonden", 404);
return { series };
});
app.get(
"/api/v1/admin/metadata/tv/:tmdbId/season/:season",
{ preHandler: requireAdmin },
async (request) => {
const { tmdbId: rawId, season: rawSeason } = request.params as {
tmdbId: string;
season: string;
};
const tmdbId = parseInt(rawId, 10);
const seasonNumber = parseInt(rawSeason, 10);
if (!Number.isFinite(tmdbId) || !Number.isFinite(seasonNumber)) {
throw new AppError("INVALID_REQUEST", "tmdbId/season ongeldig", 400);
}
if (!config.TMDB_API_KEY) {
throw new AppError("CONFIG", "TMDB_API_KEY ontbreekt", 503);
}
const season = await metadata.getTvSeason(tmdbId, seasonNumber);
if (!season) throw new AppError("NOT_FOUND", "Seizoen niet gevonden", 404);
return { season };
}
);
app.post("/api/v1/admin/movies/:id/link", { preHandler: requireAdmin }, async (request) => { app.post("/api/v1/admin/movies/:id/link", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const body = request.body as { tmdbId?: number; imdbId?: string }; const body = request.body as { tmdbId?: number; imdbId?: string };

View file

@ -317,6 +317,26 @@ async function main() {
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K' ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K'
`); `);
await prisma.$executeRawUnsafe(`
DO $$ BEGIN
CREATE TYPE "DownloadSeriesMode" AS ENUM ('EPISODE', 'SEASON');
EXCEPTION WHEN duplicate_object THEN null; END $$;
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "series_mode" "DownloadSeriesMode"
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "season_number" INTEGER
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_number" INTEGER
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episode_title" TEXT
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "episodes_json" TEXT
`);
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "integration_settings" ( CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,

View file

@ -3,7 +3,14 @@ import { requireAdmin } from "../auth/routes";
import type { Config } from "../config"; import type { Config } from "../config";
import { MetadataService } from "../metadata/service"; import { MetadataService } from "../metadata/service";
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
import { DownloadService, isLibraryTarget, startDownloadPoller, type LibraryTarget } from "./service"; import {
DownloadService,
isLibraryTarget,
isSeriesMode,
startDownloadPoller,
type LibraryTarget,
} from "./service";
import { parseEpisodesJson } from "./series-track";
function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) { function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
return { return {
@ -12,6 +19,11 @@ function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
nodeName: j.node.name, nodeName: j.node.name,
status: j.status, status: j.status,
libraryTarget: j.libraryTarget, libraryTarget: j.libraryTarget,
seriesMode: j.seriesMode,
seasonNumber: j.seasonNumber,
episodeNumber: j.episodeNumber,
episodeTitle: j.episodeTitle,
episodes: parseEpisodesJson(j.episodesJson),
sourceTitle: j.sourceTitle, sourceTitle: j.sourceTitle,
movieTitle: j.movieTitle, movieTitle: j.movieTitle,
movieYear: j.movieYear, movieYear: j.movieYear,
@ -100,10 +112,20 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
releaseHint?: string; releaseHint?: string;
movieTitle?: string; movieTitle?: string;
movieYear?: string; movieYear?: string;
seasonNumber?: string;
episodeNumber?: string;
mediaKind?: string;
query?: string;
}; };
const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined; const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined;
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) { const seasonNumber = q.seasonNumber ? parseInt(q.seasonNumber, 10) : undefined;
throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400); const episodeNumber = q.episodeNumber ? parseInt(q.episodeNumber, 10) : undefined;
const isEpisode =
q.mediaKind === "episode" ||
(seasonNumber != null && episodeNumber != null && episodeNumber > 0);
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId)) && !q.query?.trim()) {
throw new AppError("INVALID_REQUEST", "imdbId, tmdbId of query verplicht", 400);
} }
const movieYear = q.movieYear ? parseInt(q.movieYear, 10) : undefined; const movieYear = q.movieYear ? parseInt(q.movieYear, 10) : undefined;
const out = await downloads.searchSubtitles({ const out = await downloads.searchSubtitles({
@ -113,6 +135,10 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
releaseHint: q.releaseHint, releaseHint: q.releaseHint,
movieTitle: q.movieTitle, movieTitle: q.movieTitle,
movieYear: movieYear && Number.isFinite(movieYear) ? movieYear : null, movieYear: movieYear && Number.isFinite(movieYear) ? movieYear : null,
query: q.query,
seasonNumber: seasonNumber && Number.isFinite(seasonNumber) ? seasonNumber : undefined,
episodeNumber: episodeNumber && Number.isFinite(episodeNumber) ? episodeNumber : undefined,
mediaKind: isEpisode ? "episode" : "movie",
}); });
return out; return out;
}); });
@ -139,6 +165,11 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
uri?: string; uri?: string;
sourceTitle?: string; sourceTitle?: string;
libraryTarget?: string; libraryTarget?: string;
seriesMode?: string;
seasonNumber?: number;
episodeNumber?: number;
episodeTitle?: string;
episodes?: Array<{ episodeNumber: number; episodeTitle?: string }>;
tmdbId?: number; tmdbId?: number;
imdbId?: string; imdbId?: string;
movieTitle?: string; movieTitle?: string;
@ -159,6 +190,11 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
uri: body.uri, uri: body.uri,
sourceTitle: body.sourceTitle || body.movieTitle, sourceTitle: body.sourceTitle || body.movieTitle,
libraryTarget: body.libraryTarget, libraryTarget: body.libraryTarget,
seriesMode: isSeriesMode(body.seriesMode) ? body.seriesMode : null,
seasonNumber: body.seasonNumber,
episodeNumber: body.episodeNumber,
episodeTitle: body.episodeTitle,
episodes: body.episodes,
tmdbId: body.tmdbId, tmdbId: body.tmdbId,
imdbId: body.imdbId, imdbId: body.imdbId,
movieTitle: body.movieTitle, movieTitle: body.movieTitle,
@ -183,7 +219,19 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
subtitleFileId?: number | null; subtitleFileId?: number | null;
subtitleSource?: "com" | "org" | null; subtitleSource?: "com" | "org" | null;
subtitleLang?: string | null; subtitleLang?: string | null;
episodeNumber?: number;
}; };
if (body.episodeNumber != null && Number.isFinite(body.episodeNumber)) {
if (body.subtitleFileId == null) {
throw new AppError("INVALID_REQUEST", "subtitleFileId verplicht", 400);
}
const job = await downloads.attachEpisodeSubtitle(id, Math.floor(body.episodeNumber), {
subtitleFileId: body.subtitleFileId,
subtitleSource: body.subtitleSource,
subtitleLang: body.subtitleLang,
});
return { job };
}
const job = await downloads.retrySubtitle(id, { const job = await downloads.retrySubtitle(id, {
subtitleFileId: body.subtitleFileId, subtitleFileId: body.subtitleFileId,
subtitleSource: body.subtitleSource, subtitleSource: body.subtitleSource,

View file

@ -0,0 +1,44 @@
export type SeriesEpisodeTrack = {
episodeNumber: number;
episodeTitle: string;
importedPath: string | null;
subtitlePath: string | null;
subtitleError: string | null;
};
export function parseEpisodesJson(raw: string | null | undefined): SeriesEpisodeTrack[] {
if (!raw?.trim()) return [];
try {
const data = JSON.parse(raw) as unknown;
if (!Array.isArray(data)) return [];
return data
.map((row) => {
const r = row as Partial<SeriesEpisodeTrack>;
const episodeNumber = Number(r.episodeNumber);
if (!Number.isFinite(episodeNumber) || episodeNumber <= 0) return null;
return {
episodeNumber,
episodeTitle: typeof r.episodeTitle === "string" ? r.episodeTitle : "",
importedPath: r.importedPath || null,
subtitlePath: r.subtitlePath || null,
subtitleError: r.subtitleError || null,
};
})
.filter((x): x is SeriesEpisodeTrack => !!x);
} catch {
return [];
}
}
export function stringifyEpisodes(tracks: SeriesEpisodeTrack[]): string {
return JSON.stringify(tracks);
}
export function folderNameForShow(title: string, year?: number | null): string {
const safe = title
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "")
.replace(/\s+/g, " ")
.trim();
if (year && year > 1900) return `${safe} (${year})`;
return safe;
}

View file

@ -19,16 +19,28 @@ import {
} from "../opensubtitles/client"; } from "../opensubtitles/client";
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client"; import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles"; import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
import type { DownloadLibraryTarget, SynologyDsConfig } from "@prisma/client"; import type { DownloadLibraryTarget, DownloadSeriesMode, SynologyDsConfig } from "@prisma/client";
import {
folderNameForShow,
parseEpisodesJson,
stringifyEpisodes,
type SeriesEpisodeTrack,
} from "./series-track";
export type LibraryTarget = DownloadLibraryTarget; export type LibraryTarget = DownloadLibraryTarget;
export type SeriesMode = DownloadSeriesMode;
const LIBRARY_TARGETS: LibraryTarget[] = ["FILMS", "FOUR_K", "SERIES"]; const LIBRARY_TARGETS: LibraryTarget[] = ["FILMS", "FOUR_K", "SERIES"];
const SERIES_MODES: SeriesMode[] = ["EPISODE", "SEASON"];
export function isLibraryTarget(v: unknown): v is LibraryTarget { export function isLibraryTarget(v: unknown): v is LibraryTarget {
return typeof v === "string" && LIBRARY_TARGETS.includes(v as LibraryTarget); return typeof v === "string" && LIBRARY_TARGETS.includes(v as LibraryTarget);
} }
export function isSeriesMode(v: unknown): v is SeriesMode {
return typeof v === "string" && SERIES_MODES.includes(v as SeriesMode);
}
function normalizeBaseUrl(raw: string): string { function normalizeBaseUrl(raw: string): string {
let u = raw.trim().replace(/\/+$/, ""); let u = raw.trim().replace(/\/+$/, "");
if (!/^https?:\/\//i.test(u)) u = `https://${u}`; if (!/^https?:\/\//i.test(u)) u = `https://${u}`;
@ -135,6 +147,9 @@ export class DownloadService {
movieYear?: number | null; movieYear?: number | null;
/** Fallback voor OS.org (bijv. serie S01E02) als IMDb ontbreekt of episode-specifiek. */ /** Fallback voor OS.org (bijv. serie S01E02) als IMDb ontbreekt of episode-specifiek. */
query?: string; query?: string;
seasonNumber?: number;
episodeNumber?: number;
mediaKind?: "movie" | "episode";
}): Promise<{ }): Promise<{
imdbId: string | null; imdbId: string | null;
results: SubtitleHit[]; results: SubtitleHit[];
@ -149,16 +164,33 @@ export class DownloadService {
const orgClient = this.orgClient(); const orgClient = this.orgClient();
const configured = true; const configured = true;
const isEpisode =
opts.mediaKind === "episode" ||
(opts.seasonNumber != null &&
opts.episodeNumber != null &&
opts.episodeNumber > 0);
let imdbId = opts.imdbId?.trim() || null; let imdbId = opts.imdbId?.trim() || null;
if (!imdbId && opts.tmdbId) { if (!imdbId && opts.tmdbId) {
imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId); imdbId = isEpisode
? await this.metadata.resolveSeriesImdbId(opts.tmdbId)
: await this.metadata.resolveMovieImdbId(opts.tmdbId);
} }
const languages = opts.languages?.trim() || "nl"; const languages = opts.languages?.trim() || "nl";
const query = opts.query?.trim() || undefined; let query = opts.query?.trim() || undefined;
if (
!query &&
isEpisode &&
opts.movieTitle &&
opts.seasonNumber != null &&
opts.episodeNumber != null
) {
query = `${opts.movieTitle} S${String(opts.seasonNumber).padStart(2, "0")}E${String(opts.episodeNumber).padStart(2, "0")}`;
}
// Zonder IMDb: alleen OS.org query (als aanwezig) // Zonder IMDb: alleen OS.org query (als aanwezig)
if (!imdbId && !query) { if (!imdbId && !query && !(isEpisode && opts.tmdbId)) {
return { imdbId: null, results: [], configured }; return { imdbId: null, results: [], configured };
} }
@ -170,8 +202,20 @@ export class DownloadService {
return [] as SubtitleHit[]; return [] as SubtitleHit[];
}); });
if (imdbId && comClient) { if (comClient && (imdbId || (isEpisode && opts.tmdbId))) {
tasks.push(safe("com/imdb", comClient.search({ imdbId, languages }))); tasks.push(
safe(
"com",
comClient.search({
imdbId: imdbId || undefined,
languages,
type: isEpisode ? "episode" : "movie",
seasonNumber: opts.seasonNumber,
episodeNumber: opts.episodeNumber,
parentTmdbId: isEpisode ? opts.tmdbId : undefined,
})
)
);
} }
if (imdbId) { if (imdbId) {
tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages })));
@ -478,6 +522,11 @@ export class DownloadService {
uri: string; uri: string;
sourceTitle: string; sourceTitle: string;
libraryTarget: LibraryTarget; libraryTarget: LibraryTarget;
seriesMode?: SeriesMode | null;
seasonNumber?: number | null;
episodeNumber?: number | null;
episodeTitle?: string | null;
episodes?: Array<{ episodeNumber: number; episodeTitle?: string }>;
tmdbId?: number; tmdbId?: number;
imdbId?: string; imdbId?: string;
movieTitle: string; movieTitle: string;
@ -490,7 +539,7 @@ export class DownloadService {
const uri = input.uri.trim(); const uri = input.uri.trim();
if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400); if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400);
if (!input.movieTitle.trim()) { if (!input.movieTitle.trim()) {
throw new AppError("INVALID_REQUEST", "Bevestig eerst de film (titel)", 400); throw new AppError("INVALID_REQUEST", "Bevestig eerst de titel", 400);
} }
if (!input.tmdbId && !input.imdbId) { if (!input.tmdbId && !input.imdbId) {
throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400); throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400);
@ -499,6 +548,21 @@ export class DownloadService {
throw new AppError("INVALID_REQUEST", "Ongeldig bibliotheekdoel", 400); throw new AppError("INVALID_REQUEST", "Ongeldig bibliotheekdoel", 400);
} }
const isSeries = input.libraryTarget === "SERIES";
let seriesMode: SeriesMode | null = null;
if (isSeries) {
if (!isSeriesMode(input.seriesMode)) {
throw new AppError("INVALID_REQUEST", "seriesMode verplicht (EPISODE of SEASON)", 400);
}
seriesMode = input.seriesMode;
if (!input.seasonNumber || input.seasonNumber < 0) {
throw new AppError("INVALID_REQUEST", "Seizoen verplicht", 400);
}
if (seriesMode === "EPISODE" && (!input.episodeNumber || input.episodeNumber <= 0)) {
throw new AppError("INVALID_REQUEST", "Aflevering verplicht", 400);
}
}
const { client, config } = await this.clientForNode(input.nodeId); const { client, config } = await this.clientForNode(input.nodeId);
requireLibraryPath(config, input.libraryTarget); requireLibraryPath(config, input.libraryTarget);
@ -509,7 +573,7 @@ export class DownloadService {
let posterUrl = input.posterUrl ?? null; let posterUrl = input.posterUrl ?? null;
if (!tmdbId && imdbId) { if (!tmdbId && imdbId) {
const hits = await this.metadata.searchCatalog("movie", imdbId); const hits = await this.metadata.searchCatalog(isSeries ? "series" : "movie", imdbId);
const hit = hits[0]; const hit = hits[0];
if (hit) { if (hit) {
tmdbId = hit.tmdbId; tmdbId = hit.tmdbId;
@ -521,11 +585,31 @@ export class DownloadService {
} }
if (tmdbId && !imdbId) { if (tmdbId && !imdbId) {
imdbId = await this.metadata.resolveMovieImdbId(tmdbId); imdbId = isSeries
? await this.metadata.resolveSeriesImdbId(tmdbId)
: await this.metadata.resolveMovieImdbId(tmdbId);
} }
if (tmdbId) { if (tmdbId) {
try { try {
if (isSeries) {
await prisma.series.upsert({
where: { tmdbId },
create: {
title,
year,
posterUrl,
tmdbId,
imdbId,
},
update: {
title,
year: year ?? undefined,
posterUrl: posterUrl ?? undefined,
...(imdbId ? { imdbId } : {}),
},
});
} else {
await prisma.movie.upsert({ await prisma.movie.upsert({
where: { tmdbId }, where: { tmdbId },
create: { create: {
@ -542,15 +626,55 @@ export class DownloadService {
...(imdbId ? { imdbId } : {}), ...(imdbId ? { imdbId } : {}),
}, },
}); });
}
} catch (err) { } catch (err) {
console.warn("[downloads] movie upsert:", err); console.warn("[downloads] catalog upsert:", err);
} }
} }
const destFolderName = folderNameForMovie(title, year); let episodeTracks: SeriesEpisodeTrack[] = [];
let episodeNumber = input.episodeNumber ?? null;
let episodeTitle = input.episodeTitle?.trim() || null;
const seasonNumber = isSeries ? input.seasonNumber! : null;
if (isSeries && seriesMode === "SEASON" && tmdbId) {
const season = await this.metadata.getTvSeason(tmdbId, seasonNumber!);
const fromMeta = season?.episodes ?? [];
const picked =
input.episodes && input.episodes.length > 0
? input.episodes
: fromMeta.map((e) => ({ episodeNumber: e.episodeNumber, episodeTitle: e.title }));
if (picked.length === 0) {
throw new AppError("INVALID_REQUEST", "Geen afleveringen voor dit seizoen", 400);
}
episodeTracks = picked.map((e) => ({
episodeNumber: e.episodeNumber,
episodeTitle: e.episodeTitle || fromMeta.find((m) => m.episodeNumber === e.episodeNumber)?.title || "",
importedPath: null,
subtitlePath: null,
subtitleError: null,
}));
} else if (isSeries && seriesMode === "EPISODE") {
episodeTracks = [
{
episodeNumber: episodeNumber!,
episodeTitle: episodeTitle || "",
importedPath: null,
subtitlePath: null,
subtitleError: null,
},
];
}
const destFolderName = isSeries
? folderNameForShow(title, year)
: folderNameForMovie(title, year);
const releaseTags = extractReleaseTags(input.sourceTitle, title, year) || null; const releaseTags = extractReleaseTags(input.sourceTitle, title, year) || null;
// Season packs: no single subtitle at create. Episode: optional.
const allowSubtitle = !isSeries || seriesMode === "EPISODE";
const subtitleFileId = const subtitleFileId =
input.subtitleFileId != null && Number.isFinite(input.subtitleFileId) allowSubtitle && input.subtitleFileId != null && Number.isFinite(input.subtitleFileId)
? Math.floor(input.subtitleFileId) ? Math.floor(input.subtitleFileId)
: null; : null;
const subtitleLang = subtitleFileId const subtitleLang = subtitleFileId
@ -562,7 +686,6 @@ export class DownloadService {
: "com" : "com"
: null; : null;
// Download ondertitel meteen — anders start de film zonder zichtbare fout
let subtitleContent: string | null = null; let subtitleContent: string | null = null;
let subtitleFileName: string | null = null; let subtitleFileName: string | null = null;
if (subtitleFileId && subtitleSource) { if (subtitleFileId && subtitleSource) {
@ -615,6 +738,7 @@ export class DownloadService {
nodeId: input.nodeId, nodeId: input.nodeId,
status: "DOWNLOADING", status: "DOWNLOADING",
libraryTarget: input.libraryTarget, libraryTarget: input.libraryTarget,
seriesMode,
sourceUri: uri, sourceUri: uri,
sourceTitle: input.sourceTitle.trim() || title, sourceTitle: input.sourceTitle.trim() || title,
dsTaskId, dsTaskId,
@ -623,6 +747,10 @@ export class DownloadService {
movieTitle: title, movieTitle: title,
movieYear: year, movieYear: year,
posterUrl, posterUrl,
seasonNumber,
episodeNumber,
episodeTitle,
episodesJson: episodeTracks.length ? stringifyEpisodes(episodeTracks) : null,
destFolderName, destFolderName,
releaseTags, releaseTags,
subtitleFileId, subtitleFileId,
@ -634,7 +762,7 @@ export class DownloadService {
}, },
}); });
if (subtitleContent) { if (subtitleContent && !isSeries) {
try { try {
let movieId: string | null = null; let movieId: string | null = null;
if (tmdbId) { if (tmdbId) {
@ -830,7 +958,12 @@ export class DownloadService {
return; return;
} }
const destFolder = job.destFolderName || folderNameForMovie(job.movieTitle, job.movieYear); const isSeries = job.libraryTarget === "SERIES";
const destFolder =
job.destFolderName ||
(isSeries
? folderNameForShow(job.movieTitle, job.movieYear)
: folderNameForMovie(job.movieTitle, job.movieYear));
let destRoot: string; let destRoot: string;
try { try {
destRoot = requireLibraryPath(config, job.libraryTarget); destRoot = requireLibraryPath(config, job.libraryTarget);
@ -859,6 +992,19 @@ export class DownloadService {
data: { status: "IMPORTING", sourcePath, destFolderName: destFolder, error: null }, data: { status: "IMPORTING", sourcePath, destFolderName: destFolder, error: null },
}); });
let tracks = parseEpisodesJson(job.episodesJson);
if (isSeries && tracks.length === 0 && job.episodeNumber) {
tracks = [
{
episodeNumber: job.episodeNumber,
episodeTitle: job.episodeTitle || "",
importedPath: null,
subtitlePath: null,
subtitleError: null,
},
];
}
let subtitle: let subtitle:
| { language: string; extension?: string; contentBase64: string } | { language: string; extension?: string; contentBase64: string }
| undefined; | undefined;
@ -866,7 +1012,9 @@ export class DownloadService {
let subtitleFileName: string | null = null; let subtitleFileName: string | null = null;
let subtitleError: string | null = null; let subtitleError: string | null = null;
if (job.subtitleFileId || job.subtitleContent) { // Only attach subtitle on import for movies / single-episode series
const attachSubtitleNow = !isSeries || job.seriesMode === "EPISODE";
if (attachSubtitleNow && (job.subtitleFileId || job.subtitleContent)) {
try { try {
if (!subtitleText?.trim() && job.subtitleFileId) { if (!subtitleText?.trim() && job.subtitleFileId) {
const source = job.subtitleSource === "org" ? "org" : "com"; const source = job.subtitleSource === "org" ? "org" : "com";
@ -909,9 +1057,17 @@ export class DownloadService {
movieYear: job.movieYear, movieYear: job.movieYear,
releaseTags, releaseTags,
qualityHint: qualityHintForTarget(job.libraryTarget), qualityHint: qualityHintForTarget(job.libraryTarget),
subtitle, kind: isSeries ? "series" : "movie",
seasonNumber: isSeries ? job.seasonNumber ?? undefined : undefined,
episodes: isSeries
? tracks.map((t) => ({
episodeNumber: t.episodeNumber,
episodeTitle: t.episodeTitle || undefined,
}))
: undefined,
subtitle: attachSubtitleNow ? subtitle : undefined,
}, },
120_000 isSeries && job.seriesMode === "SEASON" ? 300_000 : 120_000
); );
if (!ack.ok) { if (!ack.ok) {
@ -926,6 +1082,75 @@ export class DownloadService {
return; return;
} }
if (isSeries) {
const byEp = new Map((ack.episodes ?? []).map((e) => [e.episodeNumber, e]));
tracks = tracks.map((t) => {
const hit = byEp.get(t.episodeNumber);
if (!hit) return t;
return {
...t,
importedPath: hit.importedPath || t.importedPath,
subtitlePath: hit.subtitlePath || t.subtitlePath,
};
});
// Also add unexpected imported episodes
for (const ep of ack.episodes ?? []) {
if (!tracks.some((t) => t.episodeNumber === ep.episodeNumber)) {
tracks.push({
episodeNumber: ep.episodeNumber,
episodeTitle: "",
importedPath: ep.importedPath,
subtitlePath: ep.subtitlePath || null,
subtitleError: null,
});
}
}
const importedCount = tracks.filter((t) => t.importedPath).length;
const missing = tracks.filter((t) => !t.importedPath).map((t) => `E${String(t.episodeNumber).padStart(2, "0")}`);
if (job.seriesMode === "EPISODE" && subtitle && !ack.subtitlePath && !tracks[0]?.subtitlePath) {
subtitleError =
subtitleError ||
"Aflevering geïmporteerd, maar node schreef geen .srt — update media-node";
if (tracks[0]) tracks[0].subtitleError = subtitleError;
}
let statusNote: string | null = null;
if (importedCount === 0) {
await prisma.downloadJob.update({
where: { id: jobId },
data: {
status: "FAILED",
error: "Geen afleveringen geïmporteerd",
episodesJson: stringifyEpisodes(tracks),
},
});
return;
}
if (missing.length > 0) {
statusNote = `Geïmporteerd ${importedCount}/${tracks.length}; mist: ${missing.join(", ")}`;
} else if (job.seriesMode === "SEASON") {
const needSubs = tracks.filter((t) => t.importedPath && !t.subtitlePath).length;
if (needSubs > 0) {
statusNote = `${importedCount} afleveringen klaar — kies nog ondertitels (${needSubs})`;
}
}
await prisma.downloadJob.update({
where: { id: jobId },
data: {
status: "IMPORTED",
importedPath: ack.importedPath ?? tracks.find((t) => t.importedPath)?.importedPath ?? null,
subtitlePath:
ack.subtitlePath ?? tracks.find((t) => t.subtitlePath)?.subtitlePath ?? null,
subtitleError,
episodesJson: stringifyEpisodes(tracks),
error: statusNote || subtitleError,
},
});
return;
}
if (subtitle && !ack.subtitlePath) { if (subtitle && !ack.subtitlePath) {
subtitleError = subtitleError =
subtitleError || subtitleError ||
@ -976,6 +1201,72 @@ export class DownloadService {
} }
} }
/** Attach / replace subtitle for one episode of an imported series job. */
async attachEpisodeSubtitle(
jobId: string,
episodeNumber: number,
opts: {
subtitleFileId: number;
subtitleSource?: SubtitleSource | null;
subtitleLang?: string | null;
}
) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
if (job.libraryTarget !== "SERIES") {
throw new AppError("INVALID_REQUEST", "Alleen voor series-jobs", 400);
}
const tracks = parseEpisodesJson(job.episodesJson);
const track = tracks.find((t) => t.episodeNumber === episodeNumber);
if (!track?.importedPath) {
throw new AppError("INVALID_REQUEST", "Aflevering nog niet geïmporteerd", 400);
}
const source: SubtitleSource = opts.subtitleSource === "org" ? "org" : "com";
const file = await this.downloadSubtitleFile(Math.floor(opts.subtitleFileId), source);
const content = file.content.toString("utf8");
if (!content.trim()) throw new AppError("OPENSUBTITLES", "Ondertitelbestand is leeg", 400);
const ext = file.fileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
const lang = (opts.subtitleLang || "nl").trim().toLowerCase() || "nl";
const ack = await nodeConnectionManager.writeSubtitleAcked(
job.nodeId,
{
jobId: `${job.id}:E${episodeNumber}`,
videoPath: track.importedPath,
subtitle: {
language: lang,
extension: ext,
contentBase64: Buffer.from(content, "utf8").toString("base64"),
},
},
60_000
);
if (!ack.ok) {
track.subtitleError = ack.error || "Ondertitel schrijven mislukt";
await prisma.downloadJob.update({
where: { id: jobId },
data: { episodesJson: stringifyEpisodes(tracks), error: track.subtitleError },
});
throw new AppError("SUBTITLE", track.subtitleError, 400);
}
track.subtitlePath = ack.subtitlePath || null;
track.subtitleError = null;
const needSubs = tracks.filter((t) => t.importedPath && !t.subtitlePath).length;
await prisma.downloadJob.update({
where: { id: jobId },
data: {
episodesJson: stringifyEpisodes(tracks),
error:
needSubs > 0
? `${tracks.filter((t) => t.importedPath).length} afleveringen — nog ${needSubs} zonder sub`
: null,
},
});
return prisma.downloadJob.findUniqueOrThrow({ where: { id: jobId } });
}
/** Re-download / re-pick and write sidecar next to already-imported video. */ /** Re-download / re-pick and write sidecar next to already-imported video. */
async retrySubtitle( async retrySubtitle(
jobId: string, jobId: string,

View file

@ -1991,6 +1991,97 @@ export class MetadataService {
return id ?? null; return id ?? null;
} }
/** Resolve IMDb id for a TMDB series (tt…). */
async resolveSeriesImdbId(tmdbId: number): Promise<string | null> {
const id = await this.fetchTmdbSeriesImdbId(tmdbId);
return id ?? null;
}
async getTvSeriesInfo(tmdbId: number): Promise<{
tmdbId: number;
title: string;
year: number | null;
overview: string | null;
posterUrl: string | null;
imdbId: string | null;
numberOfSeasons: number;
} | null> {
if (!this.tmdbApiKey || !Number.isFinite(tmdbId)) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
language: TMDB_LANG,
});
const res = await fetch(`https://api.themoviedb.org/3/tv/${tmdbId}?${params}`);
if (!res.ok) return null;
const item = (await res.json()) as {
id: number;
name?: string;
overview?: string;
first_air_date?: string;
poster_path?: string;
number_of_seasons?: number;
};
const imdbId = (await this.fetchTmdbSeriesImdbId(tmdbId)) ?? null;
return {
tmdbId: item.id,
title: item.name || "Onbekend",
year: item.first_air_date ? parseInt(item.first_air_date.slice(0, 4), 10) : null,
overview: item.overview || null,
posterUrl: item.poster_path ? `${TMDB_IMAGE_BASE}${item.poster_path}` : null,
imdbId,
numberOfSeasons: Math.max(0, item.number_of_seasons ?? 0),
};
}
async getTvSeason(
tmdbId: number,
seasonNumber: number
): Promise<{
seasonNumber: number;
name: string | null;
episodes: Array<{
episodeNumber: number;
title: string;
overview: string | null;
airDate: string | null;
stillUrl: string | null;
}>;
} | null> {
if (!this.tmdbApiKey || !Number.isFinite(tmdbId) || seasonNumber < 0) return null;
const params = new URLSearchParams({
api_key: this.tmdbApiKey,
language: TMDB_LANG,
});
const res = await fetch(
`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNumber}?${params}`
);
if (!res.ok) return null;
const data = (await res.json()) as {
season_number?: number;
name?: string;
episodes?: Array<{
episode_number?: number;
name?: string;
overview?: string;
air_date?: string;
still_path?: string;
}>;
};
return {
seasonNumber: data.season_number ?? seasonNumber,
name: data.name || null,
episodes: (data.episodes ?? [])
.filter((e) => (e.episode_number ?? 0) > 0)
.map((e) => ({
episodeNumber: e.episode_number!,
title: e.name || `Aflevering ${e.episode_number}`,
overview: e.overview || null,
airDate: e.air_date || null,
stillUrl: e.still_path ? `${TMDB_IMAGE_BASE}${e.still_path}` : null,
})),
};
}
private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> { private async fetchTmdbMovieImdbId(tmdbId: number): Promise<string | undefined> {
if (!this.tmdbApiKey) return undefined; if (!this.tmdbApiKey) return undefined;
const params = new URLSearchParams({ api_key: this.tmdbApiKey }); const params = new URLSearchParams({ api_key: this.tmdbApiKey });

View file

@ -95,24 +95,43 @@ export class OpenSubtitlesClient {
return data.token; return data.token;
} }
/** Search - login optional; API-key-only still works. Only IMDb (no free-text query). */ /** Search - login optional. Supports movie or episode via type/season/episode. */
async search(opts: { async search(opts: {
imdbId?: string; imdbId?: string;
languages?: string; languages?: string;
/** Ignored — free-text query mixes wrong movies. */ /** Ignored for movies — free-text mixes wrong titles. Used as fallback only. */
query?: string; query?: string;
type?: "movie" | "episode" | "all";
seasonNumber?: number;
episodeNumber?: number;
parentTmdbId?: number;
}): Promise<SubtitleHit[]> { }): Promise<SubtitleHit[]> {
const languages = (opts.languages || "nl").trim() || "nl"; const languages = (opts.languages || "nl").trim() || "nl";
const imdb = opts.imdbId?.trim().replace(/^tt/i, "").replace(/^0+/, "") || ""; const imdb = opts.imdbId?.trim().replace(/^tt/i, "").replace(/^0+/, "") || "";
if (!imdb || !/^\d+$/.test(imdb)) return []; const type = opts.type || "movie";
const hasEpisode =
type === "episode" &&
opts.seasonNumber != null &&
opts.episodeNumber != null &&
opts.seasonNumber >= 0 &&
opts.episodeNumber > 0;
if (!imdb && !hasEpisode && !opts.parentTmdbId) return [];
const params = new URLSearchParams({ const params = new URLSearchParams({
languages, languages,
type: "movie", type: hasEpisode ? "episode" : type === "all" ? "all" : "movie",
order_by: "download_count", order_by: "download_count",
order_direction: "desc", order_direction: "desc",
imdb_id: imdb,
}); });
if (imdb && /^\d+$/.test(imdb)) params.set("imdb_id", imdb);
if (hasEpisode) {
params.set("season_number", String(opts.seasonNumber));
params.set("episode_number", String(opts.episodeNumber));
}
if (opts.parentTmdbId && Number.isFinite(opts.parentTmdbId)) {
params.set("parent_tmdb_id", String(opts.parentTmdbId));
}
const token = await this.ensureLogin().catch(() => null); const token = await this.ensureLogin().catch(() => null);
const headers = this.headers( const headers = this.headers(
@ -163,7 +182,8 @@ export class OpenSubtitlesClient {
const featImdb = a?.feature_details?.imdb_id const featImdb = a?.feature_details?.imdb_id
? String(a.feature_details.imdb_id).replace(/^tt/i, "").replace(/^0+/, "") ? String(a.feature_details.imdb_id).replace(/^tt/i, "").replace(/^0+/, "")
: null; : null;
if (featImdb && featImdb !== imdb) continue; // Movie search: keep IMDb strict. Episode search: parent/series IMDb ≠ episode IMDb.
if (!hasEpisode && featImdb && imdb && featImdb !== imdb) continue;
out.push({ out.push({
fileId: file.file_id, fileId: file.file_id,
language: (a?.language || languages).toLowerCase(), language: (a?.language || languages).toLowerCase(),

View file

@ -24,7 +24,7 @@ import (
"github.com/sthmedia/media-node/internal/streaming" "github.com/sthmedia/media-node/internal/streaming"
) )
var version = "1.3.3" var version = "1.3.4"
func main() { func main() {
if len(os.Args) < 2 { if len(os.Args) < 2 {

View file

@ -410,6 +410,12 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
MovieYear *int `json:"movieYear"` MovieYear *int `json:"movieYear"`
ReleaseTags string `json:"releaseTags"` ReleaseTags string `json:"releaseTags"`
QualityHint string `json:"qualityHint"` QualityHint string `json:"qualityHint"`
Kind string `json:"kind"`
SeasonNumber int `json:"seasonNumber"`
Episodes []struct {
EpisodeNumber int `json:"episodeNumber"`
EpisodeTitle string `json:"episodeTitle"`
} `json:"episodes"`
Subtitle *struct { Subtitle *struct {
Language string `json:"language"` Language string `json:"language"`
Extension string `json:"extension"` Extension string `json:"extension"`
@ -421,14 +427,7 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
return return
} }
opts := importmedia.Options{ var sub *importmedia.SubtitleFile
SourcePath: p.SourcePath,
DestDir: p.DestDir,
Title: p.MovieTitle,
Year: p.MovieYear,
ReleaseTags: p.ReleaseTags,
QualityHint: p.QualityHint,
}
if p.Subtitle != nil && p.Subtitle.ContentBase64 != "" { if p.Subtitle != nil && p.Subtitle.ContentBase64 != "" {
raw, err := decodeBase64(p.Subtitle.ContentBase64) raw, err := decodeBase64(p.Subtitle.ContentBase64)
if err != nil { if err != nil {
@ -440,23 +439,40 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
_ = c.write(ack) _ = c.write(ack)
return return
} }
opts.Subtitle = &importmedia.SubtitleFile{ sub = &importmedia.SubtitleFile{
Language: p.Subtitle.Language, Language: p.Subtitle.Language,
Ext: p.Subtitle.Extension, Ext: p.Subtitle.Extension,
Content: raw, Content: raw,
} }
} }
if strings.EqualFold(p.Kind, "series") {
c.handleImportSeries(p.JobID, p.SourcePath, p.DestDir, p.MovieTitle, p.MovieYear, p.ReleaseTags, p.QualityHint, p.SeasonNumber, p.Episodes, sub)
return
}
opts := importmedia.Options{
SourcePath: p.SourcePath,
DestDir: p.DestDir,
Title: p.MovieTitle,
Year: p.MovieYear,
ReleaseTags: p.ReleaseTags,
QualityHint: p.QualityHint,
Subtitle: sub,
}
result, err := importmedia.ImportMovie(opts) result, err := importmedia.ImportMovie(opts)
ok := err == nil ok := err == nil
errMsg := "" errMsg := ""
imported := "" imported := ""
subPath := ""
if err != nil { if err != nil {
errMsg = err.Error() errMsg = err.Error()
log.Printf("IMPORT_MEDIA failed job=%s: %v", p.JobID, err) log.Printf("IMPORT_MEDIA failed job=%s: %v", p.JobID, err)
} else { } else {
imported = result.ImportedPath imported = result.ImportedPath
log.Printf("IMPORT_MEDIA ok job=%s path=%s sub=%s", p.JobID, imported, result.SubtitlePath) subPath = result.SubtitlePath
log.Printf("IMPORT_MEDIA ok job=%s path=%s sub=%s", p.JobID, imported, subPath)
c.scanner.IndexPath(imported) c.scanner.IndexPath(imported)
} }
@ -464,18 +480,75 @@ func (c *Client) handleImportMedia(payload json.RawMessage) {
"jobId": p.JobID, "jobId": p.JobID,
"ok": ok, "ok": ok,
"importedPath": imported, "importedPath": imported,
"subtitlePath": "", "subtitlePath": subPath,
"error": errMsg, "error": errMsg,
}) })
if ok && result != nil && result.SubtitlePath != "" { if werr := c.write(ack); werr != nil {
ack = controlMessage("IMPORT_MEDIA_ACK", map[string]interface{}{ log.Printf("failed to send IMPORT_MEDIA_ACK: %v", werr)
"jobId": p.JobID, }
"ok": true, }
"importedPath": imported,
"subtitlePath": result.SubtitlePath, func (c *Client) handleImportSeries(
"error": "", jobID, sourcePath, showDir, title string,
year *int,
releaseTags, qualityHint string,
seasonNumber int,
episodes []struct {
EpisodeNumber int `json:"episodeNumber"`
EpisodeTitle string `json:"episodeTitle"`
},
sub *importmedia.SubtitleFile,
) {
specs := make([]importmedia.SeriesEpisodeSpec, 0, len(episodes))
for _, ep := range episodes {
specs = append(specs, importmedia.SeriesEpisodeSpec{
EpisodeNumber: ep.EpisodeNumber,
EpisodeTitle: ep.EpisodeTitle,
}) })
} }
result, err := importmedia.ImportSeries(importmedia.SeriesOptions{
SourcePath: sourcePath,
ShowDir: showDir,
ShowTitle: title,
Year: year,
SeasonNumber: seasonNumber,
Episodes: specs,
ReleaseTags: releaseTags,
QualityHint: qualityHint,
Subtitle: sub,
})
ok := err == nil
errMsg := ""
imported := ""
subPath := ""
var epOut []map[string]interface{}
if err != nil {
errMsg = err.Error()
log.Printf("IMPORT_MEDIA series failed job=%s: %v", jobID, err)
} else {
imported = result.ImportedPath
subPath = result.SubtitlePath
log.Printf("IMPORT_MEDIA series ok job=%s season=%s eps=%d", jobID, imported, len(result.Episodes))
c.scanner.IndexPath(imported)
for _, ep := range result.Episodes {
epOut = append(epOut, map[string]interface{}{
"episodeNumber": ep.EpisodeNumber,
"importedPath": ep.ImportedPath,
"subtitlePath": ep.SubtitlePath,
})
if ep.ImportedPath != "" {
c.scanner.IndexPath(ep.ImportedPath)
}
}
}
ack := controlMessage("IMPORT_MEDIA_ACK", map[string]interface{}{
"jobId": jobID,
"ok": ok,
"importedPath": imported,
"subtitlePath": subPath,
"episodes": epOut,
"error": errMsg,
})
if werr := c.write(ack); werr != nil { if werr := c.write(ack); werr != nil {
log.Printf("failed to send IMPORT_MEDIA_ACK: %v", werr) log.Printf("failed to send IMPORT_MEDIA_ACK: %v", werr)
} }

View file

@ -0,0 +1,272 @@
package importmedia
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
var (
seriesSxxExx = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])S(\d{1,2})E(\d{1,3})(?:[^a-z0-9]|$)`)
seriesNxNN = regexp.MustCompile(`(?i)(?:^|[^a-z0-9])(\d{1,2})x(\d{1,3})(?:[^a-z0-9]|$)`)
)
type SeriesEpisodeSpec struct {
EpisodeNumber int
EpisodeTitle string
}
type SeriesEpisodeResult struct {
EpisodeNumber int
ImportedPath string
SubtitlePath string
}
type SeriesResult struct {
// Season directory path
ImportedPath string
Episodes []SeriesEpisodeResult
SubtitlePath string // single-episode subtitle, if any
}
type SeriesOptions struct {
SourcePath string
ShowDir string // …/Show Name (Year)
ShowTitle string
Year *int
SeasonNumber int
Episodes []SeriesEpisodeSpec
ReleaseTags string
QualityHint string
Subtitle *SubtitleFile // only applied when exactly one episode imported
}
// ImportSeries moves episode videos into Show/Season XX/ with scanner-friendly names.
func ImportSeries(opts SeriesOptions) (*SeriesResult, error) {
sourcePath := filepath.Clean(strings.TrimSpace(opts.SourcePath))
showDir := filepath.Clean(strings.TrimSpace(opts.ShowDir))
if sourcePath == "" || showDir == "" {
return nil, fmt.Errorf("source/show pad ontbreekt")
}
if opts.SeasonNumber <= 0 {
return nil, fmt.Errorf("ongeldig seizoen")
}
if len(opts.Episodes) == 0 {
return nil, fmt.Errorf("geen afleveringen opgegeven")
}
wanted := map[int]SeriesEpisodeSpec{}
for _, ep := range opts.Episodes {
if ep.EpisodeNumber > 0 {
wanted[ep.EpisodeNumber] = ep
}
}
if len(wanted) == 0 {
return nil, fmt.Errorf("geen geldige afleveringnummers")
}
videos, err := listVideos(sourcePath)
if err != nil {
return nil, err
}
if len(videos) == 0 {
return nil, fmt.Errorf("geen videobestanden in download")
}
seasonDir := filepath.Join(showDir, fmt.Sprintf("Season %02d", opts.SeasonNumber))
if err := os.MkdirAll(seasonDir, 0o755); err != nil {
return nil, fmt.Errorf("seizoensmap aanmaken: %w", err)
}
// Map episode number → source video path
matched := map[int]string{}
unparsed := []string{}
for _, video := range videos {
base := filepath.Base(video)
epNum := parseEpisodeNumber(base)
if epNum <= 0 {
unparsed = append(unparsed, video)
continue
}
if _, ok := wanted[epNum]; !ok {
continue
}
// Prefer larger file if duplicate episode numbers
if prev, ok := matched[epNum]; ok {
if fileSize(video) <= fileSize(prev) {
continue
}
}
matched[epNum] = video
}
// Single-episode job with one video and no SxxExx in name → accept that file
if len(wanted) == 1 && len(matched) == 0 && len(videos) == 1 {
for num := range wanted {
matched[num] = videos[0]
}
} else if len(wanted) == 1 && len(matched) == 0 && len(unparsed) == 1 && len(videos) == 1 {
for num := range wanted {
matched[num] = unparsed[0]
}
}
if len(matched) == 0 {
return nil, fmt.Errorf("geen afleveringen herkend in download (verwacht S%02dExx in bestandsnaam)", opts.SeasonNumber)
}
showBase := sanitizeName(opts.ShowTitle)
tags := strings.TrimSpace(opts.ReleaseTags)
if tags == "" {
tags = strings.TrimSpace(opts.QualityHint)
}
tags = sanitizeName(tags)
results := make([]SeriesEpisodeResult, 0, len(matched))
moved := make([]string, 0, len(matched))
nums := make([]int, 0, len(matched))
for n := range matched {
nums = append(nums, n)
}
sort.Ints(nums)
for _, epNum := range nums {
src := matched[epNum]
spec := wanted[epNum]
ext := strings.ToLower(filepath.Ext(src))
epCode := fmt.Sprintf("S%02dE%02d", opts.SeasonNumber, epNum)
name := showBase + " - " + epCode
if t := sanitizeName(spec.EpisodeTitle); t != "" {
name += " - " + t
}
if tags != "" {
name += " - " + tags
}
destPath := filepath.Join(seasonDir, name+ext)
if err := moveFile(src, destPath); err != nil {
return nil, fmt.Errorf("verplaatsen E%02d: %w", epNum, err)
}
moved = append(moved, src)
results = append(results, SeriesEpisodeResult{
EpisodeNumber: epNum,
ImportedPath: destPath,
})
}
out := &SeriesResult{
ImportedPath: seasonDir,
Episodes: results,
}
// Single episode + subtitle sidecar
if opts.Subtitle != nil && len(opts.Subtitle.Content) > 0 && len(results) == 1 {
subPath, err := WriteSidecar(results[0].ImportedPath, opts.Subtitle)
if err != nil {
return nil, err
}
results[0].SubtitlePath = subPath
out.SubtitlePath = subPath
out.Episodes = results
}
cleanupMovedSources(sourcePath, moved)
return out, nil
}
func parseEpisodeNumber(name string) int {
if m := seriesSxxExx.FindStringSubmatch(name); len(m) >= 3 {
return atoi(m[2])
}
if m := seriesNxNN.FindStringSubmatch(name); len(m) >= 3 {
return atoi(m[2])
}
return 0
}
func listVideos(sourcePath string) ([]string, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("bron niet gevonden: %w", err)
}
var out []string
consider := func(path string) {
if !videoExt[strings.ToLower(filepath.Ext(path))] {
return
}
if sampleRe.MatchString(filepath.Base(path)) {
return
}
out = append(out, path)
}
if !info.IsDir() {
consider(sourcePath)
return out, nil
}
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil || fi == nil || fi.IsDir() {
return nil
}
consider(path)
return nil
})
return out, err
}
func fileSize(path string) int64 {
fi, err := os.Stat(path)
if err != nil {
return 0
}
return fi.Size()
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c < '0' || c > '9' {
break
}
n = n*10 + int(c-'0')
}
return n
}
// Remove moved sources and empty dirs; leave unmatched videos alone if still present.
func cleanupMovedSources(sourcePath string, moved []string) {
movedSet := map[string]bool{}
for _, m := range moved {
movedSet[m] = true
}
info, err := os.Stat(sourcePath)
if err != nil {
return
}
if !info.IsDir() {
if movedSet[sourcePath] {
_ = os.Remove(sourcePath)
}
_ = removeEmptyDirs(filepath.Dir(sourcePath))
return
}
// Remove leftover nfo/txt/samples next to moved content, but keep unmatched videos
_ = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, walkErr error) error {
if walkErr != nil || fi == nil || fi.IsDir() {
return nil
}
if movedSet[path] {
return nil // already moved away
}
base := filepath.Base(path)
ext := strings.ToLower(filepath.Ext(base))
if sampleRe.MatchString(base) || ext == ".nfo" || ext == ".txt" || ext == ".jpg" || ext == ".png" {
_ = os.Remove(path)
}
return nil
})
_ = removeEmptyDirs(sourcePath)
}

View file

@ -128,12 +128,15 @@ export interface UpgradeAckPayload {
error?: string; error?: string;
} }
/** Master → node: move finished Download Station output into 4K library folder. */ /** Master → node: move finished Download Station output into library folder. */
export interface ImportMediaPayload { export interface ImportMediaPayload {
jobId: string; jobId: string;
/** Absolute host path of the finished download folder/file */ /** Absolute host path of the finished download folder/file */
sourcePath: string; sourcePath: string;
/** Absolute host path for destination movie folder (Title (Year)) */ /**
* Movie: destination movie folder (Title (Year)).
* Series: destination show folder (Show (Year)); Season XX is created underneath.
*/
destDir: string; destDir: string;
movieTitle: string; movieTitle: string;
movieYear?: number | null; movieYear?: number | null;
@ -141,7 +144,15 @@ export interface ImportMediaPayload {
releaseTags?: string; releaseTags?: string;
/** Fallback if releaseTags empty */ /** Fallback if releaseTags empty */
qualityHint?: string; qualityHint?: string;
/** Optional sidecar subtitle written next to the video */ /** Default movie when omitted (backwards compatible). */
kind?: "movie" | "series";
seasonNumber?: number;
/** Expected episodes (required for series). */
episodes?: Array<{
episodeNumber: number;
episodeTitle?: string;
}>;
/** Optional sidecar subtitle written next to the video (single-episode series or movie). */
subtitle?: { subtitle?: {
language: string; language: string;
extension?: string; extension?: string;
@ -149,12 +160,20 @@ export interface ImportMediaPayload {
}; };
} }
export interface ImportMediaEpisodeResult {
episodeNumber: number;
importedPath: string;
subtitlePath?: string;
}
export interface ImportMediaAckPayload { export interface ImportMediaAckPayload {
jobId: string; jobId: string;
ok: boolean; ok: boolean;
importedPath?: string; importedPath?: string;
/** Absolute path of written sidecar, if any */ /** Absolute path of written sidecar, if any (movie / single episode) */
subtitlePath?: string; subtitlePath?: string;
/** Per-episode results for series imports */
episodes?: ImportMediaEpisodeResult[];
error?: string; error?: string;
} }