stremio/apps/master-api/src/opensubtitles/client.ts
Jos Vooges | STH e2a9155105 Search OpenSubtitles.com and .org in parallel for more NL subtitle hits.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 20:43:19 +02:00

217 lines
6.7 KiB
TypeScript

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<string, string>): Record<string, string> {
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<string | null> {
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<SubtitleHit[]> {
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();
const headers = this.headers({
"Content-Type": "application/json",
...(token ? { 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 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: 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());
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, "\\$&");
}