Search OpenSubtitles.com and .org in parallel for more NL subtitle hits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 20:43:19 +02:00
parent 213faa15ea
commit e2a9155105
10 changed files with 320 additions and 48 deletions

View file

@ -42,6 +42,7 @@ interface SubtitleHit {
downloadCount: number; downloadCount: number;
hearingImpaired: boolean; hearingImpaired: boolean;
uploader: string | null; uploader: string | null;
source?: "com" | "org";
} }
interface JobRow { interface JobRow {
@ -122,6 +123,7 @@ export default function DownloadsPage() {
const [subsBusy, setSubsBusy] = useState(false); const [subsBusy, setSubsBusy] = useState(false);
const [subsConfigured, setSubsConfigured] = useState(true); const [subsConfigured, setSubsConfigured] = useState(true);
const [selectedSubId, setSelectedSubId] = useState<number | null>(null); const [selectedSubId, setSelectedSubId] = useState<number | 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 [jobs, setJobs] = useState<JobRow[]>([]); const [jobs, setJobs] = useState<JobRow[]>([]);
@ -194,6 +196,7 @@ export default function DownloadsPage() {
setJobErr(null); setJobErr(null);
setSubs([]); setSubs([]);
setSelectedSubId(null); setSelectedSubId(null);
setSelectedSubSource(null);
setSubsMsg(null); setSubsMsg(null);
setMetaQ(hit.suggestQuery || hit.title); setMetaQ(hit.suggestQuery || hit.title);
setMetaHits([]); setMetaHits([]);
@ -221,8 +224,14 @@ export default function DownloadsPage() {
setSubsMsg(null); setSubsMsg(null);
setSubs([]); setSubs([]);
setSelectedSubId(null); setSelectedSubId(null);
setSelectedSubSource(null);
try { try {
const params = new URLSearchParams({ languages: "nl", releaseHint }); const params = new URLSearchParams({
languages: "nl",
releaseHint,
movieTitle: meta.title,
});
if (meta.year) params.set("movieYear", String(meta.year));
if (meta.imdbId) params.set("imdbId", meta.imdbId); if (meta.imdbId) params.set("imdbId", meta.imdbId);
else params.set("tmdbId", String(meta.tmdbId)); else params.set("tmdbId", String(meta.tmdbId));
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, { const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
@ -240,8 +249,12 @@ export default function DownloadsPage() {
} }
const results: SubtitleHit[] = data.results ?? []; const results: SubtitleHit[] = data.results ?? [];
setSubs(results); setSubs(results);
if (results[0]) setSelectedSubId(results[0].fileId); if (results[0]) {
else setSubsMsg("Geen NL-ondertitels gevonden"); setSelectedSubId(results[0].fileId);
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
} else {
setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)");
}
} finally { } finally {
setSubsBusy(false); setSubsBusy(false);
} }
@ -275,6 +288,7 @@ export default function DownloadsPage() {
posterUrl: selectedMeta.posterUrl, posterUrl: selectedMeta.posterUrl,
subtitleFileId: selectedSubId, subtitleFileId: selectedSubId,
subtitleLang: selectedSubId ? "nl" : null, subtitleLang: selectedSubId ? "nl" : null,
subtitleSource: selectedSubId ? selectedSubSource || "com" : null,
}), }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -288,6 +302,7 @@ export default function DownloadsPage() {
setSearchErr(null); setSearchErr(null);
setSubs([]); setSubs([]);
setSelectedSubId(null); setSelectedSubId(null);
setSelectedSubSource(null);
loadJobs(); loadJobs();
} finally { } finally {
setJobBusy(false); setJobBusy(false);
@ -544,6 +559,9 @@ export default function DownloadsPage() {
{selectedMeta && ( {selectedMeta && (
<div className="downloads-subs"> <div className="downloads-subs">
<h3 className="card-subtitle">Ondertitel (NL)</h3> <h3 className="card-subtitle">Ondertitel (NL)</h3>
<p className="muted" style={{ marginTop: 0 }}>
Bronnen: OpenSubtitles.com + OpenSubtitles.org
</p>
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>} {subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
{subsMsg && <p className="muted">{subsMsg}</p>} {subsMsg && <p className="muted">{subsMsg}</p>}
{!subsBusy && subsConfigured && ( {!subsBusy && subsConfigured && (
@ -551,7 +569,10 @@ export default function DownloadsPage() {
<button <button
type="button" type="button"
className={`match-item${selectedSubId == null ? " selected" : ""}`} className={`match-item${selectedSubId == null ? " selected" : ""}`}
onClick={() => setSelectedSubId(null)} onClick={() => {
setSelectedSubId(null);
setSelectedSubSource(null);
}}
> >
<span> <span>
<strong>Geen ondertitel</strong> <strong>Geen ondertitel</strong>
@ -560,15 +581,25 @@ export default function DownloadsPage() {
</button> </button>
{subs.map((s) => ( {subs.map((s) => (
<button <button
key={s.fileId} key={`${s.source || "com"}-${s.fileId}`}
type="button" type="button"
className={`match-item${selectedSubId === s.fileId ? " selected" : ""}`} className={`match-item${
onClick={() => setSelectedSubId(s.fileId)} selectedSubId === s.fileId &&
(selectedSubSource || "com") === (s.source || "com")
? " selected"
: ""
}`}
onClick={() => {
setSelectedSubId(s.fileId);
setSelectedSubSource(s.source === "org" ? "org" : "com");
}}
> >
<span> <span>
<strong> <strong>
{s.language.toUpperCase()} · {s.downloadCount} downloads {(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
{s.hearingImpaired ? " · HI" : ""} {s.hearingImpaired ? " · HI" : ""}
{" · "}
{s.source === "org" ? "OS.org" : "OS.com"}
</strong> </strong>
<small className="muted"> <small className="muted">
{s.release || s.fileName} {s.release || s.fileName}

View file

@ -491,7 +491,8 @@ export default function SettingsPage() {
<div className="section-head"> <div className="section-head">
<h2 className="section-title">OpenSubtitles</h2> <h2 className="section-title">OpenSubtitles</h2>
<p className="muted"> <p className="muted">
NL-ondertitels bij Downloads. Key via opensubtitles.com → API consumers. Zoekt via OpenSubtitles.com (API-key) én OpenSubtitles.org. Key optioneel maar
verhoogt de kans — aanmaken via opensubtitles.com → API consumers.
</p> </p>
</div> </div>
<div className="card"> <div className="card">

View file

@ -0,0 +1 @@
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT;

View file

@ -417,6 +417,7 @@ model DownloadJob {
releaseTags String? @map("release_tags") releaseTags String? @map("release_tags")
subtitleFileId Int? @map("subtitle_file_id") subtitleFileId Int? @map("subtitle_file_id")
subtitleLang String? @map("subtitle_lang") subtitleLang String? @map("subtitle_lang")
subtitleSource String? @map("subtitle_source")
error String? error String?
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View file

@ -247,6 +247,9 @@ async function main() {
await prisma.$executeRawUnsafe( await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT` `ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_lang" TEXT`
); );
await prisma.$executeRawUnsafe(
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_source" TEXT`
);
await prisma.$executeRawUnsafe(` await prisma.$executeRawUnsafe(`
CREATE TABLE IF NOT EXISTS "integration_settings" ( CREATE TABLE IF NOT EXISTS "integration_settings" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,

View file

@ -63,16 +63,21 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
tmdbId?: string; tmdbId?: string;
languages?: string; languages?: string;
releaseHint?: string; releaseHint?: string;
movieTitle?: string;
movieYear?: string;
}; };
const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined; const tmdbId = q.tmdbId ? parseInt(q.tmdbId, 10) : undefined;
if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) { if (!q.imdbId?.trim() && (!tmdbId || !Number.isFinite(tmdbId))) {
throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400); throw new AppError("INVALID_REQUEST", "imdbId of tmdbId verplicht", 400);
} }
const movieYear = q.movieYear ? parseInt(q.movieYear, 10) : undefined;
const out = await downloads.searchSubtitles({ const out = await downloads.searchSubtitles({
imdbId: q.imdbId, imdbId: q.imdbId,
tmdbId, tmdbId,
languages: q.languages || "nl", languages: q.languages || "nl",
releaseHint: q.releaseHint, releaseHint: q.releaseHint,
movieTitle: q.movieTitle,
movieYear: movieYear && Number.isFinite(movieYear) ? movieYear : null,
}); });
return out; return out;
}); });
@ -116,6 +121,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
posterUrl?: string | null; posterUrl?: string | null;
subtitleFileId?: number | null; subtitleFileId?: number | null;
subtitleLang?: string | null; subtitleLang?: string | null;
subtitleSource?: "com" | "org" | null;
}; };
if (!body.nodeId || !body.uri || !body.movieTitle) { if (!body.nodeId || !body.uri || !body.movieTitle) {
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400); throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
@ -131,6 +137,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
posterUrl: body.posterUrl, posterUrl: body.posterUrl,
subtitleFileId: body.subtitleFileId, subtitleFileId: body.subtitleFileId,
subtitleLang: body.subtitleLang, subtitleLang: body.subtitleLang,
subtitleSource: body.subtitleSource === "org" ? "org" : body.subtitleFileId ? "com" : null,
}); });
return { job }; return { job };
}); });

View file

@ -13,9 +13,12 @@ import {
import { import {
OpenSubtitlesClient, OpenSubtitlesClient,
extractReleaseTags, extractReleaseTags,
stripQualityTokens,
type SubtitleHit, type SubtitleHit,
type SubtitleSource,
} from "../opensubtitles/client"; } from "../opensubtitles/client";
import { createOpenSubtitlesClientFromDb } from "../settings/opensubtitles"; import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
function normalizeBaseUrl(raw: string): string { function normalizeBaseUrl(raw: string): string {
let u = raw.trim().replace(/\/+$/, ""); let u = raw.trim().replace(/\/+$/, "");
@ -48,8 +51,12 @@ export class DownloadService {
return createOpenSubtitlesClientFromDb(this.sessionSecret); return createOpenSubtitlesClientFromDb(this.sessionSecret);
} }
private orgClient(): OpenSubtitlesOrgClient {
return new OpenSubtitlesOrgClient();
}
async hasOpenSubtitles(): Promise<boolean> { async hasOpenSubtitles(): Promise<boolean> {
return !!(await this.opensubtitles()); return isOpenSubtitlesEnabled();
} }
async searchSubtitles(opts: { async searchSubtitles(opts: {
@ -57,40 +64,91 @@ export class DownloadService {
tmdbId?: number; tmdbId?: number;
languages?: string; languages?: string;
releaseHint?: string; releaseHint?: string;
}): Promise<{ imdbId: string | null; results: SubtitleHit[]; configured: boolean }> { movieTitle?: string;
const client = await this.opensubtitles(); movieYear?: number | null;
if (!client) { }): Promise<{
imdbId: string | null;
results: SubtitleHit[];
configured: boolean;
}> {
const enabled = await isOpenSubtitlesEnabled();
if (!enabled) {
return { imdbId: null, results: [], configured: false }; return { imdbId: null, results: [], configured: false };
} }
const comClient = await this.opensubtitles();
const orgClient = this.orgClient();
const configured = true;
let imdbId = opts.imdbId?.trim() || null; let imdbId = opts.imdbId?.trim() || null;
if (!imdbId && opts.tmdbId) { if (!imdbId && opts.tmdbId) {
imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId); imdbId = await this.metadata.resolveMovieImdbId(opts.tmdbId);
} }
if (!imdbId) {
return { imdbId: null, results: [], configured: true };
}
const languages = opts.languages?.trim() || "nl"; const languages = opts.languages?.trim() || "nl";
const results = await client.search({ const title =
imdbId, opts.movieTitle?.trim() ||
languages, stripQualityTokens(opts.releaseHint || "").replace(/\b(19|20)\d{2}\b/g, "").trim() ||
query: opts.releaseHint, "";
const year = opts.movieYear ?? null;
const titleQuery = title
? year && year > 1900
? `${title} ${year}`
: title
: "";
const tasks: Array<Promise<SubtitleHit[]>> = [];
const safe = (label: string, p: Promise<SubtitleHit[]>) =>
p.catch((err) => {
console.warn(`[downloads] subtitle search ${label}:`, err instanceof Error ? err.message : err);
return [] as SubtitleHit[];
}); });
const hint = (opts.releaseHint || "").toLowerCase();
const scored = results if (comClient && imdbId) {
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 })));
}
if (tasks.length === 0) {
return { imdbId, results: [], configured };
}
const batches = await Promise.all(tasks);
const merged = mergeSubtitleHits(batches.flat());
const hintRaw = opts.releaseHint || "";
const hint = stripQualityTokens(hintRaw).toLowerCase();
const scored = merged
.map((r) => { .map((r) => {
let score = r.downloadCount; let score = r.downloadCount;
if (r.language === "nl" || r.language.startsWith("nl")) score += 1_000_000; if (r.language === "nl" || r.language.startsWith("nl") || r.language === "dut") {
if (hint && r.release) { score += 1_000_000;
}
if (r.source === "org") score += 8; // .org vaak rijker; niet onder .com laten verdwijnen
if (r.source === "com") score += 5;
if (hint) {
const hay = `${r.release} ${r.fileName}`.toLowerCase();
const tokens = hint.split(/[^a-z0-9]+/).filter((t) => t.length > 2); const tokens = hint.split(/[^a-z0-9]+/).filter((t) => t.length > 2);
for (const t of tokens) { for (const t of tokens) {
if (r.release.toLowerCase().includes(t)) score += 50; if (hay.includes(t)) score += 50;
} }
} }
return { r, score }; return { r, score };
}) })
.sort((a, b) => b.score - a.score) .sort((a, b) => b.score - a.score)
.map((x) => x.r); .map((x) => x.r);
return { imdbId, results: scored, configured: true };
return { imdbId, results: scored.slice(0, 40), configured };
} }
private async clientForNode(nodeId: string): Promise<{ private async clientForNode(nodeId: string): Promise<{
@ -262,6 +320,7 @@ export class DownloadService {
posterUrl?: string | null; posterUrl?: string | null;
subtitleFileId?: number | null; subtitleFileId?: number | null;
subtitleLang?: string | null; subtitleLang?: string | null;
subtitleSource?: SubtitleSource | null;
}) { }) {
const uri = input.uri.trim(); const uri = input.uri.trim();
if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400); if (!uri) throw new AppError("INVALID_REQUEST", "Download-URI ontbreekt", 400);
@ -328,6 +387,11 @@ export class DownloadService {
const subtitleLang = subtitleFileId const subtitleLang = subtitleFileId
? (input.subtitleLang?.trim().toLowerCase() || "nl") ? (input.subtitleLang?.trim().toLowerCase() || "nl")
: null; : null;
const subtitleSource: SubtitleSource | null = subtitleFileId
? input.subtitleSource === "org"
? "org"
: "com"
: null;
let beforeIds = new Set<string>(); let beforeIds = new Set<string>();
try { try {
@ -371,6 +435,7 @@ export class DownloadService {
releaseTags, releaseTags,
subtitleFileId, subtitleFileId,
subtitleLang, subtitleLang,
subtitleSource,
}, },
}); });
@ -512,10 +577,19 @@ export class DownloadService {
| undefined; | undefined;
let subtitleText: string | null = null; let subtitleText: string | null = null;
let subtitleFileName: string | null = null; let subtitleFileName: string | null = null;
const osClient = await this.opensubtitles(); if (job.subtitleFileId) {
if (job.subtitleFileId && osClient) {
try { try {
const file = await osClient.downloadFile(job.subtitleFileId); const source = job.subtitleSource === "org" ? "org" : "com";
const file =
source === "org"
? await this.orgClient().downloadFile(job.subtitleFileId)
: await (async () => {
const osClient = await this.opensubtitles();
if (!osClient) {
throw new Error("OpenSubtitles.com niet geconfigureerd");
}
return osClient.downloadFile(job.subtitleFileId!);
})();
const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt"; const ext = file.fileName.toLowerCase().endsWith(".ass") ? ".ass" : ".srt";
subtitleText = file.content.toString("utf8"); subtitleText = file.content.toString("utf8");
subtitleFileName = file.fileName; subtitleFileName = file.fileName;
@ -596,6 +670,19 @@ export class DownloadService {
} }
} }
function mergeSubtitleHits(hits: SubtitleHit[]): SubtitleHit[] {
// Bewaar hits van .com én .org apart (andere catalogs), dedupe alleen binnen dezelfde bron.
const byId = new Map<string, SubtitleHit>();
for (const hit of hits) {
const idKey = `${hit.source}:${hit.fileId}`;
const prev = byId.get(idKey);
if (!prev || hit.downloadCount > prev.downloadCount) {
byId.set(idKey, hit);
}
}
return [...byId.values()];
}
function isTaskDone(status: string): boolean { function isTaskDone(status: string): boolean {
return status === "finished" || status === "seeding"; return status === "finished" || status === "seeding";
} }

View file

@ -1,5 +1,7 @@
import { AppError } from "../security/errors"; import { AppError } from "../security/errors";
export type SubtitleSource = "com" | "org";
export type SubtitleHit = { export type SubtitleHit = {
fileId: number; fileId: number;
language: string; language: string;
@ -9,6 +11,7 @@ export type SubtitleHit = {
hearingImpaired: boolean; hearingImpaired: boolean;
fps: number | null; fps: number | null;
uploader: string | null; uploader: string | null;
source: SubtitleSource;
}; };
type OsLogin = { token: string; baseUrl: string; expiresAt: number }; type OsLogin = { token: string; baseUrl: string; expiresAt: number };
@ -74,22 +77,21 @@ export class OpenSubtitlesClient {
} }
async search(opts: { async search(opts: {
imdbId: string; imdbId?: string;
languages?: string; languages?: string;
query?: string; query?: string;
}): Promise<SubtitleHit[]> { }): Promise<SubtitleHit[]> {
const imdb = opts.imdbId.trim().replace(/^tt/i, "");
if (!/^\d+$/.test(imdb)) return [];
const languages = (opts.languages || "nl").trim() || "nl"; const languages = (opts.languages || "nl").trim() || "nl";
const params = new URLSearchParams({ const params = new URLSearchParams({
imdb_id: imdb,
languages, languages,
type: "movie", type: "movie",
order_by: "download_count", order_by: "download_count",
order_direction: "desc", order_direction: "desc",
}); });
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 (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(
@ -101,7 +103,7 @@ export class OpenSubtitlesClient {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
throw new AppError( throw new AppError(
"OPENSUBTITLES", "OPENSUBTITLES",
`OpenSubtitles zoeken mislukt (${res.status}): ${text.slice(0, 160)}`, `OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
400 400
); );
} }
@ -134,9 +136,10 @@ export class OpenSubtitlesClient {
hearingImpaired: !!a?.hearing_impaired, hearingImpaired: !!a?.hearing_impaired,
fps: a?.fps ?? null, fps: a?.fps ?? null,
uploader: a?.uploader?.name ?? null, uploader: a?.uploader?.name ?? null,
source: "com",
}); });
} }
return out.slice(0, 25); return out.slice(0, 40);
} }
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
@ -197,6 +200,18 @@ export function extractReleaseTags(
return s; return s;
} }
/** Strip resolution/codec noise so subtitle search can fall back to title match. */
export function stripQualityTokens(s: string): string {
return s
.replace(/[._]+/g, " ")
.replace(
/\b(2160p|1080p|720p|480p|4k|uhd|hdr10\+?|hdr|dv|dovi|atmos|truehd|dts(?:-hd)?(?:\s*ma)?|flac|aac|ac3|eac3|ddp5\.1|bluray|blu-ray|remux|web-?dl|webrip|hdtv|x265|x264|hevc|avc|h\.?265|h\.?264|proper|repack|extended|directors?\s*cut)\b/gi,
" "
)
.replace(/\s+/g, " ")
.trim();
}
function escapeRegExp(s: string): string { function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }

View file

@ -0,0 +1,117 @@
import { gunzipSync } from "zlib";
import { AppError } from "../security/errors";
import type { SubtitleHit } from "./client";
type OrgRow = {
IDSubtitleFile?: string;
SubFileName?: string;
MovieReleaseName?: string;
SubDownloadsCnt?: string;
SubHearingImpaired?: string;
MovieFPS?: string;
UserNickName?: string | null;
ISO639?: string;
SubLanguageID?: string;
SubDownloadLink?: string;
SubFormat?: string;
};
/**
* Legacy OpenSubtitles.org REST search (still live).
* Download via stable dl.opensubtitles.org/file/{id} (plain .srt).
*/
export class OpenSubtitlesOrgClient {
private readonly ua = "MediaCluster v1.3";
async search(opts: {
imdbId?: string;
query?: string;
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}`);
} else if (opts.query?.trim()) {
parts.unshift(`query-${encodeURIComponent(opts.query.trim()).replace(/%20/g, "+")}`);
} else {
return [];
}
const url = `https://rest.opensubtitles.org/search/${parts.join("/")}`;
const res = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": this.ua,
"X-User-Agent": this.ua,
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles.org zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
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);
}
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
const url = `https://dl.opensubtitles.org/en/download/file/${fileId}`;
const res = await fetch(url, {
headers: { "User-Agent": this.ua, Accept: "*/*" },
redirect: "follow",
});
if (!res.ok) {
throw new AppError(
"OPENSUBTITLES",
`OpenSubtitles.org download mislukt (${res.status})`,
400
);
}
const buf = Buffer.from(await res.arrayBuffer());
const disposition = res.headers.get("content-disposition") || "";
const nameMatch = /filename="?([^";]+)"?/i.exec(disposition);
let fileName = nameMatch?.[1]?.trim() || `subtitle-${fileId}.srt`;
let content = buf;
if (fileName.toLowerCase().endsWith(".gz") || isGzip(buf)) {
content = gunzipSync(buf);
fileName = fileName.replace(/\.gz$/i, "");
if (!/\.(srt|ass|ssa|vtt)$/i.test(fileName)) fileName = `${fileName}.srt`;
}
return { content, fileName };
}
}
function toOrgLang(languages: string): string {
const first = languages.split(",")[0]?.trim().toLowerCase() || "nl";
if (first === "nl" || first === "nld" || first === "dut") return "dut";
if (first.length === 3) return first;
return first;
}
function isGzip(buf: Buffer): boolean {
return buf.length >= 2 && buf[0] === 0x1f && buf[1] === 0x8b;
}

View file

@ -5,6 +5,13 @@ import { OpenSubtitlesClient } from "../opensubtitles/client";
const OS_ID = "opensubtitles"; const OS_ID = "opensubtitles";
export async function isOpenSubtitlesEnabled(): Promise<boolean> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
// Geen rij = standaard aan (.org werkt zonder key)
if (!row) return true;
return row.enabled;
}
export type OpenSubtitlesSettingsPublic = { export type OpenSubtitlesSettingsPublic = {
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
@ -15,9 +22,10 @@ export type OpenSubtitlesSettingsPublic = {
export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> { export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSettingsPublic> {
const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const row = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
const enabled = row?.enabled ?? true;
return { return {
configured: !!(row?.apiKeyEnc && row.enabled), configured: enabled,
enabled: row?.enabled ?? true, enabled,
username: row?.username ?? "", username: row?.username ?? "",
hasApiKey: !!row?.apiKeyEnc, hasApiKey: !!row?.apiKeyEnc,
hasPassword: !!row?.passwordEnc, hasPassword: !!row?.passwordEnc,
@ -35,13 +43,10 @@ export async function upsertOpenSubtitlesSettings(
): Promise<OpenSubtitlesSettingsPublic> { ): Promise<OpenSubtitlesSettingsPublic> {
const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } });
const apiKeyTrim = input.apiKey?.trim(); const apiKeyTrim = input.apiKey?.trim();
if (!apiKeyTrim && !existing?.apiKeyEnc) { // API-key optioneel: zonder key blijft OpenSubtitles.org beschikbaar
throw new AppError("INVALID_REQUEST", "API-key is verplicht", 400);
}
const apiKeyEnc = apiKeyTrim const apiKeyEnc = apiKeyTrim
? encryptSecret(apiKeyTrim, sessionSecret) ? encryptSecret(apiKeyTrim, sessionSecret)
: existing!.apiKeyEnc; : existing?.apiKeyEnc ?? null;
const passwordEnc = const passwordEnc =
input.password !== undefined input.password !== undefined
? input.password.trim() ? input.password.trim()
@ -68,11 +73,15 @@ export async function upsertOpenSubtitlesSettings(
}, },
}); });
// Validate by searching a known movie (optional light check: login if creds) // Validate: .com key if present, else .org search smoke-test
const client = await createOpenSubtitlesClientFromDb(sessionSecret); const client = await createOpenSubtitlesClientFromDb(sessionSecret);
if (!client) throw new AppError("OPENSUBTITLES", "Kon client niet maken", 400);
try { try {
if (client) {
await client.search({ imdbId: "tt1375666", languages: "nl" }); await client.search({ imdbId: "tt1375666", languages: "nl" });
} else {
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" });
}
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt"; const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
throw new AppError("OPENSUBTITLES", message, 400); throw new AppError("OPENSUBTITLES", message, 400);