Add full series download flow with episode and season import.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e841997399
commit
bd21b01956
15 changed files with 1316 additions and 106 deletions
|
|
@ -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) => (
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
44
apps/master-api/src/downloads/series-track.ts
Normal file
44
apps/master-api/src/downloads/series-track.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
272
node/media-node/internal/importmedia/series.go
Normal file
272
node/media-node/internal/importmedia/series.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue