Allow re-picking OS.org subtitles on imported jobs; fix OpenSubtitles.com User-Agent format.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 21:45:48 +02:00
parent afcc2e759f
commit 942da3a58c
6 changed files with 273 additions and 37 deletions

View file

@ -54,10 +54,13 @@ interface JobRow {
movieTitle: string; movieTitle: string;
movieYear: number | null; movieYear: number | null;
posterUrl: string | null; posterUrl: string | null;
tmdbId?: number | null;
imdbId?: string | null;
error: string | null; error: string | null;
hasSubtitle?: boolean; hasSubtitle?: boolean;
subtitlePath?: string | null; subtitlePath?: string | null;
subtitleError?: string | null; subtitleError?: string | null;
subtitleSource?: string | null;
importedPath?: string | null; importedPath?: string | null;
} }
@ -129,6 +132,8 @@ export default function DownloadsPage() {
const [selectedSubId, setSelectedSubId] = useState<number | null>(null); const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
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 [subPickBusy, setSubPickBusy] = useState(false);
const [jobs, setJobs] = useState<JobRow[]>([]); const [jobs, setJobs] = useState<JobRow[]>([]);
@ -324,16 +329,83 @@ export default function DownloadsPage() {
loadJobs(); loadJobs();
} }
async function retrySubtitle(id: string) { async function openSubtitlePicker(job: JobRow) {
const res = await fetch(`/api/v1/admin/downloads/jobs/${id}/retry-subtitle`, { setSubPickJob(job);
method: "POST", setSubs([]);
credentials: "include", setSelectedSubId(null);
}); setSelectedSubSource(null);
const data = await res.json().catch(() => ({})); setSubsMsg(null);
if (!res.ok) { setSubsBusy(true);
alert(data.error?.message ?? "Ondertitel schrijven mislukt"); 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 ( return (
@ -487,9 +559,9 @@ export default function DownloadsPage() {
Opnieuw importeren Opnieuw importeren
</button> </button>
)} )}
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && ( {j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void retrySubtitle(j.id)}> <button type="button" onClick={() => void openSubtitlePicker(j)}>
Ondertitel opnieuw Ondertitel kiezen
</button> </button>
)} )}
</div> </div>
@ -536,9 +608,9 @@ export default function DownloadsPage() {
Retry Retry
</button> </button>
)} )}
{j.status === "IMPORTED" && j.hasSubtitle && !j.subtitlePath && ( {j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
<button type="button" onClick={() => void retrySubtitle(j.id)}> <button type="button" onClick={() => void openSubtitlePicker(j)}>
Sub opnieuw Sub kiezen
</button> </button>
)} )}
</td> </td>
@ -676,6 +748,73 @@ export default function DownloadsPage() {
</div> </div>
</div> </div>
)} )}
{subPickJob && (
<div className="modal-backdrop" onClick={() => setSubPickJob(null)}>
<div className="modal-panel downloads-modal" onClick={(e) => e.stopPropagation()}>
<div className="page-kicker">Ondertitel opnieuw</div>
<h2 className="downloads-modal-title">
{subPickJob.movieTitle}
{subPickJob.movieYear ? ` (${subPickJob.movieYear})` : ""}
</h2>
<p className="muted" style={{ marginTop: 0 }}>
Kies bij voorkeur <strong>OS.org</strong> — OpenSubtitles.com login faalt vaak (403 /
User-Agent).
</p>
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
{subsMsg && <p className="danger-text">{subsMsg}</p>}
{!subsBusy && (
<div className="match-list">
{subs.map((s) => (
<button
key={`${s.source || "com"}-${s.fileId}`}
type="button"
className={`match-item${
selectedSubId === s.fileId &&
(selectedSubSource || "com") === (s.source || "com")
? " selected"
: ""
}`}
onClick={() => {
setSelectedSubId(s.fileId);
setSelectedSubSource(s.source === "org" ? "org" : "com");
}}
>
<span>
<strong>
{(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
{s.hearingImpaired ? " · HI" : ""}
{" · "}
{s.source === "org" ? "OS.org" : "OS.com"}
</strong>
<small className="muted">
{s.release || s.fileName}
{s.uploader ? ` · ${s.uploader}` : ""}
</small>
</span>
</button>
))}
</div>
)}
<div className="downloads-modal-actions">
<button
type="button"
disabled={subPickBusy || selectedSubId == null}
onClick={() => void confirmSubtitlePick()}
>
{subPickBusy
? "Schrijven…"
: selectedSubSource === "org"
? "Schrijf OS.org-ondertitel"
: "Schrijf ondertitel (.com kan falen)"}
</button>
<button type="button" className="btn-logout" onClick={() => setSubPickJob(null)}>
Annuleren
</button>
</div>
</div>
</div>
)}
</> </>
); );
} }

View file

