227 lines
7 KiB
TypeScript
227 lines
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();
|
|
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, "\\$&");
|
|
}
|