Clarify OpenSubtitles.com 403 (User-Agent) and prefer OS.org downloads without .com login.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-30 21:39:47 +02:00
parent 93bbd07487
commit afcc2e759f
8 changed files with 140 additions and 33 deletions

View file

@ -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)");
}

View file

@ -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<string | null>(null);
const [osErr, setOsErr] = useState<string | null>(null);
const [osWarn, setOsWarn] = useState<string | null>(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() {
<div className="section-head">
<h2 className="section-title">OpenSubtitles</h2>
<p className="muted">
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{" "}
<strong>opensubtitles.com</strong> zijn (niet .org). User-Agent moet exact gelijk
zijn aan de naam van je API Consumer. Lukt .com niet: kies bij Downloads een{" "}
<strong>OS.org</strong>-hit.
</p>
</div>
<div className="card">
@ -508,7 +522,16 @@ export default function SettingsPage() {
/>
</label>
<label>
Gebruikersnaam
User-Agent (API Consumer-naam)
<input
value={osUserAgent}
onChange={(e) => setOsUserAgent(e.target.value)}
placeholder="MediaCluster v1.3"
autoComplete="off"
/>
</label>
<label>
Gebruikersnaam (.com)
<input
value={osUsername}
onChange={(e) => setOsUsername(e.target.value)}
@ -516,7 +539,7 @@ export default function SettingsPage() {
/>
</label>
<label>
Wachtwoord {osHasPassword ? "(leeg = behouden)" : ""}
Wachtwoord (.com) {osHasPassword ? "(leeg = behouden)" : ""}
<input
type="password"
value={osPassword}
@ -538,9 +561,10 @@ export default function SettingsPage() {
{osBusy ? "Bezig…" : "Opslaan + testen"}
</button>
<span className="muted">
Status: {osConfigured ? "geconfigureerd" : "niet geconfigureerd"}
Status: {osConfigured ? "aan" : "uit"}
</span>
{osMsg && <span className="ok-text">{osMsg}</span>}
{osWarn && <span className="danger-text">{osWarn}</span>}
{osErr && <span className="danger-text">{osErr}</span>}
</div>
</div>

View file

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

View file

@ -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")

View file

@ -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,

View file

@ -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<string, string>): Record<string, string> {
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<string | null> {
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)}`,

View file

@ -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<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;
}
@ -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<OpenSubtitlesSettingsPublic> {
@ -27,8 +31,12 @@ export async function getOpenSubtitlesSettingsPublic(): Promise<OpenSubtitlesSet
configured: enabled,
enabled,
username: row?.username ?? "",
userAgent: row?.userAgent?.trim() || DEFAULT_UA,
hasApiKey: !!row?.apiKeyEnc,
hasPassword: !!row?.passwordEnc,
comOk: false,
orgOk: false,
warning: null,
};
}
@ -38,12 +46,12 @@ export async function upsertOpenSubtitlesSettings(
apiKey?: string;
username?: string;
password?: string;
userAgent?: string;
enabled?: boolean;
}
): Promise<OpenSubtitlesSettingsPublic> {
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) {
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) {
const message = err instanceof Error ? err.message : "OpenSubtitles test mislukt";
throw new AppError("OPENSUBTITLES", message, 400);
warning = err instanceof Error ? err.message : "OpenSubtitles.com test mislukt";
}
}
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
);
}

View file

@ -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);