@ -89,7 +89,7 @@ export default function SettingsPage() {
const [osApiKey, setOsApiKey] = useState(""); const [osApiKey, setOsApiKey] = useState("");
const [osUsername, setOsUsername] = useState(""); const [osUsername, setOsUsername] = useState("");
const [osPassword, setOsPassword] = 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 [osEnabled, setOsEnabled] = useState(true);
const [osHasKey, setOsHasKey] = useState(false); const [osHasKey, setOsHasKey] = useState(false);
const [osHasPassword, setOsHasPassword] = useState(false); const [osHasPassword, setOsHasPassword] = useState(false);
@ -133,7 +133,7 @@ export default function SettingsPage() {
setOsConfigured(!!s.configured); setOsConfigured(!!s.configured);
setOsEnabled(s.enabled !== false); setOsEnabled(s.enabled !== false);
setOsUsername(s.username ?? ""); setOsUsername(s.username ?? "");
setOsUserAgent(s.userAgent || "MediaCluster v1.3"); setOsUserAgent(s.userAgent || "MediaCluster v1.3.2");
setOsHasKey(!!s.hasApiKey); setOsHasKey(!!s.hasApiKey);
setOsHasPassword(!!s.hasPassword); setOsHasPassword(!!s.hasPassword);
setOsWarn(s.warning || null); setOsWarn(s.warning || null);
@ -231,7 +231,7 @@ export default function SettingsPage() {
apiKey: osApiKey || undefined, apiKey: osApiKey || undefined,
username: osUsername, username: osUsername,
password: osPassword || undefined, password: osPassword || undefined,
userAgent: osUserAgent.trim() || "MediaCluster v1.3", userAgent: osUserAgent.trim() || "MediaCluster v1.3.2",
enabled: osEnabled, enabled: osEnabled,
}), }),
}); });
@ -526,7 +526,7 @@ export default function SettingsPage() {
<input <input
value={osUserAgent} value={osUserAgent}
onChange={(e) => setOsUserAgent(e.target.value)} onChange={(e) => setOsUserAgent(e.target.value)}
placeholder="MediaCluster v1.3" placeholder="MediaCluster v1.3.2"
autoComplete="off" autoComplete="off"
/> />
</label> </label>

View file

@ -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) => { app.post("/api/v1/admin/downloads/jobs/:id/retry-subtitle", { preHandler: requireAdmin }, async (request) => {
const { id } = request.params as { id: string }; 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 }; return { job };
}); });

View file

@ -758,33 +758,82 @@ export class DownloadService {
} }
} }
/** Re-download (if needed) and write sidecar next to already-imported video. */ /** Re-download / re-pick and write sidecar next to already-imported video. */
async retrySubtitle(jobId: string) { async retrySubtitle(
jobId: string,
opts?: {
subtitleFileId?: number | null;
subtitleSource?: SubtitleSource | null;
subtitleLang?: string | null;
}
) {
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } }); const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404); if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
if (!job.importedPath) { if (!job.importedPath) {
throw new AppError("INVALID_REQUEST", "Nog geen geïmporteerd pad — importeer eerst de film", 400); 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; let fileName: string | null = null;
if (!content?.trim() && job.subtitleFileId) { let source: SubtitleSource | null = pickSource || (job.subtitleSource === "org" ? "org" : job.subtitleFileId ? "com" : null);
const source = job.subtitleSource === "org" ? "org" : "com"; let fileId = pickId || job.subtitleFileId;
const file = await this.downloadSubtitleFile(job.subtitleFileId, source); 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"); content = file.content.toString("utf8");
fileName = file.fileName; fileName = file.fileName;
await prisma.downloadJob.update({ source = pickSource;
where: { id: jobId }, fileId = pickId;
data: { subtitleContent: content, subtitleError: null }, } 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()) { 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 ext = fileName?.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
const ack = await nodeConnectionManager.writeSubtitleAcked( const ack = await nodeConnectionManager.writeSubtitleAcked(
job.nodeId, job.nodeId,
@ -792,7 +841,7 @@ export class DownloadService {
jobId: job.id, jobId: job.id,
videoPath: job.importedPath, videoPath: job.importedPath,
subtitle: { subtitle: {
language: job.subtitleLang || "nl", language: lang,
extension: ext, extension: ext,
contentBase64: Buffer.from(content, "utf8").toString("base64"), 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({ return prisma.downloadJob.findUniqueOrThrow({
where: { id: jobId }, where: { id: jobId },
include: { node: { select: { id: true, name: true } } }, include: { node: { select: { id: true, name: true } } },

View file

@ -16,7 +16,7 @@ export type SubtitleHit = {
type OsLogin = { token: string; baseUrl: string; expiresAt: number }; 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). * OpenSubtitles.com REST API (v1).
@ -38,7 +38,9 @@ export class OpenSubtitlesClient {
private headers(extra?: Record<string, string>): Record<string, string> { private headers(extra?: Record<string, string>): Record<string, string> {
return { return {
"Api-Key": this.apiKey, "Api-Key": this.apiKey,
// Docs: descriptive app name + parseable version, e.g. "MyApp v1.2.3"
"User-Agent": this.userAgent, "User-Agent": this.userAgent,
"X-User-Agent": this.userAgent,
Accept: "application/json", Accept: "application/json",
...extra, ...extra,
}; };

View file

@ -4,7 +4,7 @@ import { AppError } from "../security/errors";
import { OpenSubtitlesClient } from "../opensubtitles/client"; import { OpenSubtitlesClient } from "../opensubtitles/client";
const OS_ID = "opensubtitles"; const OS_ID = "opensubtitles";
const DEFAULT_UA = "MediaCluster v1.3"; const DEFAULT_UA = "MediaCluster v1.3.2";
export async function isOpenSubtitlesEnabled(): Promise<boolean> { export async function isOpenSubtitlesEnabled(): Promise<boolean> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });