From d233878e6daa5fd878e35c9ac945f9544bce0711 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 30 Aug 2026 22:01:14 +0200 Subject: [PATCH] fix subtitle search: IMDb-only to stop wrong movies; restore OS.org hits. --- apps/admin-ui/src/app/downloads/page.tsx | 2 +- apps/master-api/src/downloads/service.ts | 27 ++-- apps/master-api/src/opensubtitles/client.ts | 32 +++-- .../src/opensubtitles/org-client.ts | 116 +++++++++++++----- 4 files changed, 114 insertions(+), 63 deletions(-) diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index 7905b6f..8f57f2b 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -265,7 +265,7 @@ export default function DownloadsPage() { setSelectedSubId(preferred.fileId); setSelectedSubSource(preferred.source === "org" ? "org" : "com"); } else { - setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)"); + setSubsMsg("Geen NL-ondertitels gevonden (alleen IMDb via OS.com + OS.org)"); } } finally { setSubsBusy(false); diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts index 6e2fb54..c56e384 100644 --- a/apps/master-api/src/downloads/service.ts +++ b/apps/master-api/src/downloads/service.ts @@ -106,16 +106,11 @@ export class DownloadService { } const languages = opts.languages?.trim() || "nl"; - const title = - opts.movieTitle?.trim() || - stripQualityTokens(opts.releaseHint || "").replace(/\b(19|20)\d{2}\b/g, "").trim() || - ""; - const year = opts.movieYear ?? null; - const titleQuery = title - ? year && year > 1900 - ? `${title} ${year}` - : title - : ""; + + // Alleen IMDb — free-text query mengt verkeerde films door elkaar + if (!imdbId) { + return { imdbId: null, results: [], configured }; + } const tasks: Array> = []; @@ -125,19 +120,11 @@ export class DownloadService { return [] as SubtitleHit[]; }); - if (comClient && imdbId) { + if (comClient) { tasks.push(safe("com/imdb", comClient.search({ imdbId, languages }))); } - if (comClient && titleQuery) { - tasks.push(safe("com/title", comClient.search({ query: titleQuery, languages }))); - } // OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org) - if (imdbId) { - tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); - } - if (titleQuery) { - tasks.push(safe("org/title", orgClient.search({ query: titleQuery, languages }))); - } + tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); if (tasks.length === 0) { return { imdbId, results: [], configured }; diff --git a/apps/master-api/src/opensubtitles/client.ts b/apps/master-api/src/opensubtitles/client.ts index d6d61f7..c40c051 100644 --- a/apps/master-api/src/opensubtitles/client.ts +++ b/apps/master-api/src/opensubtitles/client.ts @@ -12,6 +12,9 @@ export type SubtitleHit = { fps: number | null; uploader: string | null; source: SubtitleSource; + movieName?: string | null; + movieYear?: number | null; + imdbId?: string | null; }; type OsLogin = { token: string; baseUrl: string; expiresAt: number }; @@ -58,7 +61,7 @@ export class OpenSubtitlesClient { return ( `OpenSubtitles.com weigert toegang (403). Check: ` + `1) account op opensubtitles.com (niet .org), ` + - `2) User-Agent exact gelijk aan je API Consumer-naam (nu: “${this.userAgent}”), ` + + `2) User-Agent exact gelijk aan je API Consumer-naam (nu: "${this.userAgent}"), ` + `3) of kies een OS.org-ondertitel (geen .com-login nodig).` ); } @@ -92,23 +95,24 @@ export class OpenSubtitlesClient { return data.token; } - /** Search — login optional; failures are ignored so API-key-only still works. */ + /** Search - login optional; API-key-only still works. Only IMDb (no free-text query). */ async search(opts: { imdbId?: string; languages?: string; + /** Ignored — free-text query mixes wrong movies. */ query?: string; }): Promise { const languages = (opts.languages || "nl").trim() || "nl"; + const imdb = opts.imdbId?.trim().replace(/^tt/i, "").replace(/^0+/, "") || ""; + if (!imdb || !/^\d+$/.test(imdb)) return []; + const params = new URLSearchParams({ languages, type: "movie", order_by: "download_count", order_direction: "desc", + imdb_id: imdb, }); - const imdb = opts.imdbId?.trim().replace(/^tt/i, ""); - if (imdb && /^\d+$/.test(imdb)) params.set("imdb_id", imdb); - if (opts.query?.trim()) params.set("query", opts.query.trim()); - if (!params.has("imdb_id") && !params.has("query")) return []; const token = await this.ensureLogin().catch(() => null); const headers = this.headers( @@ -121,7 +125,7 @@ export class OpenSubtitlesClient { if (res.status === 403 || /cannot consume/i.test(text)) { throw new AppError( "OPENSUBTITLES", - `OpenSubtitles.com zoeken geweigerd (403). User-Agent “${this.userAgent}” moet exact je API Consumer-naam zijn, of gebruik alleen OS.org-hits.`, + `OpenSubtitles.com zoeken geweigerd (403). User-Agent "${this.userAgent}" moet exact je API Consumer-naam zijn, of gebruik alleen OS.org-hits.`, 400 ); } @@ -140,6 +144,11 @@ export class OpenSubtitlesClient { download_count?: number; hearing_impaired?: boolean; fps?: number; + feature_details?: { + imdb_id?: number | string; + title?: string; + year?: number; + }; files?: Array<{ file_id?: number; file_name?: string }>; uploader?: { name?: string }; }; @@ -151,6 +160,10 @@ export class OpenSubtitlesClient { const a = row.attributes; const file = a?.files?.[0]; if (!file?.file_id) continue; + const featImdb = a?.feature_details?.imdb_id + ? String(a.feature_details.imdb_id).replace(/^tt/i, "").replace(/^0+/, "") + : null; + if (featImdb && featImdb !== imdb) continue; out.push({ fileId: file.file_id, language: (a?.language || languages).toLowerCase(), @@ -161,6 +174,9 @@ export class OpenSubtitlesClient { fps: a?.fps ?? null, uploader: a?.uploader?.name ?? null, source: "com", + movieName: a?.feature_details?.title ?? null, + movieYear: a?.feature_details?.year ?? null, + imdbId: featImdb || imdb, }); } return out.slice(0, 40); @@ -199,7 +215,7 @@ export class OpenSubtitlesClient { if (res.status === 403 || /cannot consume/i.test(text)) { throw new AppError( "OPENSUBTITLES", - `OpenSubtitles.com download geweigerd (403). Kies een OS.org-ondertitel, of fix API Consumer User-Agent (“${this.userAgent}”).`, + `OpenSubtitles.com download geweigerd (403). Kies een OS.org-ondertitel, of fix API Consumer User-Agent ("${this.userAgent}").`, 400 ); } diff --git a/apps/master-api/src/opensubtitles/org-client.ts b/apps/master-api/src/opensubtitles/org-client.ts index 1e55637..9d402fe 100644 --- a/apps/master-api/src/opensubtitles/org-client.ts +++ b/apps/master-api/src/opensubtitles/org-client.ts @@ -14,14 +14,17 @@ type OrgRow = { SubLanguageID?: string; SubDownloadLink?: string; SubFormat?: string; + MovieName?: string | null; + MovieYear?: string | null; + IDMovieImdb?: string | null; }; /** - * Legacy OpenSubtitles.org REST search (still live). - * Download via stable dl.opensubtitles.org/file/{id} (plain .srt). + * OpenSubtitles.org legacy REST (rest.opensubtitles.org). + * Dutch language id is `dut`. IMDb without "tt". No API key. */ export class OpenSubtitlesOrgClient { - private readonly ua = "MediaCluster v1.3"; + private readonly ua = "MediaCluster v1.3.2"; async search(opts: { imdbId?: string; @@ -29,23 +32,55 @@ export class OpenSubtitlesOrgClient { languages?: string; }): Promise { const lang = toOrgLang(opts.languages || "nl"); - const parts: string[] = [`sublanguageid-${lang}`]; - const imdb = opts.imdbId?.trim().replace(/^tt/i, ""); - if (imdb && /^\d+$/.test(imdb)) { - parts.unshift(`imdbid-${imdb}`); + const imdbDigits = normalizeImdb(opts.imdbId); + + const urls: string[] = []; + if (imdbDigits) { + urls.push( + `https://rest.opensubtitles.org/search/imdbid-${imdbDigits}/sublanguageid-${lang}` + ); } else if (opts.query?.trim()) { - parts.unshift(`query-${encodeURIComponent(opts.query.trim()).replace(/%20/g, "+")}`); - } else { - return []; + const q = encodeURIComponent(opts.query.trim()).replace(/%20/g, "+"); + urls.push(`https://rest.opensubtitles.org/search/query-${q}/sublanguageid-${lang}`); + } + if (!urls.length) return []; + + let lastErr: string | null = null; + const out: SubtitleHit[] = []; + const seen = new Set(); + + for (const url of urls) { + try { + const rows = await this.fetchSearch(url); + console.info(`[opensubtitles.org] ${url} -> ${rows.length} raw`); + for (const row of rows) { + const hit = mapRow(row, lang); + if (!hit || seen.has(hit.fileId)) continue; + // Alleen droppen bij expliciet andere IMDb — ontbrekende IDMovieImdb behouden + if (imdbDigits && hit.imdbId && hit.imdbId !== imdbDigits) continue; + seen.add(hit.fileId); + out.push(hit); + } + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err); + console.warn(`[opensubtitles.org] ${url}:`, lastErr); + } } - const url = `https://rest.opensubtitles.org/search/${parts.join("/")}`; + if (!out.length && lastErr) { + throw new AppError("OPENSUBTITLES", lastErr, 400); + } + return out.slice(0, 50); + } + + private async fetchSearch(url: string): Promise { const res = await fetch(url, { headers: { Accept: "application/json", "User-Agent": this.ua, "X-User-Agent": this.ua, }, + redirect: "follow", }); if (!res.ok) { const text = await res.text().catch(() => ""); @@ -55,27 +90,10 @@ export class OpenSubtitlesOrgClient { 400 ); } - - const body = (await res.json()) as OrgRow[] | { data?: OrgRow[] }; - const rows = Array.isArray(body) ? body : body.data ?? []; - const out: SubtitleHit[] = []; - for (const row of rows) { - const fileId = parseInt(String(row.IDSubtitleFile || ""), 10); - if (!Number.isFinite(fileId) || fileId <= 0) continue; - const fps = row.MovieFPS ? parseFloat(row.MovieFPS) : null; - out.push({ - fileId, - language: (row.ISO639 || lang || "nl").toLowerCase(), - release: (row.MovieReleaseName || row.SubFileName || "").trim(), - fileName: row.SubFileName || `sub-${fileId}.srt`, - downloadCount: parseInt(String(row.SubDownloadsCnt || "0"), 10) || 0, - hearingImpaired: row.SubHearingImpaired === "1", - fps: fps && Number.isFinite(fps) ? fps : null, - uploader: row.UserNickName || null, - source: "org", - }); - } - return out.slice(0, 40); + const body = (await res.json()) as OrgRow[] | { data?: OrgRow[] } | null; + if (Array.isArray(body)) return body; + if (body && Array.isArray(body.data)) return body.data; + return []; } async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { @@ -101,16 +119,46 @@ export class OpenSubtitlesOrgClient { fileName = fileName.replace(/\.gz$/i, ""); if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`; } - if (!buf.length) { + if (!content.length) { throw new AppError("OPENSUBTITLES", "OpenSubtitles.org: leeg ondertitelbestand", 400); } return { content, fileName }; } } +function mapRow(row: OrgRow, fallbackLang: string): SubtitleHit | null { + const fileId = parseInt(String(row.IDSubtitleFile || ""), 10); + if (!Number.isFinite(fileId) || fileId <= 0) return null; + const fps = row.MovieFPS ? parseFloat(row.MovieFPS) : null; + const yearRaw = row.MovieYear ? parseInt(String(row.MovieYear), 10) : NaN; + const imdb = normalizeImdb(row.IDMovieImdb || undefined); + return { + fileId, + language: (row.ISO639 || row.SubLanguageID || fallbackLang || "nl").toLowerCase(), + release: (row.MovieReleaseName || row.SubFileName || "").trim(), + fileName: row.SubFileName || `sub-${fileId}.srt`, + downloadCount: parseInt(String(row.SubDownloadsCnt || "0"), 10) || 0, + hearingImpaired: row.SubHearingImpaired === "1", + fps: fps && Number.isFinite(fps) ? fps : null, + uploader: row.UserNickName || null, + source: "org", + movieName: row.MovieName?.trim() || null, + movieYear: Number.isFinite(yearRaw) ? yearRaw : null, + imdbId: imdb, + }; +} + +function normalizeImdb(id?: string | null): string | null { + if (!id) return null; + const digits = String(id).trim().replace(/^tt/i, "").replace(/^0+(?=\d)/, ""); + return /^\d+$/.test(digits) ? digits : null; +} + +/** Legacy REST uses OpenSubtitles language ids (`dut` = Dutch). */ function toOrgLang(languages: string): string { const first = languages.split(",")[0]?.trim().toLowerCase() || "nl"; - if (first === "nl" || first === "nld" || first === "dut") return "dut"; + if (first === "nl" || first === "nld" || first === "dut" || first === "dutch") return "dut"; + if (first === "en" || first === "eng") return "eng"; if (first.length === 3) return first; return first; }