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);
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);

View file

@ -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<Promise<SubtitleHit[]>> = [];
@ -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 };

View file

@ -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<SubtitleHit[]> {
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
);
}

View file

@ -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<SubtitleHit[]> {
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<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, {
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;
}