fix subtitle search: IMDb-only to stop wrong movies; restore OS.org hits.

This commit is contained in:
Jos Vooges | STH 2026-08-30 22:01:14 +02:00
parent 942da3a58c
commit d233878e6d
4 changed files with 114 additions and 63 deletions

View file

@ -265,7 +265,7 @@ export default function DownloadsPage() {
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 (gezocht op IMDb + filmnaam via .com en .org)"); setSubsMsg("Geen NL-ondertitels gevonden (alleen IMDb via OS.com + OS.org)");
} }
} finally { } finally {
setSubsBusy(false); setSubsBusy(false);

View file

@ -106,16 +106,11 @@ export class DownloadService {
} }
const languages = opts.languages?.trim() || "nl"; const languages = opts.languages?.trim() || "nl";
const title =
opts.movieTitle?.trim() || // Alleen IMDb — free-text query mengt verkeerde films door elkaar
stripQualityTokens(opts.releaseHint || "").replace(/\b(19|20)\d{2}\b/g, "").trim() || if (!imdbId) {
""; return { imdbId: null, results: [], configured };
const year = opts.movieYear ?? null; }
const titleQuery = title
? year && year > 1900
? `${title} ${year}`
: title
: "";
const tasks: Array<Promise<SubtitleHit[]>> = []; const tasks: Array<Promise<SubtitleHit[]>> = [];
@ -125,19 +120,11 @@ export class DownloadService {
return [] as SubtitleHit[]; return [] as SubtitleHit[];
}); });
if (comClient && imdbId) { if (comClient) {
tasks.push(safe("com/imdb", comClient.search({ imdbId, languages }))); 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) // OpenSubtitles.org = aparte catalogus (rest.opensubtitles.org)
if (imdbId) {
tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages }))); tasks.push(safe("org/imdb", orgClient.search({ imdbId, languages })));
}
if (titleQuery) {
tasks.push(safe("org/title", orgClient.search({ query: titleQuery, languages })));
}
if (tasks.length === 0) { if (tasks.length === 0) {
return { imdbId, results: [], configured }; return { imdbId, results: [], configured };

View file

@ -12,6 +12,9 @@ export type SubtitleHit = {
fps: number | null; fps: number | null;
uploader: string | null; uploader: string | null;
source: SubtitleSource; source: SubtitleSource;
movieName?: string | null;
movieYear?: number | null;
imdbId?: string | null;
}; };
type OsLogin = { token: string; baseUrl: string; expiresAt: number }; type OsLogin = { token: string; baseUrl: string; expiresAt: number };
@ -58,7 +61,7 @@ export class OpenSubtitlesClient {
return ( return (
`OpenSubtitles.com weigert toegang (403). Check: ` + `OpenSubtitles.com weigert toegang (403). Check: ` +
`1) account op opensubtitles.com (niet .org), ` + `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).` `3) of kies een OS.org-ondertitel (geen .com-login nodig).`
); );
} }
@ -92,23 +95,24 @@ export class OpenSubtitlesClient {
return data.token; 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: { async search(opts: {
imdbId?: string; imdbId?: string;
languages?: string; languages?: string;
/** Ignored — free-text query mixes wrong movies. */
query?: string; query?: string;
}): 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+/, "") || "";
if (!imdb || !/^\d+$/.test(imdb)) return [];
const params = new URLSearchParams({ const params = new URLSearchParams({
languages, languages,
type: "movie", type: "movie",
order_by: "download_count", order_by: "download_count",
order_direction: "desc", 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 token = await this.ensureLogin().catch(() => null);
const headers = this.headers( const headers = this.headers(
@ -121,7 +125,7 @@ export class OpenSubtitlesClient {
if (res.status === 403 || /cannot consume/i.test(text)) { if (res.status === 403 || /cannot consume/i.test(text)) {
throw new AppError( throw new AppError(
"OPENSUBTITLES", "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 400
); );
} }
@ -140,6 +144,11 @@ export class OpenSubtitlesClient {
download_count?: number; download_count?: number;
hearing_impaired?: boolean; hearing_impaired?: boolean;
fps?: number; fps?: number;
feature_details?: {
imdb_id?: number | string;
title?: string;
year?: number;
};
files?: Array<{ file_id?: number; file_name?: string }>; files?: Array<{ file_id?: number; file_name?: string }>;
uploader?: { name?: string }; uploader?: { name?: string };
}; };
@ -151,6 +160,10 @@ export class OpenSubtitlesClient {
const a = row.attributes; const a = row.attributes;
const file = a?.files?.[0]; const file = a?.files?.[0];
if (!file?.file_id) continue; 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({ out.push({
fileId: file.file_id, fileId: file.file_id,
language: (a?.language || languages).toLowerCase(), language: (a?.language || languages).toLowerCase(),
@ -161,6 +174,9 @@ export class OpenSubtitlesClient {
fps: a?.fps ?? null, fps: a?.fps ?? null,
uploader: a?.uploader?.name ?? null, uploader: a?.uploader?.name ?? null,
source: "com", source: "com",
movieName: a?.feature_details?.title ?? null,
movieYear: a?.feature_details?.year ?? null,
imdbId: featImdb || imdb,
}); });
} }
return out.slice(0, 40); return out.slice(0, 40);
@ -199,7 +215,7 @@ export class OpenSubtitlesClient {
if (res.status === 403 || /cannot consume/i.test(text)) { if (res.status === 403 || /cannot consume/i.test(text)) {
throw new AppError( throw new AppError(
"OPENSUBTITLES", "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 400
); );
} }

View file

@ -14,14 +14,17 @@ type OrgRow = {
SubLanguageID?: string; SubLanguageID?: string;
SubDownloadLink?: string; SubDownloadLink?: string;
SubFormat?: string; SubFormat?: string;
MovieName?: string | null;
MovieYear?: string | null;
IDMovieImdb?: string | null;
}; };
/** /**
* Legacy OpenSubtitles.org REST search (still live). * OpenSubtitles.org legacy REST (rest.opensubtitles.org).
* Download via stable dl.opensubtitles.org/file/{id} (plain .srt). * Dutch language id is `dut`. IMDb without "tt". No API key.
*/ */
export class OpenSubtitlesOrgClient { export class OpenSubtitlesOrgClient {
private readonly ua = "MediaCluster v1.3"; private readonly ua = "MediaCluster v1.3.2";
async search(opts: { async search(opts: {
imdbId?: string; imdbId?: string;
@ -29,23 +32,55 @@ export class OpenSubtitlesOrgClient {
languages?: string; languages?: string;
}): Promise<SubtitleHit[]> { }): Promise<SubtitleHit[]> {
const lang = toOrgLang(opts.languages || "nl"); const lang = toOrgLang(opts.languages || "nl");
const parts: string[] = [`sublanguageid-${lang}`]; const imdbDigits = normalizeImdb(opts.imdbId);
const imdb = opts.imdbId?.trim().replace(/^tt/i, "");
if (imdb && /^\d+$/.test(imdb)) { const urls: string[] = [];
parts.unshift(`imdbid-${imdb}`); if (imdbDigits) {
urls.push(
`https://rest.opensubtitles.org/search/imdbid-${imdbDigits}/sublanguageid-${lang}`
);
} else if (opts.query?.trim()) { } else if (opts.query?.trim()) {
parts.unshift(`query-${encodeURIComponent(opts.query.trim()).replace(/%20/g, "+")}`); const q = encodeURIComponent(opts.query.trim()).replace(/%20/g, "+");
} else { urls.push(`https://rest.opensubtitles.org/search/query-${q}/sublanguageid-${lang}`);
return []; }
if (!urls.length) return [];
let lastErr: string | null = null;
const out: SubtitleHit[] = [];
const seen = new Set<number>();
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<OrgRow[]> {
const res = await fetch(url, { const res = await fetch(url, {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
"User-Agent": this.ua, "User-Agent": this.ua,
"X-User-Agent": this.ua, "X-User-Agent": this.ua,
}, },
redirect: "follow",
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@ -55,27 +90,10 @@ export class OpenSubtitlesOrgClient {
400 400
); );
} }
const body = (await res.json()) as OrgRow[] | { data?: OrgRow[] } | null;
const body = (await res.json()) as OrgRow[] | { data?: OrgRow[] }; if (Array.isArray(body)) return body;
const rows = Array.isArray(body) ? body : body.data ?? []; if (body && Array.isArray(body.data)) return body.data;
const out: SubtitleHit[] = []; return [];
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);
} }
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
@ -101,16 +119,46 @@ export class OpenSubtitlesOrgClient {
fileName = fileName.replace(/\.gz$/i, ""); fileName = fileName.replace(/\.gz$/i, "");
if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`; 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); throw new AppError("OPENSUBTITLES", "OpenSubtitles.org: leeg ondertitelbestand", 400);
} }
return { content, fileName }; 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 { function toOrgLang(languages: string): string {
const first = languages.split(",")[0]?.trim().toLowerCase() || "nl"; 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; if (first.length === 3) return first;
return first; return first;
} }