diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index bab48aa..7905b6f 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -54,10 +54,13 @@ interface JobRow { movieTitle: string; movieYear: number | null; posterUrl: string | null; + tmdbId?: number | null; + imdbId?: string | null; error: string | null; hasSubtitle?: boolean; subtitlePath?: string | null; subtitleError?: string | null; + subtitleSource?: string | null; importedPath?: string | null; } @@ -129,6 +132,8 @@ export default function DownloadsPage() { const [selectedSubId, setSelectedSubId] = useState(null); const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null); const [subsMsg, setSubsMsg] = useState(null); + const [subPickJob, setSubPickJob] = useState(null); + const [subPickBusy, setSubPickBusy] = useState(false); const [jobs, setJobs] = useState([]); @@ -324,16 +329,83 @@ export default function DownloadsPage() { loadJobs(); } - async function retrySubtitle(id: string) { - const res = await fetch(`/api/v1/admin/downloads/jobs/${id}/retry-subtitle`, { - method: "POST", - credentials: "include", - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - alert(data.error?.message ?? "Ondertitel schrijven mislukt"); + async function openSubtitlePicker(job: JobRow) { + setSubPickJob(job); + setSubs([]); + setSelectedSubId(null); + setSelectedSubSource(null); + setSubsMsg(null); + setSubsBusy(true); + setSubsConfigured(true); + try { + const params = new URLSearchParams({ + languages: "nl", + releaseHint: job.sourceTitle || job.movieTitle, + movieTitle: job.movieTitle, + }); + if (job.movieYear) params.set("movieYear", String(job.movieYear)); + if (job.imdbId) params.set("imdbId", job.imdbId); + else if (job.tmdbId) params.set("tmdbId", String(job.tmdbId)); + else { + setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken"); + return; + } + const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, { + credentials: "include", + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt"); + return; + } + setSubsConfigured(data.configured !== false); + const results: SubtitleHit[] = data.results ?? []; + // Toon .org eerst — werkt zonder .com-login + results.sort((a, b) => { + if (a.source === "org" && b.source !== "org") return -1; + if (b.source === "org" && a.source !== "org") return 1; + return (b.downloadCount || 0) - (a.downloadCount || 0); + }); + setSubs(results); + const preferred = results.find((r) => r.source === "org") || results[0]; + if (preferred) { + setSelectedSubId(preferred.fileId); + setSelectedSubSource(preferred.source === "org" ? "org" : "com"); + } else { + setSubsMsg("Geen NL-ondertitels gevonden"); + } + } finally { + setSubsBusy(false); + } + } + + async function confirmSubtitlePick() { + if (!subPickJob || selectedSubId == null) { + alert("Kies een ondertitel (bij voorkeur OS.org)"); + return; + } + setSubPickBusy(true); + try { + const res = await fetch(`/api/v1/admin/downloads/jobs/${subPickJob.id}/retry-subtitle`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + subtitleFileId: selectedSubId, + subtitleSource: selectedSubSource || "org", + subtitleLang: "nl", + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + alert(data.error?.message ?? "Ondertitel schrijven mislukt"); + return; + } + setSubPickJob(null); + loadJobs(); + } finally { + setSubPickBusy(false); } - loadJobs(); } return ( @@ -487,9 +559,9 @@ export default function DownloadsPage() { Opnieuw importeren )} - {j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && ( - )} @@ -536,9 +608,9 @@ export default function DownloadsPage() { Retry )} - {j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && ( - )} @@ -676,6 +748,73 @@ export default function DownloadsPage() { )} + + {subPickJob && ( +
setSubPickJob(null)}> +
e.stopPropagation()}> +
Ondertitel opnieuw
+

+ {subPickJob.movieTitle} + {subPickJob.movieYear ? ` (${subPickJob.movieYear})` : ""} +

+

+ Kies bij voorkeur OS.org — OpenSubtitles.com login faalt vaak (403 / + User-Agent). +

+ {subsBusy &&

OpenSubtitles zoeken…

} + {subsMsg &&

{subsMsg}

} + {!subsBusy && ( +
+ {subs.map((s) => ( + + ))} +
+ )} +
+ + +
+
+
+ )} ); } diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 275ba9e..c2c3583 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -89,7 +89,7 @@ export default function SettingsPage() { const [osApiKey, setOsApiKey] = useState(""); const [osUsername, setOsUsername] = useState(""); const [osPassword, setOsPassword] = useState(""); - const [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3"); + const [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3.2"); const [osEnabled, setOsEnabled] = useState(true); const [osHasKey, setOsHasKey] = useState(false); const [osHasPassword, setOsHasPassword] = useState(false); @@ -133,7 +133,7 @@ export default function SettingsPage() { setOsConfigured(!!s.configured); setOsEnabled(s.enabled !== false); setOsUsername(s.username ?? ""); - setOsUserAgent(s.userAgent || "MediaCluster v1.3"); + setOsUserAgent(s.userAgent || "MediaCluster v1.3.2"); setOsHasKey(!!s.hasApiKey); setOsHasPassword(!!s.hasPassword); setOsWarn(s.warning || null); @@ -231,7 +231,7 @@ export default function SettingsPage() { apiKey: osApiKey || undefined, username: osUsername, password: osPassword || undefined, - userAgent: osUserAgent.trim() || "MediaCluster v1.3", + userAgent: osUserAgent.trim() || "MediaCluster v1.3.2", enabled: osEnabled, }), }); @@ -526,7 +526,7 @@ export default function SettingsPage() { setOsUserAgent(e.target.value)} - placeholder="MediaCluster v1.3" + placeholder="MediaCluster v1.3.2" autoComplete="off" /> diff --git a/apps/master-api/src/downloads/routes.ts b/apps/master-api/src/downloads/routes.ts index a3a8910..7f23854 100644 --- a/apps/master-api/src/downloads/routes.ts +++ b/apps/master-api/src/downloads/routes.ts @@ -154,7 +154,16 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do app.post("/api/v1/admin/downloads/jobs/:id/retry-subtitle", { preHandler: requireAdmin }, async (request) => { const { id } = request.params as { id: string }; - const job = await downloads.retrySubtitle(id); + const body = (request.body || {}) as { + subtitleFileId?: number | null; + subtitleSource?: "com" | "org" | null; + subtitleLang?: string | null; + }; + const job = await downloads.retrySubtitle(id, { + subtitleFileId: body.subtitleFileId, + subtitleSource: body.subtitleSource, + subtitleLang: body.subtitleLang, + }); return { job }; }); diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts index f9b6845..6e2fb54 100644 --- a/apps/master-api/src/downloads/service.ts +++ b/apps/master-api/src/downloads/service.ts @@ -758,33 +758,82 @@ export class DownloadService { } } - /** Re-download (if needed) and write sidecar next to already-imported video. */ - async retrySubtitle(jobId: string) { + /** Re-download / re-pick and write sidecar next to already-imported video. */ + async retrySubtitle( + jobId: string, + opts?: { + subtitleFileId?: number | null; + 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.importedPath) { throw new AppError("INVALID_REQUEST", "Nog geen geïmporteerd pad — importeer eerst de film", 400); } - if (!job.subtitleFileId && !job.subtitleContent) { - throw new AppError("INVALID_REQUEST", "Deze job heeft geen ondertitel gekozen", 400); - } - let content = job.subtitleContent; + const pickId = + opts?.subtitleFileId != null && Number.isFinite(opts.subtitleFileId) + ? Math.floor(opts.subtitleFileId) + : null; + const pickSource: SubtitleSource | null = pickId + ? opts?.subtitleSource === "org" + ? "org" + : "com" + : null; + + let content = pickId ? null : job.subtitleContent; let fileName: string | null = null; - if (!content?.trim() && job.subtitleFileId) { - const source = job.subtitleSource === "org" ? "org" : "com"; - const file = await this.downloadSubtitleFile(job.subtitleFileId, source); + let source: SubtitleSource | null = pickSource || (job.subtitleSource === "org" ? "org" : job.subtitleFileId ? "com" : null); + let fileId = pickId || job.subtitleFileId; + const lang = (opts?.subtitleLang || job.subtitleLang || "nl").trim().toLowerCase() || "nl"; + + // Nieuwe keuze of opnieuw downloaden + if (pickId && pickSource) { + const file = await this.downloadSubtitleFile(pickId, pickSource); content = file.content.toString("utf8"); fileName = file.fileName; - await prisma.downloadJob.update({ - where: { id: jobId }, - data: { subtitleContent: content, subtitleError: null }, - }); + source = pickSource; + fileId = pickId; + } else if (!content?.trim() && fileId && source) { + try { + const file = await this.downloadSubtitleFile(fileId, source); + content = file.content.toString("utf8"); + fileName = file.fileName; + } catch (err) { + const message = err instanceof Error ? err.message : "Download mislukt"; + // Als .com faalt: forceer opnieuw kiezen via UI (OS.org) + if (source === "com") { + throw new AppError( + "OPENSUBTITLES", + `${message} — kies opnieuw een OS.org-ondertitel bij deze job.`, + 400 + ); + } + throw err instanceof AppError ? err : new AppError("OPENSUBTITLES", message, 400); + } } + if (!content?.trim()) { - throw new AppError("OPENSUBTITLES", "Geen ondertitelinhoud beschikbaar", 400); + throw new AppError( + "OPENSUBTITLES", + "Geen ondertitelinhoud — kies een OS.org-hit bij deze job", + 400 + ); } + await prisma.downloadJob.update({ + where: { id: jobId }, + data: { + subtitleFileId: fileId, + subtitleSource: source, + subtitleLang: lang, + subtitleContent: content, + subtitleError: null, + }, + }); + const ext = fileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt"; const ack = await nodeConnectionManager.writeSubtitleAcked( job.nodeId, @@ -792,7 +841,7 @@ export class DownloadService { jobId: job.id, videoPath: job.importedPath, subtitle: { - language: job.subtitleLang || "nl", + language: lang, extension: ext, contentBase64: Buffer.from(content, "utf8").toString("base64"), }, @@ -818,6 +867,43 @@ export class DownloadService { }, }); + try { + let movieId: string | null = null; + if (job.tmdbId) { + const movie = await prisma.movie.findUnique({ where: { tmdbId: job.tmdbId } }); + movieId = movie?.id ?? null; + } + const existing = await prisma.librarySubtitle.findFirst({ + where: { downloadJobId: job.id }, + }); + if (existing) { + await prisma.librarySubtitle.update({ + where: { id: existing.id }, + data: { + content, + fileName, + language: lang, + format: ext.replace(/^\./, ""), + }, + }); + } else { + await prisma.librarySubtitle.create({ + data: { + movieId, + tmdbId: job.tmdbId, + imdbId: job.imdbId, + language: lang, + format: ext.replace(/^\./, ""), + content, + fileName, + downloadJobId: job.id, + }, + }); + } + } catch (err) { + console.warn(`[downloads] library subtitle retry job=${jobId}:`, err); + } + return prisma.downloadJob.findUniqueOrThrow({ where: { id: jobId }, include: { node: { select: { id: true, name: true } } }, diff --git a/apps/master-api/src/opensubtitles/client.ts b/apps/master-api/src/opensubtitles/client.ts index 4152a94..d6d61f7 100644 --- a/apps/master-api/src/opensubtitles/client.ts +++ b/apps/master-api/src/opensubtitles/client.ts @@ -16,7 +16,7 @@ export type SubtitleHit = { type OsLogin = { token: string; baseUrl: string; expiresAt: number }; -const DEFAULT_UA = "MediaCluster v1.3"; +const DEFAULT_UA = "MediaCluster v1.3.2"; /** * OpenSubtitles.com REST API (v1). @@ -38,7 +38,9 @@ export class OpenSubtitlesClient { private headers(extra?: Record): Record { return { "Api-Key": this.apiKey, + // Docs: descriptive app name + parseable version, e.g. "MyApp v1.2.3" "User-Agent": this.userAgent, + "X-User-Agent": this.userAgent, Accept: "application/json", ...extra, }; diff --git a/apps/master-api/src/settings/opensubtitles.ts b/apps/master-api/src/settings/opensubtitles.ts index dd39f6c..263e1bf 100644 --- a/apps/master-api/src/settings/opensubtitles.ts +++ b/apps/master-api/src/settings/opensubtitles.ts @@ -4,7 +4,7 @@ import { AppError } from "../security/errors"; import { OpenSubtitlesClient } from "../opensubtitles/client"; const OS_ID = "opensubtitles"; -const DEFAULT_UA = "MediaCluster v1.3"; +const DEFAULT_UA = "MediaCluster v1.3.2"; export async function isOpenSubtitlesEnabled(): Promise { const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });