Compare commits
No commits in common. "1cc6fedd956f9e89228a9f5136985b083b5c4bf2" and "f6d484aa56b3c106e3692486f907e0f9a24489b2" have entirely different histories.
1cc6fedd95
...
f6d484aa56
11 changed files with 86 additions and 385 deletions
|
|
@ -63,15 +63,13 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
const search = url.search;
|
const search = url.search;
|
||||||
const isLogin = request.method === "POST" && targetPath === "v1/auth/login";
|
const isLogin = request.method === "POST" && targetPath === "v1/auth/login";
|
||||||
const isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
|
const isLogout = request.method === "POST" && targetPath === "v1/auth/logout";
|
||||||
const isSse =
|
|
||||||
request.method === "GET" && targetPath === "v1/admin/viewers/events";
|
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
const contentType = request.headers.get("content-type");
|
const contentType = request.headers.get("content-type");
|
||||||
if (contentType) headers.set("content-type", contentType);
|
if (contentType) headers.set("content-type", contentType);
|
||||||
const cookie = request.headers.get("cookie");
|
const cookie = request.headers.get("cookie");
|
||||||
if (cookie) headers.set("cookie", cookie);
|
if (cookie) headers.set("cookie", cookie);
|
||||||
headers.set("accept", isSse ? "text/event-stream" : "application/json");
|
headers.set("accept", "application/json");
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
request.method !== "GET" && request.method !== "HEAD"
|
request.method !== "GET" && request.method !== "HEAD"
|
||||||
|
|
@ -88,10 +86,6 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
// SSE must not be aborted when the proxy function "returns" the stream
|
|
||||||
...(isSse
|
|
||||||
? { cache: "no-store" as RequestCache, signal: request.signal }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Special-case auth so the session cookie is owned by admin.vonas.nl
|
// Special-case auth so the session cookie is owned by admin.vonas.nl
|
||||||
|
|
@ -136,34 +130,6 @@ async function proxy(request: NextRequest, pathSegments: string[]) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSse) {
|
|
||||||
if (!upstream.ok) {
|
|
||||||
const errBody = await upstream.arrayBuffer();
|
|
||||||
return new NextResponse(errBody, {
|
|
||||||
status: upstream.status,
|
|
||||||
headers: {
|
|
||||||
"content-type":
|
|
||||||
upstream.headers.get("content-type") ?? "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!upstream.body) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: { code: "UPSTREAM_ERROR", message: "SSE stream missing body" } },
|
|
||||||
{ status: 502 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return new NextResponse(upstream.body, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/event-stream; charset=utf-8",
|
|
||||||
"Cache-Control": "no-cache, no-transform",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseBody = await upstream.arrayBuffer();
|
const responseBody = await upstream.arrayBuffer();
|
||||||
const response = new NextResponse(responseBody, { status: upstream.status });
|
const response = new NextResponse(responseBody, { status: upstream.status });
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
|
|
|
||||||
|
|
@ -417,20 +417,12 @@ export default function DownloadsPage() {
|
||||||
}
|
}
|
||||||
const results: SubtitleHit[] = data.results ?? [];
|
const results: SubtitleHit[] = data.results ?? [];
|
||||||
setSubs(results);
|
setSubs(results);
|
||||||
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
|
||||||
if (results[0]) {
|
if (results[0]) {
|
||||||
const preferred = results.find((r) => r.source === "org") || results[0];
|
const preferred = results.find((r) => r.source === "org") || results[0];
|
||||||
setSelectedSubId(preferred.fileId);
|
setSelectedSubId(preferred.fileId);
|
||||||
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
|
setSelectedSubSource(preferred.source === "org" ? "org" : "com");
|
||||||
if (warnings.length) {
|
|
||||||
setSubsMsg(`Let op: ${warnings.join(" · ")}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setSubsMsg(
|
setSubsMsg("Geen NL-ondertitels gevonden");
|
||||||
warnings.length
|
|
||||||
? `Geen NL-ondertitels gevonden (${warnings.join(" · ")})`
|
|
||||||
: "Geen NL-ondertitels gevonden"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubsBusy(false);
|
setSubsBusy(false);
|
||||||
|
|
@ -674,17 +666,11 @@ export default function DownloadsPage() {
|
||||||
return (b.downloadCount || 0) - (a.downloadCount || 0);
|
return (b.downloadCount || 0) - (a.downloadCount || 0);
|
||||||
});
|
});
|
||||||
setSubs(results);
|
setSubs(results);
|
||||||
const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : [];
|
|
||||||
if (results[0]) {
|
if (results[0]) {
|
||||||
setSelectedSubId(results[0].fileId);
|
setSelectedSubId(results[0].fileId);
|
||||||
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
|
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
|
||||||
if (warnings.length) setSubsMsg(`Let op: ${warnings.join(" · ")}`);
|
|
||||||
} else {
|
} else {
|
||||||
setSubsMsg(
|
setSubsMsg("Geen ondertitels gevonden");
|
||||||
warnings.length
|
|
||||||
? `Geen ondertitels gevonden (${warnings.join(" · ")})`
|
|
||||||
: "Geen ondertitels gevonden"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubsBusy(false);
|
setSubsBusy(false);
|
||||||
|
|
|
||||||
|
|
@ -194,48 +194,6 @@ export default function ViewersPage() {
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// Live updates via SSE (device link, login lock, CRUD). Geen interval-poll.
|
|
||||||
// Overslaan tijdens typen; formuliervelden zitten in aparte state en blijven staan.
|
|
||||||
useEffect(() => {
|
|
||||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
const isTyping = () => {
|
|
||||||
const el = document.activeElement;
|
|
||||||
if (!el || !(el instanceof HTMLElement)) return false;
|
|
||||||
const tag = el.tagName;
|
|
||||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
|
||||||
};
|
|
||||||
const scheduleRefresh = () => {
|
|
||||||
if (isTyping()) return;
|
|
||||||
if (debounce) clearTimeout(debounce);
|
|
||||||
debounce = setTimeout(() => {
|
|
||||||
if (!isTyping()) load();
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
|
|
||||||
const es = new EventSource("/api/v1/admin/viewers/events");
|
|
||||||
es.addEventListener("viewers", (ev) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse((ev as MessageEvent).data) as { type?: string };
|
|
||||||
if (data.type === "hello") return;
|
|
||||||
} catch {
|
|
||||||
// still refresh on malformed payloads
|
|
||||||
}
|
|
||||||
scheduleRefresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fallback als je terugkomt op het tabblad (bijv. na korte netwerk-dip)
|
|
||||||
const onVisibility = () => {
|
|
||||||
if (document.visibilityState === "visible") scheduleRefresh();
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", onVisibility);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounce) clearTimeout(debounce);
|
|
||||||
es.close();
|
|
||||||
document.removeEventListener("visibilitychange", onVisibility);
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const active = viewers.filter((v) => v.enabled).length;
|
const active = viewers.filter((v) => v.enabled).length;
|
||||||
const appOn = viewers.filter((v) => v.appAccess).length;
|
const appOn = viewers.filter((v) => v.appAccess).length;
|
||||||
|
|
@ -851,9 +809,6 @@ export default function ViewersPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="sidebar-tools">
|
<div className="sidebar-tools">
|
||||||
<button type="button" onClick={() => load()} title="Lijst opnieuw laden">
|
|
||||||
Vernieuwen
|
|
||||||
</button>
|
|
||||||
{playConfig?.configured ? (
|
{playConfig?.configured ? (
|
||||||
<>
|
<>
|
||||||
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("sync-all")}>
|
<button type="button" disabled={bulkBusy} onClick={() => void bulkAction("sync-all")}>
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,6 @@ export class DownloadService {
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
results: SubtitleHit[];
|
results: SubtitleHit[];
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
warnings?: string[];
|
|
||||||
}> {
|
}> {
|
||||||
const enabled = await isOpenSubtitlesEnabled();
|
const enabled = await isOpenSubtitlesEnabled();
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
|
|
@ -164,7 +163,6 @@ export class DownloadService {
|
||||||
const comClient = await this.opensubtitles();
|
const comClient = await this.opensubtitles();
|
||||||
const orgClient = this.orgClient();
|
const orgClient = this.orgClient();
|
||||||
const configured = true;
|
const configured = true;
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
const isEpisode =
|
const isEpisode =
|
||||||
opts.mediaKind === "episode" ||
|
opts.mediaKind === "episode" ||
|
||||||
|
|
@ -200,9 +198,7 @@ export class DownloadService {
|
||||||
|
|
||||||
const safe = (label: string, p: Promise<SubtitleHit[]>) =>
|
const safe = (label: string, p: Promise<SubtitleHit[]>) =>
|
||||||
p.catch((err) => {
|
p.catch((err) => {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
console.warn(`[downloads] subtitle search ${label}:`, err instanceof Error ? err.message : err);
|
||||||
console.warn(`[downloads] subtitle search ${label}:`, msg);
|
|
||||||
warnings.push(`${label}: ${msg}`);
|
|
||||||
return [] as SubtitleHit[];
|
return [] as SubtitleHit[];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -229,7 +225,7 @@ export class DownloadService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
return { imdbId, results: [], configured, warnings: warnings.length ? warnings : undefined };
|
return { imdbId, results: [], configured };
|
||||||
}
|
}
|
||||||
|
|
||||||
const batches = await Promise.all(tasks);
|
const batches = await Promise.all(tasks);
|
||||||
|
|
@ -257,21 +253,7 @@ export class DownloadService {
|
||||||
.sort((a, b) => b.score - a.score)
|
.sort((a, b) => b.score - a.score)
|
||||||
.map((x) => x.r);
|
.map((x) => x.r);
|
||||||
|
|
||||||
// Als .org stil faalde en we alleen .com-hits hebben: expliciet waarschuwen
|
return { imdbId, results: scored.slice(0, 40), configured };
|
||||||
const hasOrg = scored.some((r) => r.source === "org");
|
|
||||||
const orgAttempted = !!imdbId || !!query;
|
|
||||||
if (orgAttempted && !hasOrg && !warnings.some((w) => w.startsWith("org/"))) {
|
|
||||||
warnings.push(
|
|
||||||
"org: geen resultaten (controleer IMDb-padding / rest.opensubtitles.org)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
imdbId,
|
|
||||||
results: scored.slice(0, 40),
|
|
||||||
configured,
|
|
||||||
warnings: warnings.length ? warnings : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org + local sidecars. */
|
/** TV/client: resolve IMDb (+ episode query) from media file, then search OS.com/OS.org + local sidecars. */
|
||||||
|
|
|
||||||
|
|
@ -138,77 +138,68 @@ export class OpenSubtitlesClient {
|
||||||
token ? { Authorization: `Bearer ${token}` } : undefined
|
token ? { Authorization: `Bearer ${token}` } : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
const out: SubtitleHit[] = [];
|
const res = await fetch(`${this.root()}/api/v1/subtitles?${params}`, { headers });
|
||||||
const maxPages = 3;
|
if (!res.ok) {
|
||||||
for (let page = 1; page <= maxPages; page++) {
|
const text = await res.text().catch(() => "");
|
||||||
params.set("page", String(page));
|
if (res.status === 403 || /cannot consume/i.test(text)) {
|
||||||
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(
|
throw new AppError(
|
||||||
"OPENSUBTITLES",
|
"OPENSUBTITLES",
|
||||||
`OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
|
`OpenSubtitles.com zoeken geweigerd (403). User-Agent "${this.userAgent}" moet exact je API Consumer-naam zijn, of gebruik alleen OS.org-hits.`,
|
||||||
400
|
400
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
throw new AppError(
|
||||||
const body = (await res.json()) as {
|
"OPENSUBTITLES",
|
||||||
total_pages?: number;
|
`OpenSubtitles.com zoeken mislukt (${res.status}): ${text.slice(0, 160)}`,
|
||||||
data?: Array<{
|
400
|
||||||
attributes?: {
|
);
|
||||||
language?: string;
|
|
||||||
release?: string;
|
|
||||||
download_count?: number;
|
|
||||||
hearing_impaired?: boolean;
|
|
||||||
fps?: number;
|
|
||||||
feature_details?: {
|
|
||||||
imdb_id?: number | string;
|
|
||||||
title?: string;
|
|
||||||
year?: number;
|
|
||||||
};
|
|
||||||
files?: Array<{ file_id?: number; file_name?: string }>;
|
|
||||||
uploader?: { name?: string };
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const row of body.data ?? []) {
|
|
||||||
const a = row.attributes;
|
|
||||||
const file = a?.files?.[0];
|
|
||||||
if (!file?.file_id) continue;
|
|
||||||
const featImdb = a?.feature_details?.imdb_id
|
|
||||||
? String(a.feature_details.imdb_id).replace(/^tt/i, "").replace(/^0+/, "")
|
|
||||||
: null;
|
|
||||||
// Movie search: keep IMDb strict. Episode search: parent/series IMDb ≠ episode IMDb.
|
|
||||||
if (!hasEpisode && featImdb && imdb && featImdb !== imdb) 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",
|
|
||||||
movieName: a?.feature_details?.title ?? null,
|
|
||||||
movieYear: a?.feature_details?.year ?? null,
|
|
||||||
imdbId: featImdb || imdb,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPages = body.total_pages ?? page;
|
|
||||||
if (page >= totalPages || out.length >= 50) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.slice(0, 50);
|
const body = (await res.json()) as {
|
||||||
|
data?: Array<{
|
||||||
|
attributes?: {
|
||||||
|
language?: string;
|
||||||
|
release?: string;
|
||||||
|
download_count?: number;
|
||||||
|
hearing_impaired?: boolean;
|
||||||
|
fps?: number;
|
||||||
|
feature_details?: {
|
||||||
|
imdb_id?: number | string;
|
||||||
|
title?: string;
|
||||||
|
year?: 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;
|
||||||
|
const featImdb = a?.feature_details?.imdb_id
|
||||||
|
? String(a.feature_details.imdb_id).replace(/^tt/i, "").replace(/^0+/, "")
|
||||||
|
: null;
|
||||||
|
// Movie search: keep IMDb strict. Episode search: parent/series IMDb ≠ episode IMDb.
|
||||||
|
if (!hasEpisode && featImdb && imdb && featImdb !== imdb) 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",
|
||||||
|
movieName: a?.feature_details?.title ?? null,
|
||||||
|
movieYear: a?.feature_details?.year ?? null,
|
||||||
|
imdbId: featImdb || imdb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.slice(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
async downloadFile(fileId: number): Promise<{ content: Buffer; fileName: string }> {
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { imdbIdsMatch, normalizeImdbForUrl } from "./org-client";
|
|
||||||
|
|
||||||
function assert(cond: unknown, msg: string): asserts cond {
|
|
||||||
if (!cond) throw new Error(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(normalizeImdbForUrl("tt0468569") === "0468569", "pad Dark Knight");
|
|
||||||
assert(normalizeImdbForUrl("468569") === "0468569", "pad short id");
|
|
||||||
assert(normalizeImdbForUrl("0468569") === "0468569", "keep padded");
|
|
||||||
assert(normalizeImdbForUrl("tt1375666") === "1375666", "no over-pad Inception");
|
|
||||||
assert(normalizeImdbForUrl("1375666") === "1375666", "7-digit unchanged");
|
|
||||||
assert(normalizeImdbForUrl("tt0111161") === "0111161", "Shawshank pad");
|
|
||||||
assert(normalizeImdbForUrl(null) === null, "null");
|
|
||||||
assert(imdbIdsMatch("0468569", "468569"), "match padded vs short");
|
|
||||||
assert(imdbIdsMatch("0111161", "111161"), "match shawshank forms");
|
|
||||||
assert(!imdbIdsMatch("0468569", "1375666"), "different titles");
|
|
||||||
|
|
||||||
console.log("opensubtitles/org-client imdb helpers: ok");
|
|
||||||
|
|
@ -22,10 +22,6 @@ type OrgRow = {
|
||||||
/**
|
/**
|
||||||
* OpenSubtitles.org legacy REST (rest.opensubtitles.org).
|
* OpenSubtitles.org legacy REST (rest.opensubtitles.org).
|
||||||
* Dutch language id is `dut`. IMDb without "tt". No API key.
|
* Dutch language id is `dut`. IMDb without "tt". No API key.
|
||||||
*
|
|
||||||
* Important: unpadded IMDb IDs (e.g. `468569` instead of `0468569`) make OS.org
|
|
||||||
* respond with `302 Location: https://_/search/imdbid-0468569/...`. Following that
|
|
||||||
* redirect fails (ENOTFOUND) and searches look empty while .com still works.
|
|
||||||
*/
|
*/
|
||||||
export class OpenSubtitlesOrgClient {
|
export class OpenSubtitlesOrgClient {
|
||||||
private readonly ua = "MediaCluster v1.3.2";
|
private readonly ua = "MediaCluster v1.3.2";
|
||||||
|
|
@ -35,21 +31,17 @@ export class OpenSubtitlesOrgClient {
|
||||||
query?: string;
|
query?: string;
|
||||||
languages?: string;
|
languages?: string;
|
||||||
}): Promise<SubtitleHit[]> {
|
}): Promise<SubtitleHit[]> {
|
||||||
const langs = toOrgLangs(opts.languages || "nl");
|
const lang = toOrgLang(opts.languages || "nl");
|
||||||
const imdbDigits = normalizeImdbForUrl(opts.imdbId);
|
const imdbDigits = normalizeImdb(opts.imdbId);
|
||||||
|
|
||||||
const urls: string[] = [];
|
const urls: string[] = [];
|
||||||
if (imdbDigits) {
|
if (imdbDigits) {
|
||||||
for (const lang of langs) {
|
urls.push(
|
||||||
urls.push(
|
`https://rest.opensubtitles.org/search/imdbid-${imdbDigits}/sublanguageid-${lang}`
|
||||||
`https://rest.opensubtitles.org/search/imdbid-${imdbDigits}/sublanguageid-${lang}`
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (opts.query?.trim()) {
|
} else if (opts.query?.trim()) {
|
||||||
const q = encodeURIComponent(opts.query.trim()).replace(/%20/g, "+");
|
const q = encodeURIComponent(opts.query.trim()).replace(/%20/g, "+");
|
||||||
for (const lang of langs) {
|
urls.push(`https://rest.opensubtitles.org/search/query-${q}/sublanguageid-${lang}`);
|
||||||
urls.push(`https://rest.opensubtitles.org/search/query-${q}/sublanguageid-${lang}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!urls.length) return [];
|
if (!urls.length) return [];
|
||||||
|
|
||||||
|
|
@ -62,11 +54,10 @@ export class OpenSubtitlesOrgClient {
|
||||||
const rows = await this.fetchSearch(url);
|
const rows = await this.fetchSearch(url);
|
||||||
console.info(`[opensubtitles.org] ${url} -> ${rows.length} raw`);
|
console.info(`[opensubtitles.org] ${url} -> ${rows.length} raw`);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const hit = mapRow(row, langs[0] || "dut");
|
const hit = mapRow(row, lang);
|
||||||
if (!hit || seen.has(hit.fileId)) continue;
|
if (!hit || seen.has(hit.fileId)) continue;
|
||||||
// Alleen droppen bij expliciet andere IMDb — ontbrekende IDMovieImdb behouden.
|
// Alleen droppen bij expliciet andere IMDb — ontbrekende IDMovieImdb behouden
|
||||||
// Vergelijk canoniek (zonder leading zeros): API geeft vaak "468569", URL "0468569".
|
if (imdbDigits && hit.imdbId && hit.imdbId !== imdbDigits) continue;
|
||||||
if (imdbDigits && hit.imdbId && !imdbIdsMatch(hit.imdbId, imdbDigits)) continue;
|
|
||||||
seen.add(hit.fileId);
|
seen.add(hit.fileId);
|
||||||
out.push(hit);
|
out.push(hit);
|
||||||
}
|
}
|
||||||
|
|
@ -82,36 +73,15 @@ export class OpenSubtitlesOrgClient {
|
||||||
return out.slice(0, 50);
|
return out.slice(0, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchSearch(url: string, redirectHop = 0): Promise<OrgRow[]> {
|
private async fetchSearch(url: string): Promise<OrgRow[]> {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"User-Agent": this.ua,
|
"User-Agent": this.ua,
|
||||||
"X-User-Agent": this.ua,
|
"X-User-Agent": this.ua,
|
||||||
},
|
},
|
||||||
// Manual: OS.org sometimes 302's to host "_" with the *correct* padded path.
|
redirect: "follow",
|
||||||
redirect: "manual",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status >= 300 && res.status < 400) {
|
|
||||||
const loc = res.headers.get("location") || "";
|
|
||||||
const pathMatch = loc.match(/\/search\/[^?#\s]+/);
|
|
||||||
if (pathMatch && redirectHop < 2) {
|
|
||||||
const fixed = `https://rest.opensubtitles.org${pathMatch[0]}`;
|
|
||||||
if (fixed !== url) {
|
|
||||||
console.warn(
|
|
||||||
`[opensubtitles.org] broken redirect (${res.status} → ${loc}); retry ${fixed}`
|
|
||||||
);
|
|
||||||
return this.fetchSearch(fixed, redirectHop + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new AppError(
|
|
||||||
"OPENSUBTITLES",
|
|
||||||
`OpenSubtitles.org zoeken mislukt (${res.status} redirect: ${loc.slice(0, 120)})`,
|
|
||||||
400
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
|
|
@ -161,7 +131,7 @@ function mapRow(row: OrgRow, fallbackLang: string): SubtitleHit | null {
|
||||||
if (!Number.isFinite(fileId) || fileId <= 0) return null;
|
if (!Number.isFinite(fileId) || fileId <= 0) return null;
|
||||||
const fps = row.MovieFPS ? parseFloat(row.MovieFPS) : null;
|
const fps = row.MovieFPS ? parseFloat(row.MovieFPS) : null;
|
||||||
const yearRaw = row.MovieYear ? parseInt(String(row.MovieYear), 10) : NaN;
|
const yearRaw = row.MovieYear ? parseInt(String(row.MovieYear), 10) : NaN;
|
||||||
const imdb = normalizeImdbForUrl(row.IDMovieImdb || undefined);
|
const imdb = normalizeImdb(row.IDMovieImdb || undefined);
|
||||||
return {
|
return {
|
||||||
fileId,
|
fileId,
|
||||||
language: (row.ISO639 || row.SubLanguageID || fallbackLang || "nl").toLowerCase(),
|
language: (row.ISO639 || row.SubLanguageID || fallbackLang || "nl").toLowerCase(),
|
||||||
|
|
@ -178,38 +148,22 @@ function mapRow(row: OrgRow, fallbackLang: string): SubtitleHit | null {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Digits only, zero-padded to ≥7 for OS.org URL paths. */
|
function normalizeImdb(id?: string | null): string | null {
|
||||||
export function normalizeImdbForUrl(id?: string | null): string | null {
|
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
const digits = String(id).trim().replace(/^tt/i, "").replace(/\D/g, "");
|
const digits = String(id).trim().replace(/^tt/i, "").replace(/\D/g, "");
|
||||||
if (!digits) return null;
|
if (!digits) return null;
|
||||||
return digits.length >= 7 ? digits : digits.padStart(7, "0");
|
// OS.org URLs expect ≥7-digit zero-padded IDs; stripping zeros causes a
|
||||||
}
|
// broken 302 to host "_" and silent empty search results.
|
||||||
|
return digits.padStart(7, "0");
|
||||||
/** Canonical compare: "0468569" === "468569". */
|
|
||||||
export function imdbIdsMatch(a: string, b: string): boolean {
|
|
||||||
const ca = a.replace(/^0+/, "") || "0";
|
|
||||||
const cb = b.replace(/^0+/, "") || "0";
|
|
||||||
return ca === cb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy REST uses OpenSubtitles language ids (`dut` = Dutch). */
|
/** Legacy REST uses OpenSubtitles language ids (`dut` = Dutch). */
|
||||||
function toOrgLangs(languages: string): string[] {
|
function toOrgLang(languages: string): string {
|
||||||
const out: string[] = [];
|
const first = languages.split(",")[0]?.trim().toLowerCase() || "nl";
|
||||||
const seen = new Set<string>();
|
if (first === "nl" || first === "nld" || first === "dut" || first === "dutch") return "dut";
|
||||||
for (const part of languages.split(",")) {
|
if (first === "en" || first === "eng") return "eng";
|
||||||
const first = part.trim().toLowerCase();
|
if (first.length === 3) return first;
|
||||||
if (!first) continue;
|
return first;
|
||||||
let code: string;
|
|
||||||
if (first === "nl" || first === "nld" || first === "dut" || first === "dutch") code = "dut";
|
|
||||||
else if (first === "en" || first === "eng") code = "eng";
|
|
||||||
else if (first.length === 3) code = first;
|
|
||||||
else code = first;
|
|
||||||
if (seen.has(code)) continue;
|
|
||||||
seen.add(code);
|
|
||||||
out.push(code);
|
|
||||||
}
|
|
||||||
return out.length ? out : ["dut"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGzip(buf: Buffer): boolean {
|
function isGzip(buf: Buffer): boolean {
|
||||||
|
|
|
||||||
|
|
@ -103,9 +103,7 @@ export async function upsertOpenSubtitlesSettings(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
|
const { OpenSubtitlesOrgClient } = await import("../opensubtitles/org-client");
|
||||||
// tt0111161 (Shawshank) has a leading zero — catches the OS.org padding/redirect bug
|
await new OpenSubtitlesOrgClient().search({ imdbId: "tt1375666", languages: "nl" });
|
||||||
// that tt1375666 (Inception) does not.
|
|
||||||
await new OpenSubtitlesOrgClient().search({ imdbId: "tt0111161", languages: "nl" });
|
|
||||||
orgOk = true;
|
orgOk = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : "OpenSubtitles.org test mislukt";
|
const msg = err instanceof Error ? err.message : "OpenSubtitles.org test mislukt";
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* In-process fan-out for admin UI SSE (/api/v1/admin/viewers/events).
|
|
||||||
* Single master-api instance — fine for this deploy. No Redis needed.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type ViewerAdminEvent = {
|
|
||||||
type: string;
|
|
||||||
viewerId?: string;
|
|
||||||
at: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Listener = (event: ViewerAdminEvent) => void;
|
|
||||||
|
|
||||||
const listeners = new Set<Listener>();
|
|
||||||
|
|
||||||
export function notifyViewersChanged(
|
|
||||||
type: string,
|
|
||||||
opts?: { viewerId?: string }
|
|
||||||
): void {
|
|
||||||
if (listeners.size === 0) return;
|
|
||||||
const event: ViewerAdminEvent = {
|
|
||||||
type,
|
|
||||||
viewerId: opts?.viewerId,
|
|
||||||
at: Date.now(),
|
|
||||||
};
|
|
||||||
for (const listener of listeners) {
|
|
||||||
try {
|
|
||||||
listener(event);
|
|
||||||
} catch {
|
|
||||||
// never break the mutation path for a broken SSE client
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function subscribeViewersChanged(listener: Listener): () => void {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => {
|
|
||||||
listeners.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { ViewerProfileService } from "./profiles";
|
||||||
import type { DownloadService } from "../downloads/service";
|
import type { DownloadService } from "../downloads/service";
|
||||||
import type { SubtitleSource } from "../opensubtitles/client";
|
import type { SubtitleSource } from "../opensubtitles/client";
|
||||||
import { GooglePlayAccessService } from "../google-play/service";
|
import { GooglePlayAccessService } from "../google-play/service";
|
||||||
import { subscribeViewersChanged } from "./admin-events";
|
|
||||||
|
|
||||||
export function registerViewerRoutes(
|
export function registerViewerRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
|
|
@ -25,50 +24,6 @@ export function registerViewerRoutes(
|
||||||
return { viewers: await viewers.listViewers() };
|
return { viewers: await viewers.listViewers() };
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Live updates for admin UI (device link, login lock, CRUD).
|
|
||||||
* One long-lived connection per open viewers page; heartbeats keep proxies happy.
|
|
||||||
*/
|
|
||||||
app.get(
|
|
||||||
"/api/v1/admin/viewers/events",
|
|
||||||
{
|
|
||||||
preHandler: requireAdmin,
|
|
||||||
config: { rateLimit: false },
|
|
||||||
},
|
|
||||||
(request, reply) => {
|
|
||||||
reply.hijack();
|
|
||||||
const res = reply.raw;
|
|
||||||
res.writeHead(200, {
|
|
||||||
"Content-Type": "text/event-stream; charset=utf-8",
|
|
||||||
"Cache-Control": "no-cache, no-transform",
|
|
||||||
Connection: "keep-alive",
|
|
||||||
"X-Accel-Buffering": "no",
|
|
||||||
});
|
|
||||||
|
|
||||||
const write = (chunk: string) => {
|
|
||||||
if (!res.writableEnded) res.write(chunk);
|
|
||||||
};
|
|
||||||
|
|
||||||
write(": connected\n\n");
|
|
||||||
write(`event: viewers\ndata: ${JSON.stringify({ type: "hello", at: Date.now() })}\n\n`);
|
|
||||||
|
|
||||||
const unsubscribe = subscribeViewersChanged((event) => {
|
|
||||||
write(`event: viewers\ndata: ${JSON.stringify(event)}\n\n`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ping = setInterval(() => {
|
|
||||||
write(`: ping ${Date.now()}\n\n`);
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearInterval(ping);
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
|
|
||||||
request.raw.on("close", cleanup);
|
|
||||||
request.raw.on("error", cleanup);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
app.post("/api/v1/admin/viewers", { preHandler: requireAdmin }, async (request) => {
|
app.post("/api/v1/admin/viewers", { preHandler: requireAdmin }, async (request) => {
|
||||||
const body = request.body as {
|
const body = request.body as {
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import {
|
||||||
kidsSeriesSql,
|
kidsSeriesSql,
|
||||||
isKidsBlockedTitle,
|
isKidsBlockedTitle,
|
||||||
} from "./kids-filter";
|
} from "./kids-filter";
|
||||||
import { notifyViewersChanged } from "./admin-events";
|
|
||||||
|
|
||||||
const CODE_TTL_MS = 10 * 60 * 1000;
|
const CODE_TTL_MS = 10 * 60 * 1000;
|
||||||
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
@ -117,7 +116,6 @@ export class ViewerService {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await ensureOwnerProfile(viewer.id, viewer.name);
|
await ensureOwnerProfile(viewer.id, viewer.name);
|
||||||
notifyViewersChanged("viewer.created", { viewerId: viewer.id });
|
|
||||||
return viewer;
|
return viewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +217,6 @@ export class ViewerService {
|
||||||
if (!existing) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
if (!existing) throw new AppError("NOT_FOUND", "Gebruiker niet gevonden", 404);
|
||||||
// Cascade: profiles, devices, prefs, favorites, iptv-lijn; addon-tokens blijven (viewerUserId → null)
|
// Cascade: profiles, devices, prefs, favorites, iptv-lijn; addon-tokens blijven (viewerUserId → null)
|
||||||
await prisma.viewerUser.delete({ where: { id } });
|
await prisma.viewerUser.delete({ where: { id } });
|
||||||
notifyViewersChanged("viewer.deleted", { viewerId: id });
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -294,24 +291,14 @@ export class ViewerService {
|
||||||
} else if (patch.passwordHash) {
|
} else if (patch.passwordHash) {
|
||||||
await this.clearLoginLock(updated.email.toLowerCase());
|
await this.clearLoginLock(updated.email.toLowerCase());
|
||||||
}
|
}
|
||||||
if (Object.keys(patch).length > 0) {
|
|
||||||
notifyViewersChanged("viewer.updated", { viewerId: updated.id });
|
|
||||||
}
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeDevice(deviceId: string) {
|
async revokeDevice(deviceId: string) {
|
||||||
const device = await prisma.viewerDevice.findUnique({
|
|
||||||
where: { id: deviceId },
|
|
||||||
select: { id: true, viewerUserId: true },
|
|
||||||
});
|
|
||||||
await prisma.viewerDevice.update({
|
await prisma.viewerDevice.update({
|
||||||
where: { id: deviceId },
|
where: { id: deviceId },
|
||||||
data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) },
|
data: { revoked: true, refreshTokenHash: hashToken(`revoked:${deviceId}:${Date.now()}`) },
|
||||||
});
|
});
|
||||||
if (device) {
|
|
||||||
notifyViewersChanged("device.revoked", { viewerId: device.viewerUserId });
|
|
||||||
}
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,7 +448,6 @@ export class ViewerService {
|
||||||
|
|
||||||
if (!device) return { status: "already_linked" as const };
|
if (!device) return { status: "already_linked" as const };
|
||||||
|
|
||||||
notifyViewersChanged("device.linked", { viewerId: viewer.id });
|
|
||||||
return {
|
return {
|
||||||
status: "linked" as const,
|
status: "linked" as const,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
|
@ -567,7 +553,6 @@ export class ViewerService {
|
||||||
await this.clearLoginLock(email);
|
await this.clearLoginLock(email);
|
||||||
await ensureOwnerProfile(viewer.id, viewer.name);
|
await ensureOwnerProfile(viewer.id, viewer.name);
|
||||||
|
|
||||||
notifyViewersChanged("device.login", { viewerId: viewer.id });
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
device: { id: device.id, name: device.name, platform: device.platform },
|
device: { id: device.id, name: device.name, platform: device.platform },
|
||||||
|
|
@ -605,28 +590,15 @@ export class ViewerService {
|
||||||
const failedCount = baseCount + 1;
|
const failedCount = baseCount + 1;
|
||||||
const lockedUntil =
|
const lockedUntil =
|
||||||
failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null;
|
failedCount >= LOGIN_MAX_FAILURES ? new Date(now + LOGIN_LOCK_MS) : null;
|
||||||
const lock = await prisma.viewerLoginLock.upsert({
|
return prisma.viewerLoginLock.upsert({
|
||||||
where: { email },
|
where: { email },
|
||||||
create: { email, failedCount, lockedUntil },
|
create: { email, failedCount, lockedUntil },
|
||||||
update: { failedCount, lockedUntil },
|
update: { failedCount, lockedUntil },
|
||||||
});
|
});
|
||||||
const viewer = await prisma.viewerUser.findUnique({
|
|
||||||
where: { email },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
notifyViewersChanged("login.lock", { viewerId: viewer?.id });
|
|
||||||
return lock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async clearLoginLock(email: string) {
|
private async clearLoginLock(email: string) {
|
||||||
const result = await prisma.viewerLoginLock.deleteMany({ where: { email } });
|
await prisma.viewerLoginLock.deleteMany({ where: { email } });
|
||||||
if (result.count > 0) {
|
|
||||||
const viewer = await prisma.viewerUser.findUnique({
|
|
||||||
where: { email },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
notifyViewersChanged("login.unlock", { viewerId: viewer?.id });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
|
async authFromBearer(authHeader?: string): Promise<AuthedViewer> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue