fix subtitle search: IMDb-only to stop wrong movies; restore OS.org hits.
This commit is contained in:
parent
942da3a58c
commit
d233878e6d
4 changed files with 114 additions and 63 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue