From afcc2e759fbb9b2c7dbe4c737057f0f6c75f5ae0 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Sun, 30 Aug 2026 21:39:47 +0200 Subject: [PATCH] Clarify OpenSubtitles.com 403 (User-Agent) and prefer OS.org downloads without .com login. Co-authored-by: Cursor --- apps/admin-ui/src/app/downloads/page.tsx | 7 ++- apps/admin-ui/src/app/settings/page.tsx | 36 +++++++++-- .../migration.sql | 1 + apps/master-api/prisma/schema.prisma | 2 + apps/master-api/src/app.ts | 4 ++ apps/master-api/src/opensubtitles/client.ts | 62 +++++++++++++++---- apps/master-api/src/settings/opensubtitles.ts | 60 ++++++++++++++---- apps/master-api/src/settings/routes.ts | 1 + 8 files changed, 140 insertions(+), 33 deletions(-) create mode 100644 apps/master-api/prisma/migrations/20260830220000_opensubtitles_user_agent/migration.sql diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index 03e0b56..bab48aa 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -254,8 +254,11 @@ export default function DownloadsPage() { const results: SubtitleHit[] = data.results ?? []; setSubs(results); if (results[0]) { - setSelectedSubId(results[0].fileId); - setSelectedSubSource(results[0].source === "org" ? "org" : "com"); + // Voorkeur OS.org — werkt zonder .com-login + const preferred = + results.find((r) => r.source === "org") || results[0]; + setSelectedSubId(preferred.fileId); + setSelectedSubSource(preferred.source === "org" ? "org" : "com"); } else { setSubsMsg("Geen NL-ondertitels gevonden (gezocht op IMDb + filmnaam via .com en .org)"); } diff --git a/apps/admin-ui/src/app/settings/page.tsx b/apps/admin-ui/src/app/settings/page.tsx index 8ce684a..275ba9e 100644 --- a/apps/admin-ui/src/app/settings/page.tsx +++ b/apps/admin-ui/src/app/settings/page.tsx @@ -89,6 +89,7 @@ export default function SettingsPage() { const [osApiKey, setOsApiKey] = useState(""); const [osUsername, setOsUsername] = useState(""); const [osPassword, setOsPassword] = useState(""); + const [osUserAgent, setOsUserAgent] = useState("MediaCluster v1.3"); const [osEnabled, setOsEnabled] = useState(true); const [osHasKey, setOsHasKey] = useState(false); const [osHasPassword, setOsHasPassword] = useState(false); @@ -96,6 +97,7 @@ export default function SettingsPage() { const [osBusy, setOsBusy] = useState(false); const [osMsg, setOsMsg] = useState(null); const [osErr, setOsErr] = useState(null); + const [osWarn, setOsWarn] = useState(null); const loadShelves = useCallback(() => { fetch("/api/v1/admin/library/shelves", { credentials: "include" }) @@ -131,8 +133,10 @@ export default function SettingsPage() { setOsConfigured(!!s.configured); setOsEnabled(s.enabled !== false); setOsUsername(s.username ?? ""); + setOsUserAgent(s.userAgent || "MediaCluster v1.3"); setOsHasKey(!!s.hasApiKey); setOsHasPassword(!!s.hasPassword); + setOsWarn(s.warning || null); }) .catch(() => undefined); }, []); @@ -217,6 +221,7 @@ export default function SettingsPage() { setOsBusy(true); setOsMsg(null); setOsErr(null); + setOsWarn(null); try { const res = await fetch("/api/v1/admin/settings/opensubtitles", { method: "PUT", @@ -226,6 +231,7 @@ export default function SettingsPage() { apiKey: osApiKey || undefined, username: osUsername, password: osPassword || undefined, + userAgent: osUserAgent.trim() || "MediaCluster v1.3", enabled: osEnabled, }), }); @@ -234,7 +240,13 @@ export default function SettingsPage() { setOsErr(data.error?.message ?? "Opslaan mislukt"); return; } - setOsMsg("Opgeslagen — OpenSubtitles OK"); + const s = data.settings; + const parts: string[] = []; + if (s?.orgOk) parts.push("OS.org OK"); + if (s?.comOk) parts.push("OS.com OK"); + else if (s?.hasApiKey !== false) parts.push("OS.com niet OK"); + setOsMsg(`Opgeslagen — ${parts.join(" · ") || "OK"}`); + setOsWarn(s?.warning || null); setOsApiKey(""); setOsPassword(""); loadOpenSubtitles(); @@ -491,8 +503,10 @@ export default function SettingsPage() {

OpenSubtitles

- Zoekt via OpenSubtitles.com (API-key) én OpenSubtitles.org. Key optioneel maar - verhoogt de kans — aanmaken via opensubtitles.com → API consumers. + Zoekt via OpenSubtitles.com én .org. Account/login moet van{" "} + opensubtitles.com zijn (niet .org). User-Agent moet exact gelijk + zijn aan de naam van je API Consumer. Lukt .com niet: kies bij Downloads een{" "} + OS.org-hit.

@@ -508,7 +522,16 @@ export default function SettingsPage() { /> +
diff --git a/apps/master-api/prisma/migrations/20260830220000_opensubtitles_user_agent/migration.sql b/apps/master-api/prisma/migrations/20260830220000_opensubtitles_user_agent/migration.sql new file mode 100644 index 0000000..ea55726 --- /dev/null +++ b/apps/master-api/prisma/migrations/20260830220000_opensubtitles_user_agent/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT; diff --git a/apps/master-api/prisma/schema.prisma b/apps/master-api/prisma/schema.prisma index 9f48c2f..748a7bd 100644 --- a/apps/master-api/prisma/schema.prisma +++ b/apps/master-api/prisma/schema.prisma @@ -439,6 +439,8 @@ model IntegrationSetting { apiKeyEnc String? @map("api_key_enc") username String? passwordEnc String? @map("password_enc") + /** Must match OpenSubtitles.com API Consumer / User-Agent exactly */ + userAgent String? @map("user_agent") enabled Boolean @default(true) updatedAt DateTime @updatedAt @map("updated_at") createdAt DateTime @default(now()) @map("created_at") diff --git a/apps/master-api/src/app.ts b/apps/master-api/src/app.ts index f2b7c9a..c9d903d 100644 --- a/apps/master-api/src/app.ts +++ b/apps/master-api/src/app.ts @@ -265,12 +265,16 @@ async function main() { "api_key_enc" TEXT, "username" TEXT, "password_enc" TEXT, + "user_agent" TEXT, "enabled" BOOLEAN NOT NULL DEFAULT true, "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "integration_settings_pkey" PRIMARY KEY ("id") ) `); + await prisma.$executeRawUnsafe( + `ALTER TABLE "integration_settings" ADD COLUMN IF NOT EXISTS "user_agent" TEXT` + ); await prisma.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS "library_subtitles" ( "id" TEXT NOT NULL, diff --git a/apps/master-api/src/opensubtitles/client.ts b/apps/master-api/src/opensubtitles/client.ts index 92b0a2b..4152a94 100644 --- a/apps/master-api/src/opensubtitles/client.ts +++ b/apps/master-api/src/opensubtitles/client.ts @@ -16,23 +16,29 @@ export type SubtitleHit = { type OsLogin = { token: string; baseUrl: string; expiresAt: number }; +const DEFAULT_UA = "MediaCluster v1.3"; + /** * OpenSubtitles.com REST API (v1). - * Requires OPENSUBTITLES_API_KEY; login optional but needed for higher download quotas. + * User-Agent must match the registered API Consumer name exactly. */ export class OpenSubtitlesClient { private loginCache: OsLogin | null = null; + private readonly userAgent: string; constructor( private readonly apiKey: string, private readonly username?: string, - private readonly password?: string - ) {} + private readonly password?: string, + userAgent?: string | null + ) { + this.userAgent = userAgent?.trim() || DEFAULT_UA; + } private headers(extra?: Record): Record { return { "Api-Key": this.apiKey, - "User-Agent": "MediaCluster v1.3", + "User-Agent": this.userAgent, Accept: "application/json", ...extra, }; @@ -45,6 +51,18 @@ export class OpenSubtitlesClient { return "https://api.opensubtitles.com"; } + private loginHint(status: number, body: string): string { + if (status === 403 || /cannot consume/i.test(body)) { + return ( + `OpenSubtitles.com weigert toegang (403). Check: ` + + `1) account op opensubtitles.com (niet .org), ` + + `2) User-Agent exact gelijk aan je API Consumer-naam (nu: “${this.userAgent}”), ` + + `3) of kies een OS.org-ondertitel (geen .com-login nodig).` + ); + } + return `OpenSubtitles.com login mislukt (${status}): ${body.slice(0, 160)}`; + } + async ensureLogin(): Promise { if (!this.username?.trim() || !this.password?.trim()) return null; if (this.loginCache && Date.now() < this.loginCache.expiresAt) { @@ -60,14 +78,10 @@ export class OpenSubtitlesClient { }); if (!res.ok) { const text = await res.text().catch(() => ""); - throw new AppError( - "OPENSUBTITLES", - `OpenSubtitles login mislukt (${res.status}): ${text.slice(0, 160)}`, - 400 - ); + throw new AppError("OPENSUBTITLES", this.loginHint(res.status, text), 400); } const data = (await res.json()) as { token?: string; base_url?: string }; - if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles: geen token", 400); + if (!data.token) throw new AppError("OPENSUBTITLES", "OpenSubtitles.com: geen token", 400); this.loginCache = { token: data.token, baseUrl: data.base_url || "api.opensubtitles.com", @@ -76,6 +90,7 @@ export class OpenSubtitlesClient { return data.token; } + /** Search — login optional; failures are ignored so API-key-only still works. */ async search(opts: { imdbId?: string; languages?: string; @@ -101,6 +116,13 @@ export class OpenSubtitlesClient { const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers }); if (!res.ok) { const text = await res.text().catch(() => ""); + if (res.status === 403 || /cannot consume/i.test(text)) { + throw new AppError( + "OPENSUBTITLES", + `OpenSubtitles.com zoeken geweigerd (403). User-Agent “${this.userAgent}” moet exact je API Consumer-naam zijn, of gebruik alleen OS.org-hits.`, + 400 + ); + } throw new AppError( "OPENSUBTITLES", `OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`, @@ -143,14 +165,23 @@ export class OpenSubtitlesClient { } async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> { - const token = await this.ensureLogin(); + let token: string | null = null; + let loginError: string | null = null; + try { + token = await this.ensureLogin(); + } catch (err) { + loginError = err instanceof Error ? err.message : "Login mislukt"; + } + if (!token) { throw new AppError( "OPENSUBTITLES", - "OpenSubtitles.com download vereist gebruikersnaam + wachtwoord (Instellingen)", + loginError || + "OpenSubtitles.com download vereist werkende login (opensubtitles.com-account). Kies liever een OS.org-hit.", 400 ); } + const headers = this.headers({ "Content-Type": "application/json", Authorization: `Bearer ${token}`, @@ -163,6 +194,13 @@ export class OpenSubtitlesClient { }); if (!res.ok) { const text = await res.text().catch(() => ""); + if (res.status === 403 || /cannot consume/i.test(text)) { + throw new AppError( + "OPENSUBTITLES", + `OpenSubtitles.com download geweigerd (403). Kies een OS.org-ondertitel, of fix API Consumer User-Agent (“${this.userAgent}”).`, + 400 + ); + } throw new AppError( "OPENSUBTITLES", `OpenSubtitles.com download mislukt (${res.status}): ${text.slice(0, 200)}`, diff --git a/apps/master-api/src/settings/opensubtitles.ts b/apps/master-api/src/settings/opensubtitles.ts index 296d53d..dd39f6c 100644 --- a/apps/master-api/src/settings/opensubtitles.ts +++ b/apps/master-api/src/settings/opensubtitles.ts @@ -4,10 +4,10 @@ import { AppError } from "../security/errors"; import { OpenSubtitlesClient } from "../opensubtitles/client"; const OS_ID = "opensubtitles"; +const DEFAULT_UA = "MediaCluster v1.3"; export async function isOpenSubtitlesEnabled(): Promise { 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; } @@ -16,8 +16,12 @@ export type OpenSubtitlesSettingsPublic = { configured: boolean; enabled: boolean; username: string; + userAgent: string; hasApiKey: boolean; hasPassword: boolean; + comOk: boolean; + orgOk: boolean; + warning: string | null; }; export async function getOpenSubtitlesSettingsPublic(): Promise { @@ -27,8 +31,12 @@ export async function getOpenSubtitlesSettingsPublic(): Promise { const existing = await prisma.integrationSetting.findUnique({ where: { id: OS_ID } }); const apiKeyTrim = input.apiKey?.trim(); - // API-key optioneel: zonder key blijft OpenSubtitles.org beschikbaar const apiKeyEnc = apiKeyTrim ? encryptSecret(apiKeyTrim, sessionSecret) : existing?.apiKeyEnc ?? null; @@ -55,6 +63,10 @@ export async function upsertOpenSubtitlesSettings( : existing?.passwordEnc ?? null; const username = input.username !== undefined ? input.username.trim() || null : existing?.username ?? null; + const userAgent = + input.userAgent !== undefined + ? input.userAgent.trim() || DEFAULT_UA + : existing?.userAgent?.trim() || DEFAULT_UA; await prisma.integrationSetting.upsert({ where: { id: OS_ID }, @@ -63,31 +75,48 @@ export async function upsertOpenSubtitlesSettings( apiKeyEnc, username, passwordEnc, + userAgent, enabled: input.enabled ?? true, }, update: { apiKeyEnc, username, passwordEnc, + userAgent, enabled: input.enabled ?? true, }, }); - // Validate: .com key if present, else .org search smoke-test + let comOk = false; + let orgOk = false; + let warning: string | null = null; + const client = await createOpenSubtitlesClientFromDb(sessionSecret); - try { - if (client) { + if (client) { + try { await client.search({ imdbId: "tt1375666", languages: "nl" }); - } else { - const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client"); - await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" }); + comOk = true; + } catch (err) { + warning = err instanceof Error ? err.message : "OpenSubtitles.com test mislukt"; } - } catch (err) { - const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt"; - throw new AppError("OPENSUBTITLES", message, 400); } - return getOpenSubtitlesSettingsPublic(); + try { + const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client"); + await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" }); + orgOk = true; + } catch (err) { + const msg = err instanceof Error ? err.message : "OpenSubtitles.org test mislukt"; + warning = warning ? `${warning} · ${msg}` : msg; + } + + if (!comOk && !orgOk) { + throw new AppError("OPENSUBTITLES", warning || "Geen OpenSubtitles-bron bereikbaar", 400); + } + + // Opslaan mag als .org OK is, ook als .com login/API 403 geeft + const pub = await getOpenSubtitlesSettingsPublic(); + return { ...pub, comOk, orgOk, warning }; } export async function createOpenSubtitlesClientFromDb( @@ -97,5 +126,10 @@ export async function createOpenSubtitlesClientFromDb( if (!row?.enabled || !row.apiKeyEnc) return null; const apiKey = decryptSecret(row.apiKeyEnc, sessionSecret); const password = row.passwordEnc ? decryptSecret(row.passwordEnc, sessionSecret) : undefined; - return new OpenSubtitlesClient(apiKey, row.username ?? undefined, password); + return new OpenSubtitlesClient( + apiKey, + row.username ?? undefined, + password, + row.userAgent + ); } diff --git a/apps/master-api/src/settings/routes.ts b/apps/master-api/src/settings/routes.ts index 8375120..552dd0d 100644 --- a/apps/master-api/src/settings/routes.ts +++ b/apps/master-api/src/settings/routes.ts @@ -17,6 +17,7 @@ export function registerSettingsRoutes(app: FastifyInstance, config: Config) { apiKey?: string; username?: string; password?: string; + userAgent?: string; enabled?: boolean; }; const settings = await upsertOpenSubtitlesSettings(config.SESSION_SECRET, body);