import { AppError } from "../security/errors"; export type SubtitleSource = "com" | "org"; export type SubtitleHit = { fileId: number; language: string; release: string; fileName: string; downloadCount: number; hearingImpaired: boolean; fps: number | null; uploader: string | null; source: SubtitleSource; }; type OsLogin = { token: string; baseUrl: string; expiresAt: number }; /** * OpenSubtitles.com REST API (v1). * Requires OPENSUBTITLES_API_KEY; login optional but needed for higher download quotas. */ export class OpenSubtitlesClient { private loginCache: OsLogin | null = null; constructor( private readonly apiKey: string, private readonly username?: string, private readonly password?: string ) {} private headers(extra?: Record): Record { return { "Api-Key": this.apiKey, "User-Agent": "MediaCluster v1.3", Accept: "application/json", ...extra, }; } private root(): string { const base = this.loginCache?.baseUrl?.replace(/\/+$/, ""); if (base && !base.startsWith("http")) return `https://${base}`; if (base) return base; return "https://api.opensubtitles.com"; } async ensureLogin(): Promise { if (!this.username?.trim() || !this.password?.trim()) return null; if (this.loginCache && Date.now() < this.loginCache.expiresAt) { return this.loginCache.token; } const res = await fetch(`${this.root()}/api/v1/login`, { method: "POST", headers: this.headers({ "Content-Type": "application/json" }), body: JSON.stringify({ username: this.username.trim(), password: this.password.trim(), }), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new AppError( "OPENSUBTITLES", `OpenSubtitles login mislukt (${res.status}): ${text.slice(0, 160)}`, 400 ); } const data = (await res.json()) as { token?: string; base_url?: string }; if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen token", 400); this.loginCache = { token: data.token, baseUrl: data.base_url || "api.opensubtitles.com", expiresAt: Date.now() + 20 * 60 * 60 * 1000, }; return data.token; } async search(opts: { imdbId?: string; languages?: string; query?: string; }): Promise { const languages = (opts.languages || "nl").trim() || "nl"; const params = new URLSearchParams({ languages, type: "movie", order_by: "download_count", 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 (!params.has("imdb_id") && !params.has("query")) return []; const token = await this.ensureLogin().catch(() => null); const headers = this.headers( token ? { Authorization: `Bearer ${token}` } : undefined ); const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new AppError( "OPENSUBTITLES", `OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`, 400 ); } const body = (await res.json()) as { data?: Array<{ attributes?: { language?: string; release?: string; download_count?: number; hearing_impaired?: boolean; fps?: number; files?: Array<{ file_id?: number; file_name?: string }>; uploader?: { name?: string }; }; }>; }; const out: SubtitleHit[] = []; for (const row of body.data ?? []) { const a = row.attributes; const file = a?.files?.[0]; if (!file?.file_id) continue; out.push({ fileId: file.file_id, language: (a?.language || languages).toLowerCase(), release: a?.release || file.file_name || "", fileName: file.file_name || a?.release || `sub-${file.file_id}.srt`, downloadCount: a?.download_count ?? 0, hearingImpaired: !!a?.hearing_impaired, fps: a?.fps ?? null, uploader: a?.uploader?.name ?? null, source: "com", }); } return out.slice(0, 40); } async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { const token = await this.ensureLogin(); if (!token) { throw new AppError( "OPENSUBTITLES", "OpenSubtitles.com download vereist gebruikersnaam + wachtwoord (Instellingen)", 400 ); } const headers = this.headers({ "Content-Type": "application/json", Authorization: `Bearer ${token}`, }); const res = await fetch(`${this.root()}/api/v1/download`, { method: "POST", headers, body: JSON.stringify({ file_id: fileId }), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new AppError( "OPENSUBTITLES", `OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`, 400 ); } const data = (await res.json()) as { link?: string; file_name?: string }; if (!data.link) { throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: geen download-link", 400); } const fileRes = await fetch(data.link); if (!fileRes.ok) { throw new AppError("OPENSUBTITLES", `Subtitle ophalen mislukt (${fileRes.status})`, 400); } const buf = Buffer.from(await fileRes.arrayBuffer()); if (!buf.length) { throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: leeg ondertitelbestand", 400); } return { content: buf, fileName: data.file_name || `subtitle-${fileId}.srt`, }; } } /** Keep release tags for filename / OpenSubtitles matching. */ export function extractReleaseTags( release: string, title: string, year?: number | null ): string { let s = release.trim(); s = s.replace(/\.(mkv|mp4|m4v|avi|ts|m2ts|srt|ass|sub)$/i, ""); s = s.replace(/[._]+/g, " "); for (const w of title.split(/\s+/)) { const t = w.trim(); if (t.length < 2) continue; const re = new RegExp(`\\b${escapeRegExp(t)}\\b`, "ig"); s = s.replace(re, " "); } if (year && year > 1900) { s = s.replace(new RegExp(`\\b${year}\\b`, "g"), " "); } s = s.replace(/\s+/g, " ").trim(); 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 { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }