1325 lines
48 KiB
TypeScript
1325 lines
48 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import { Nav, useAuth } from "@/components/Nav";
|
|
|
|
interface NodeRow {
|
|
id: string;
|
|
name: string;
|
|
status: string;
|
|
}
|
|
|
|
interface DsConfig {
|
|
nodeId: string;
|
|
nodeName?: string;
|
|
}
|
|
|
|
interface SearchHit {
|
|
title: string;
|
|
size: number;
|
|
seeds: number;
|
|
peers: number;
|
|
uri: string | null;
|
|
module: string | null;
|
|
suggestQuery: string;
|
|
}
|
|
|
|
interface MetaHit {
|
|
tmdbId: number;
|
|
title: string;
|
|
year: number | null;
|
|
overview: string | null;
|
|
posterUrl: string | null;
|
|
imdbId: string | null;
|
|
}
|
|
|
|
interface SubtitleHit {
|
|
fileId: number;
|
|
language: string;
|
|
release: string;
|
|
fileName: string;
|
|
downloadCount: number;
|
|
hearingImpaired: boolean;
|
|
uploader: string | null;
|
|
source?: "com" | "org";
|
|
}
|
|
|
|
interface JobProgress {
|
|
percent: number | null;
|
|
bytesDownloaded: number | null;
|
|
bytesTotal: number | null;
|
|
speedBps: number | null;
|
|
dsStatus: string | null;
|
|
}
|
|
|
|
interface EpisodeTrack {
|
|
episodeNumber: number;
|
|
episodeTitle: string;
|
|
importedPath: string | null;
|
|
subtitlePath: string | null;
|
|
subtitleError: string | null;
|
|
}
|
|
|
|
interface JobRow {
|
|
id: string;
|
|
nodeId: string;
|
|
nodeName: string;
|
|
status: string;
|
|
libraryTarget?: LibraryTarget;
|
|
seriesMode?: "EPISODE" | "SEASON" | null;
|
|
seasonNumber?: number | null;
|
|
episodeNumber?: number | null;
|
|
episodeTitle?: string | null;
|
|
episodes?: EpisodeTrack[];
|
|
sourceTitle: string;
|
|
movieTitle: string;
|
|
movieYear: number | null;
|
|
posterUrl: string | null;
|
|
tmdbId?: number | null;
|
|
imdbId?: string | null;
|
|
error: string | null;
|
|
hasSubtitle?: boolean;
|
|
subtitlePath?: string | null;
|
|
subtitleError?: string | null;
|
|
subtitleSource?: string | null;
|
|
importedPath?: string | null;
|
|
progress?: JobProgress | null;
|
|
}
|
|
|
|
type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES";
|
|
type SeriesMode = "EPISODE" | "SEASON";
|
|
|
|
interface SeasonEpisode {
|
|
episodeNumber: number;
|
|
title: string;
|
|
}
|
|
|
|
const TARGET_OPTIONS: Array<{ id: LibraryTarget; label: string; hint: string }> = [
|
|
{ id: "FILMS", label: "Films 1080", hint: "1080p → Films-map" },
|
|
{ id: "FOUR_K", label: "4K", hint: "2160p → 4K-map" },
|
|
{ id: "SERIES", label: "Series", hint: "→ Series-map" },
|
|
];
|
|
|
|
function targetLabel(t?: string): string {
|
|
return TARGET_OPTIONS.find((o) => o.id === t)?.label || t || "4K";
|
|
}
|
|
|
|
function jobDetailLabel(j: JobRow): string {
|
|
if (j.libraryTarget !== "SERIES") return targetLabel(j.libraryTarget);
|
|
const s = j.seasonNumber != null ? `S${String(j.seasonNumber).padStart(2, "0")}` : "";
|
|
if (j.seriesMode === "EPISODE" && j.episodeNumber != null) {
|
|
return `Series · ${s}E${String(j.episodeNumber).padStart(2, "0")}`;
|
|
}
|
|
return `Series · ${s || "seizoen"}`;
|
|
}
|
|
|
|
function formatSpeed(bps: number | null | undefined): string {
|
|
if (bps == null || bps < 0) return "—";
|
|
if (bps === 0) return "0 MB/s";
|
|
const mbs = bps / 1_000_000;
|
|
if (mbs >= 10) return `${mbs.toFixed(1)} MB/s`;
|
|
if (mbs >= 1) return `${mbs.toFixed(2)} MB/s`;
|
|
const kbs = bps / 1000;
|
|
if (kbs >= 1) return `${kbs.toFixed(0)} KB/s`;
|
|
return `${bps.toFixed(0)} B/s`;
|
|
}
|
|
|
|
function progressLabel(job: JobRow): string {
|
|
if (job.status === "IMPORTING" || job.status === "FINISHED") return "Importeren…";
|
|
if (job.status !== "DOWNLOADING") return "—";
|
|
const p = job.progress;
|
|
if (!p) return "…";
|
|
const parts: string[] = [];
|
|
if (p.percent != null) parts.push(`${p.percent % 1 === 0 ? p.percent.toFixed(0) : p.percent.toFixed(1)}%`);
|
|
parts.push(formatSpeed(p.speedBps));
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
function subtitleStatus(job: JobRow): { kind: "ok" | "pending" | "missing"; text: string } | null {
|
|
if (job.subtitlePath) {
|
|
return {
|
|
kind: "ok",
|
|
text: `Ondertitel: ${job.subtitlePath.split(/[/\\]/).pop()}`,
|
|
};
|
|
}
|
|
if (!job.hasSubtitle && !job.subtitleError) return null;
|
|
if (job.status === "IMPORTED") {
|
|
return {
|
|
kind: "missing",
|
|
text: job.subtitleError || "Ondertitel ontbreekt in map",
|
|
};
|
|
}
|
|
return {
|
|
kind: "pending",
|
|
text: job.subtitleError || "Ondertitel gekozen — wordt bij import geschreven",
|
|
};
|
|
}
|
|
|
|
function formatBytes(n: number): string {
|
|
if (!n || n <= 0) return "—";
|
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
let i = 0;
|
|
let v = n;
|
|
while (v >= 1024 && i < u.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
|
}
|
|
|
|
function statusClass(s: string): string {
|
|
switch (s) {
|
|
case "IMPORTED":
|
|
return "badge badge-ok";
|
|
case "DOWNLOADING":
|
|
case "IMPORTING":
|
|
case "FINISHED":
|
|
return "badge badge-warn";
|
|
case "FAILED":
|
|
return "badge badge-danger";
|
|
default:
|
|
return "badge";
|
|
}
|
|
}
|
|
|
|
function statusLabel(s: string): string {
|
|
switch (s) {
|
|
case "DOWNLOADING":
|
|
return "Downloaden";
|
|
case "FINISHED":
|
|
return "Klaar";
|
|
case "IMPORTING":
|
|
return "Importeren";
|
|
case "IMPORTED":
|
|
return "Geïmporteerd";
|
|
case "FAILED":
|
|
return "Mislukt";
|
|
default:
|
|
return s;
|
|
}
|
|
}
|
|
|
|
export default function DownloadsPage() {
|
|
useAuth();
|
|
const [nodes, setNodes] = useState<NodeRow[]>([]);
|
|
const [configs, setConfigs] = useState<DsConfig[]>([]);
|
|
const [nodeId, setNodeId] = useState("");
|
|
|
|
const [query, setQuery] = useState("");
|
|
const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K");
|
|
const [seriesMode, setSeriesMode] = useState<SeriesMode>("EPISODE");
|
|
const [searching, setSearching] = useState(false);
|
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
|
const [searchErr, setSearchErr] = useState<string | null>(null);
|
|
|
|
const [pick, setPick] = useState<SearchHit | null>(null);
|
|
const [metaQ, setMetaQ] = useState("");
|
|
const [metaHits, setMetaHits] = useState<MetaHit[]>([]);
|
|
const [metaBusy, setMetaBusy] = useState(false);
|
|
const [selectedMeta, setSelectedMeta] = useState<MetaHit | null>(null);
|
|
const [seasonCount, setSeasonCount] = useState(0);
|
|
const [seasonNumber, setSeasonNumber] = useState(1);
|
|
const [seasonEpisodes, setSeasonEpisodes] = useState<SeasonEpisode[]>([]);
|
|
const [episodeNumber, setEpisodeNumber] = useState(1);
|
|
const [seasonBusy, setSeasonBusy] = useState(false);
|
|
const [jobBusy, setJobBusy] = useState(false);
|
|
const [jobErr, setJobErr] = useState<string | null>(null);
|
|
const [subs, setSubs] = useState<SubtitleHit[]>([]);
|
|
const [subsBusy, setSubsBusy] = useState(false);
|
|
const [subsConfigured, setSubsConfigured] = useState(true);
|
|
const [selectedSubId, setSelectedSubId] = useState<number | null>(null);
|
|
const [selectedSubSource, setSelectedSubSource] = useState<"com" | "org" | null>(null);
|
|
const [subsMsg, setSubsMsg] = useState<string | null>(null);
|
|
const [subPickJob, setSubPickJob] = useState<JobRow | null>(null);
|
|
const [subPickEpisode, setSubPickEpisode] = useState<number | null>(null);
|
|
const [subPickBusy, setSubPickBusy] = useState(false);
|
|
|
|
const [jobs, setJobs] = useState<JobRow[]>([]);
|
|
|
|
const loadJobs = useCallback(() => {
|
|
fetch("/api/v1/admin/downloads/jobs", { credentials: "include" })
|
|
.then((r) => r.json())
|
|
.then((d) => setJobs(d.jobs ?? []))
|
|
.catch(() => undefined);
|
|
}, []);
|
|
|
|
const loadBootstrap = useCallback(() => {
|
|
Promise.all([
|
|
fetch("/api/v1/admin/nodes", { credentials: "include" }).then((r) => r.json()),
|
|
fetch("/api/v1/admin/downloads/configs", { credentials: "include" }).then((r) => r.json()),
|
|
]).then(([n, c]) => {
|
|
const nodeList: NodeRow[] = (n.nodes ?? []).map(
|
|
(x: { id: string; name: string; status: string }) => ({
|
|
id: x.id,
|
|
name: x.name,
|
|
status: x.status,
|
|
})
|
|
);
|
|
setNodes(nodeList);
|
|
const list: DsConfig[] = c.configs ?? [];
|
|
setConfigs(list);
|
|
setNodeId((prev) => prev || list[0]?.nodeId || nodeList[0]?.id || "");
|
|
});
|
|
loadJobs();
|
|
}, [loadJobs]);
|
|
|
|
useEffect(() => {
|
|
loadBootstrap();
|
|
}, [loadBootstrap]);
|
|
|
|
const hasDownloading = useMemo(
|
|
() => jobs.some((j) => j.status === "DOWNLOADING" || j.status === "IMPORTING" || j.status === "FINISHED"),
|
|
[jobs]
|
|
);
|
|
|
|
useEffect(() => {
|
|
const ms = hasDownloading ? 4_000 : 15_000;
|
|
const t = setInterval(loadJobs, ms);
|
|
return () => clearInterval(t);
|
|
}, [loadJobs, hasDownloading]);
|
|
|
|
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
|
|
const configuredNodes = useMemo(() => {
|
|
const ids = new Set(configs.map((c) => c.nodeId));
|
|
return nodes.filter((n) => ids.has(n.id));
|
|
}, [nodes, configs]);
|
|
|
|
const activeJobs = useMemo(
|
|
() => jobs.filter((j) => j.status !== "IMPORTED"),
|
|
[jobs]
|
|
);
|
|
const completedJobs = useMemo(
|
|
() => jobs.filter((j) => j.status === "IMPORTED"),
|
|
[jobs]
|
|
);
|
|
|
|
async function runSearch() {
|
|
if (!query.trim() || !nodeId) return;
|
|
setSearching(true);
|
|
setSearchErr(null);
|
|
setHits([]);
|
|
try {
|
|
const res = await fetch("/api/v1/admin/downloads/search", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ nodeId, q: query.trim(), libraryTarget }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setSearchErr(data.error?.message ?? "Zoeken mislukt");
|
|
return;
|
|
}
|
|
setHits(data.results ?? []);
|
|
} finally {
|
|
setSearching(false);
|
|
}
|
|
}
|
|
|
|
async function openMatch(hit: SearchHit) {
|
|
setPick(hit);
|
|
setSelectedMeta(null);
|
|
setSeasonCount(0);
|
|
setSeasonEpisodes([]);
|
|
setSeasonNumber(1);
|
|
setEpisodeNumber(1);
|
|
setJobErr(null);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
setSelectedSubSource(null);
|
|
setSubsMsg(null);
|
|
setMetaQ(hit.suggestQuery || hit.title);
|
|
setMetaHits([]);
|
|
await searchMeta(hit.suggestQuery || hit.title);
|
|
}
|
|
|
|
async function searchMeta(q: string) {
|
|
const term = q.trim();
|
|
if (!term) return;
|
|
setMetaBusy(true);
|
|
try {
|
|
const type = libraryTarget === "SERIES" ? "series" : "movie";
|
|
const res = await fetch(
|
|
`/api/v1/admin/metadata/search?type=${type}&q=${encodeURIComponent(term)}`,
|
|
{ credentials: "include" }
|
|
);
|
|
const data = await res.json().catch(() => ({}));
|
|
setMetaHits(data.results ?? []);
|
|
} finally {
|
|
setMetaBusy(false);
|
|
}
|
|
}
|
|
|
|
async function loadSeason(tmdbId: number, season: number) {
|
|
setSeasonBusy(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/metadata/tv/${tmdbId}/season/${season}`, {
|
|
credentials: "include",
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
const eps: SeasonEpisode[] = data.season?.episodes ?? [];
|
|
setSeasonEpisodes(eps);
|
|
if (eps[0]) setEpisodeNumber(eps[0].episodeNumber);
|
|
} finally {
|
|
setSeasonBusy(false);
|
|
}
|
|
}
|
|
|
|
async function loadSubtitles(
|
|
meta: MetaHit,
|
|
releaseHint: string,
|
|
opts?: { seasonNumber?: number; episodeNumber?: number }
|
|
) {
|
|
setSubsBusy(true);
|
|
setSubsMsg(null);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
setSelectedSubSource(null);
|
|
try {
|
|
const params = new URLSearchParams({
|
|
languages: "nl",
|
|
releaseHint,
|
|
movieTitle: meta.title,
|
|
});
|
|
if (meta.year) params.set("movieYear", String(meta.year));
|
|
if (meta.imdbId) params.set("imdbId", meta.imdbId);
|
|
else params.set("tmdbId", String(meta.tmdbId));
|
|
if (opts?.seasonNumber != null && opts?.episodeNumber != null) {
|
|
params.set("mediaKind", "episode");
|
|
params.set("seasonNumber", String(opts.seasonNumber));
|
|
params.set("episodeNumber", String(opts.episodeNumber));
|
|
params.set("tmdbId", String(meta.tmdbId));
|
|
}
|
|
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
|
credentials: "include",
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt");
|
|
return;
|
|
}
|
|
setSubsConfigured(data.configured !== false);
|
|
if (data.configured === false) {
|
|
setSubsMsg("OpenSubtitles staat uit — zet aan onder Instellingen");
|
|
return;
|
|
}
|
|
const results: SubtitleHit[] = data.results ?? [];
|
|
setSubs(results);
|
|
if (results[0]) {
|
|
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");
|
|
}
|
|
} finally {
|
|
setSubsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function selectMeta(m: MetaHit) {
|
|
setSelectedMeta(m);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
if (libraryTarget !== "SERIES") {
|
|
if (pick) await loadSubtitles(m, pick.title);
|
|
return;
|
|
}
|
|
setSeasonBusy(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/metadata/tv/${m.tmdbId}`, {
|
|
credentials: "include",
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
const n = Math.max(1, data.series?.numberOfSeasons || 1);
|
|
setSeasonCount(n);
|
|
const season = Math.min(seasonNumber, n) || 1;
|
|
setSeasonNumber(season);
|
|
await loadSeason(m.tmdbId, season);
|
|
} finally {
|
|
setSeasonBusy(false);
|
|
}
|
|
}
|
|
|
|
async function onSeasonChange(season: number) {
|
|
setSeasonNumber(season);
|
|
if (!selectedMeta) return;
|
|
await loadSeason(selectedMeta.tmdbId, season);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
}
|
|
|
|
async function onEpisodeChange(ep: number) {
|
|
setEpisodeNumber(ep);
|
|
if (!selectedMeta || !pick || seriesMode !== "EPISODE") return;
|
|
await loadSubtitles(selectedMeta, pick.title, {
|
|
seasonNumber,
|
|
episodeNumber: ep,
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (
|
|
libraryTarget === "SERIES" &&
|
|
seriesMode === "EPISODE" &&
|
|
selectedMeta &&
|
|
pick &&
|
|
seasonEpisodes.length > 0
|
|
) {
|
|
void loadSubtitles(selectedMeta, pick.title, {
|
|
seasonNumber,
|
|
episodeNumber,
|
|
});
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [seriesMode, seasonEpisodes, episodeNumber, seasonNumber]);
|
|
|
|
async function confirmDownload() {
|
|
if (!pick || !selectedMeta || !pick.uri) {
|
|
setJobErr("Kies een match en zorg dat er een download-URI is");
|
|
return;
|
|
}
|
|
if (libraryTarget === "SERIES") {
|
|
if (!seasonNumber) {
|
|
setJobErr("Kies een seizoen");
|
|
return;
|
|
}
|
|
if (seriesMode === "EPISODE" && !episodeNumber) {
|
|
setJobErr("Kies een aflevering");
|
|
return;
|
|
}
|
|
}
|
|
setJobBusy(true);
|
|
setJobErr(null);
|
|
try {
|
|
const epTitle =
|
|
seasonEpisodes.find((e) => e.episodeNumber === episodeNumber)?.title || undefined;
|
|
const res = await fetch("/api/v1/admin/downloads/jobs", {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
nodeId,
|
|
uri: pick.uri,
|
|
sourceTitle: pick.title,
|
|
libraryTarget,
|
|
seriesMode: libraryTarget === "SERIES" ? seriesMode : undefined,
|
|
seasonNumber: libraryTarget === "SERIES" ? seasonNumber : undefined,
|
|
episodeNumber:
|
|
libraryTarget === "SERIES" && seriesMode === "EPISODE" ? episodeNumber : undefined,
|
|
episodeTitle:
|
|
libraryTarget === "SERIES" && seriesMode === "EPISODE" ? epTitle : undefined,
|
|
episodes:
|
|
libraryTarget === "SERIES" && seriesMode === "SEASON"
|
|
? seasonEpisodes.map((e) => ({
|
|
episodeNumber: e.episodeNumber,
|
|
episodeTitle: e.title,
|
|
}))
|
|
: undefined,
|
|
tmdbId: selectedMeta.tmdbId,
|
|
imdbId: selectedMeta.imdbId ?? undefined,
|
|
movieTitle: selectedMeta.title,
|
|
movieYear: selectedMeta.year,
|
|
posterUrl: selectedMeta.posterUrl,
|
|
subtitleFileId:
|
|
libraryTarget === "SERIES" && seriesMode === "SEASON" ? null : selectedSubId,
|
|
subtitleLang:
|
|
libraryTarget === "SERIES" && seriesMode === "SEASON"
|
|
? null
|
|
: selectedSubId
|
|
? "nl"
|
|
: null,
|
|
subtitleSource:
|
|
libraryTarget === "SERIES" && seriesMode === "SEASON"
|
|
? null
|
|
: selectedSubId
|
|
? selectedSubSource || "com"
|
|
: null,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setJobErr(data.error?.message ?? "Download starten mislukt");
|
|
return;
|
|
}
|
|
setPick(null);
|
|
setHits([]);
|
|
setQuery("");
|
|
setSearchErr(null);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
setSelectedSubSource(null);
|
|
loadJobs();
|
|
} finally {
|
|
setJobBusy(false);
|
|
}
|
|
}
|
|
|
|
async function retryImport(id: string) {
|
|
await fetch(`/api/v1/admin/downloads/jobs/${id}/retry-import`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
});
|
|
loadJobs();
|
|
}
|
|
|
|
async function deleteCompletedJob(id: string) {
|
|
const res = await fetch(`/api/v1/admin/downloads/jobs/${id}`, {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error?.message ?? "Verwijderen mislukt");
|
|
return;
|
|
}
|
|
loadJobs();
|
|
}
|
|
|
|
async function clearAllCompleted() {
|
|
if (!confirm("Alle voltooide jobs uit de lijst verwijderen? (bestanden blijven staan)")) {
|
|
return;
|
|
}
|
|
const res = await fetch("/api/v1/admin/downloads/jobs/completed", {
|
|
method: "DELETE",
|
|
credentials: "include",
|
|
});
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(data.error?.message ?? "Legen mislukt");
|
|
return;
|
|
}
|
|
loadJobs();
|
|
}
|
|
|
|
async function openSubtitlePicker(job: JobRow, episode?: number) {
|
|
setSubPickJob(job);
|
|
setSubPickEpisode(episode ?? job.episodeNumber ?? null);
|
|
setSubs([]);
|
|
setSelectedSubId(null);
|
|
setSelectedSubSource(null);
|
|
setSubsMsg(null);
|
|
setSubsBusy(true);
|
|
setSubsConfigured(true);
|
|
try {
|
|
const ep = episode ?? job.episodeNumber ?? null;
|
|
const params = new URLSearchParams({
|
|
languages: "nl",
|
|
releaseHint: job.sourceTitle || job.movieTitle,
|
|
movieTitle: job.movieTitle,
|
|
});
|
|
if (job.movieYear) params.set("movieYear", String(job.movieYear));
|
|
if (job.imdbId) params.set("imdbId", job.imdbId);
|
|
if (job.tmdbId) params.set("tmdbId", String(job.tmdbId));
|
|
if (job.libraryTarget === "SERIES" && job.seasonNumber != null && ep != null) {
|
|
params.set("mediaKind", "episode");
|
|
params.set("seasonNumber", String(job.seasonNumber));
|
|
params.set("episodeNumber", String(ep));
|
|
} else if (!job.imdbId && !job.tmdbId) {
|
|
setSubsMsg("Geen IMDb/TMDB op deze job — kan niet zoeken");
|
|
return;
|
|
}
|
|
const res = await fetch(`/api/v1/admin/downloads/subtitles?${params}`, {
|
|
credentials: "include",
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setSubsMsg(data.error?.message ?? "Ondertitels zoeken mislukt");
|
|
return;
|
|
}
|
|
setSubsConfigured(data.configured !== false);
|
|
const results: SubtitleHit[] = data.results ?? [];
|
|
results.sort((a, b) => {
|
|
if (a.source === "org" && b.source !== "org") return -1;
|
|
if (b.source === "org" && a.source !== "org") return 1;
|
|
return (b.downloadCount || 0) - (a.downloadCount || 0);
|
|
});
|
|
setSubs(results);
|
|
if (results[0]) {
|
|
setSelectedSubId(results[0].fileId);
|
|
setSelectedSubSource(results[0].source === "org" ? "org" : "com");
|
|
} else {
|
|
setSubsMsg("Geen ondertitels gevonden");
|
|
}
|
|
} finally {
|
|
setSubsBusy(false);
|
|
}
|
|
}
|
|
|
|
async function confirmSubtitlePick() {
|
|
if (!subPickJob || selectedSubId == null) {
|
|
alert("Kies een ondertitel (bij voorkeur OS.org)");
|
|
return;
|
|
}
|
|
setSubPickBusy(true);
|
|
try {
|
|
const res = await fetch(`/api/v1/admin/downloads/jobs/${subPickJob.id}/retry-subtitle`, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
subtitleFileId: selectedSubId,
|
|
subtitleSource: selectedSubSource || "org",
|
|
subtitleLang: "nl",
|
|
episodeNumber: subPickEpisode ?? undefined,
|
|
}),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
alert(data.error?.message ?? "Ondertitel schrijven mislukt");
|
|
return;
|
|
}
|
|
setSubPickJob(null);
|
|
setSubPickEpisode(null);
|
|
loadJobs();
|
|
} finally {
|
|
setSubPickBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Nav />
|
|
<div className="container downloads-page">
|
|
<div className="page-header">
|
|
<div>
|
|
<div className="page-kicker">Synology · bibliotheek</div>
|
|
<h1>Downloads</h1>
|
|
</div>
|
|
</div>
|
|
|
|
{!hasConfig && (
|
|
<div className="card downloads-banner">
|
|
<p>
|
|
Download Station is nog niet geconfigureerd.{" "}
|
|
<Link href="/settings?tab=download-station">
|
|
Ga naar Instellingen → Download Station
|
|
</Link>
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<section className="stack-section">
|
|
<div className="card downloads-search-card">
|
|
<div className="downloads-target-row" role="radiogroup" aria-label="Bibliotheekdoel">
|
|
{TARGET_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.id}
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={libraryTarget === opt.id}
|
|
className={`downloads-target ${libraryTarget === opt.id ? "on" : ""}`}
|
|
onClick={() => {
|
|
setLibraryTarget(opt.id);
|
|
setHits([]);
|
|
setSearchErr(null);
|
|
}}
|
|
>
|
|
<strong>{opt.label}</strong>
|
|
<span className="muted">{opt.hint}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
{libraryTarget === "SERIES" && (
|
|
<div className="downloads-target-row downloads-mode-row" role="radiogroup" aria-label="Series-modus">
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={seriesMode === "EPISODE"}
|
|
className={`downloads-target ${seriesMode === "EPISODE" ? "on" : ""}`}
|
|
onClick={() => setSeriesMode("EPISODE")}
|
|
>
|
|
<strong>Aflevering</strong>
|
|
<span className="muted">1 file · 1 ondertitel</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={seriesMode === "SEASON"}
|
|
className={`downloads-target ${seriesMode === "SEASON" ? "on" : ""}`}
|
|
onClick={() => setSeriesMode("SEASON")}
|
|
>
|
|
<strong>Seizoen</strong>
|
|
<span className="muted">pack → alle eps · subs later</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="downloads-toolbar">
|
|
<label className="downloads-field">
|
|
<span>Node</span>
|
|
<select
|
|
value={nodeId}
|
|
onChange={(e) => setNodeId(e.target.value)}
|
|
disabled={configuredNodes.length === 0}
|
|
>
|
|
<option value="">Kies node…</option>
|
|
{configuredNodes.map((n) => (
|
|
<option key={n.id} value={n.id}>
|
|
{n.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="downloads-field downloads-field-grow">
|
|
<span>Zoeken</span>
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={
|
|
libraryTarget === "FOUR_K"
|
|
? "Filmnaam…"
|
|
: libraryTarget === "FILMS"
|
|
? "Filmnaam…"
|
|
: "Serienaam…"
|
|
}
|
|
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
className="downloads-search-btn"
|
|
disabled={searching || !hasConfig || !query.trim()}
|
|
onClick={() => void runSearch()}
|
|
>
|
|
{searching ? "Zoeken…" : "Zoeken"}
|
|
</button>
|
|
</div>
|
|
{searchErr && <p className="danger-text">{searchErr}</p>}
|
|
</div>
|
|
|
|
{hits.length > 0 && (
|
|
<div className="card">
|
|
<h3 className="card-subtitle">{hits.length} resultaten</h3>
|
|
<div className="list-cards downloads-results">
|
|
{hits.map((h) => (
|
|
<article key={`${h.title}-${h.size}-${h.seeds}`} className="list-card">
|
|
<div className="list-card-title">{h.title}</div>
|
|
<div className="list-card-meta muted">
|
|
<span>{formatBytes(h.size)}</span>
|
|
<span>
|
|
{h.seeds}/{h.peers} seeds
|
|
</span>
|
|
<span>{h.module || "—"}</span>
|
|
</div>
|
|
<div className="action-row">
|
|
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
|
Kiezen
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
<div className="desktop-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Titel</th>
|
|
<th>Grootte</th>
|
|
<th>Seeds</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{hits.map((h) => (
|
|
<tr key={`${h.title}-${h.size}-${h.seeds}`}>
|
|
<td>
|
|
<div>{h.title}</div>
|
|
<div className="muted">{h.module}</div>
|
|
</td>
|
|
<td>{formatBytes(h.size)}</td>
|
|
<td>
|
|
{h.seeds}/{h.peers}
|
|
</td>
|
|
<td>
|
|
<button type="button" disabled={!h.uri} onClick={() => void openMatch(h)}>
|
|
Kiezen
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section className="stack-section">
|
|
<div className="section-head">
|
|
<h2 className="section-title">Jobs</h2>
|
|
</div>
|
|
<div className="card">
|
|
<div className="list-cards">
|
|
{activeJobs.length === 0 && <p className="muted">Geen actieve downloads.</p>}
|
|
{activeJobs.map((j) => (
|
|
<article key={j.id} className="list-card">
|
|
<div className="list-card-top">
|
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
|
<span className="muted">
|
|
{jobDetailLabel(j)} · {j.nodeName}
|
|
</span>
|
|
</div>
|
|
<div className="downloads-progress-block">
|
|
<div className="downloads-progress-meta">{progressLabel(j)}</div>
|
|
{j.status === "DOWNLOADING" && j.progress?.percent != null && (
|
|
<div className="downloads-progress-bar" aria-hidden>
|
|
<span style={{ width: `${Math.min(100, j.progress.percent)}%` }} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="list-card-title">
|
|
{j.movieTitle}
|
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
|
</div>
|
|
<div className="muted downloads-source">{j.sourceTitle}</div>
|
|
{(() => {
|
|
const sub = subtitleStatus(j);
|
|
if (!sub) return null;
|
|
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
|
|
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
|
|
return <div className="danger-text">{sub.text}</div>;
|
|
})()}
|
|
{j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>}
|
|
<div className="action-row">
|
|
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
|
<button type="button" onClick={() => void retryImport(j.id)}>
|
|
Opnieuw importeren
|
|
</button>
|
|
)}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
<div className="desktop-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Voortgang</th>
|
|
<th>Doel</th>
|
|
<th>Titel</th>
|
|
<th>Bron</th>
|
|
<th>Node</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activeJobs.map((j) => (
|
|
<tr key={j.id}>
|
|
<td>
|
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
|
</td>
|
|
<td className="downloads-progress-cell">
|
|
<div className="downloads-progress-meta">{progressLabel(j)}</div>
|
|
{j.status === "DOWNLOADING" && j.progress?.percent != null && (
|
|
<div className="downloads-progress-bar" aria-hidden>
|
|
<span style={{ width: `${Math.min(100, j.progress.percent)}%` }} />
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="muted">{jobDetailLabel(j)}</td>
|
|
<td>
|
|
{j.movieTitle}
|
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
|
{(() => {
|
|
const sub = subtitleStatus(j);
|
|
if (!sub) return null;
|
|
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
|
|
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
|
|
return <div className="danger-text">{sub.text}</div>;
|
|
})()}
|
|
{j.error && !j.hasSubtitle && <div className="danger-text">{j.error}</div>}
|
|
</td>
|
|
<td className="muted">{j.sourceTitle}</td>
|
|
<td>{j.nodeName}</td>
|
|
<td>
|
|
{(j.status === "FAILED" || j.status === "FINISHED") && (
|
|
<button type="button" onClick={() => void retryImport(j.id)}>
|
|
Retry
|
|
</button>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{activeJobs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="muted">
|
|
Geen actieve downloads.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="stack-section">
|
|
<div className="section-head downloads-completed-head">
|
|
<h2 className="section-title">Voltooid</h2>
|
|
{completedJobs.length > 0 && (
|
|
<button type="button" className="btn-logout" onClick={() => void clearAllCompleted()}>
|
|
Alles legen
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="card">
|
|
<div className="list-cards">
|
|
{completedJobs.length === 0 && <p className="muted">Nog geen voltooide jobs.</p>}
|
|
{completedJobs.map((j) => (
|
|
<article key={j.id} className="list-card">
|
|
<div className="list-card-top">
|
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
|
<span className="muted">
|
|
{jobDetailLabel(j)} · {j.nodeName}
|
|
</span>
|
|
</div>
|
|
<div className="list-card-title">
|
|
{j.movieTitle}
|
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
|
</div>
|
|
<div className="muted downloads-source">{j.sourceTitle}</div>
|
|
{j.error && <div className="muted">{j.error}</div>}
|
|
{j.episodes && j.episodes.length > 0 && (
|
|
<ul className="downloads-episode-list">
|
|
{j.episodes.map((ep) => (
|
|
<li key={ep.episodeNumber}>
|
|
<span>
|
|
E{String(ep.episodeNumber).padStart(2, "0")}
|
|
{ep.episodeTitle ? ` · ${ep.episodeTitle}` : ""}
|
|
{!ep.importedPath
|
|
? " · niet geïmporteerd"
|
|
: ep.subtitlePath
|
|
? " · sub OK"
|
|
: " · sub mist"}
|
|
</span>
|
|
{ep.importedPath && !ep.subtitlePath && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void openSubtitlePicker(j, ep.episodeNumber)}
|
|
>
|
|
Sub
|
|
</button>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{(!j.episodes || j.episodes.length === 0) &&
|
|
(() => {
|
|
const sub = subtitleStatus(j);
|
|
if (!sub) return null;
|
|
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
|
|
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
|
|
return <div className="danger-text">{sub.text}</div>;
|
|
})()}
|
|
<div className="action-row">
|
|
{(!j.episodes || j.episodes.length === 0) &&
|
|
j.status === "IMPORTED" &&
|
|
j.importedPath &&
|
|
!j.subtitlePath && (
|
|
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
|
Ondertitel kiezen
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
|
|
Verwijderen
|
|
</button>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
<div className="desktop-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Doel</th>
|
|
<th>Titel</th>
|
|
<th>Bron</th>
|
|
<th>Node</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{completedJobs.map((j) => (
|
|
<tr key={j.id}>
|
|
<td>
|
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
|
</td>
|
|
<td className="muted">{jobDetailLabel(j)}</td>
|
|
<td>
|
|
{j.movieTitle}
|
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
|
{(() => {
|
|
const sub = subtitleStatus(j);
|
|
if (!sub || (j.episodes && j.episodes.length > 0)) return null;
|
|
if (sub.kind === "ok") return <div className="ok-text">{sub.text}</div>;
|
|
if (sub.kind === "pending") return <div className="muted">{sub.text}</div>;
|
|
return <div className="danger-text">{sub.text}</div>;
|
|
})()}
|
|
</td>
|
|
<td className="muted">{j.sourceTitle}</td>
|
|
<td>{j.nodeName}</td>
|
|
<td className="downloads-job-actions">
|
|
{(!j.episodes || j.episodes.length === 0) &&
|
|
j.importedPath &&
|
|
!j.subtitlePath && (
|
|
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
|
Sub kiezen
|
|
</button>
|
|
)}
|
|
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
|
|
Verwijderen
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{completedJobs.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="muted">
|
|
Nog geen voltooide jobs.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{pick && (
|
|
<div className="modal-backdrop" onClick={() => setPick(null)}>
|
|
<div className="modal-panel downloads-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="page-kicker">Bevestig match</div>
|
|
<h2 className="downloads-modal-title">Wat is dit?</h2>
|
|
<p className="muted downloads-source">{pick.title}</p>
|
|
|
|
<div className="downloads-toolbar downloads-toolbar-stack">
|
|
<input
|
|
value={metaQ}
|
|
onChange={(e) => setMetaQ(e.target.value)}
|
|
placeholder="TMDB-zoek of tt…"
|
|
onKeyDown={(e) => e.key === "Enter" && void searchMeta(metaQ)}
|
|
/>
|
|
<button type="button" disabled={metaBusy} onClick={() => void searchMeta(metaQ)}>
|
|
{metaBusy ? "…" : "Zoek"}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="match-list">
|
|
{metaHits.map((m) => (
|
|
<button
|
|
key={m.tmdbId}
|
|
type="button"
|
|
className={`match-item${selectedMeta?.tmdbId === m.tmdbId ? " selected" : ""}`}
|
|
onClick={() => void selectMeta(m)}
|
|
>
|
|
{m.posterUrl && (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={m.posterUrl} alt="" width={40} height={60} />
|
|
)}
|
|
<span>
|
|
<strong>
|
|
{m.title}
|
|
{m.year ? ` (${m.year})` : ""}
|
|
</strong>
|
|
<small className="muted">
|
|
TMDB {m.tmdbId}
|
|
{m.imdbId ? ` · ${m.imdbId}` : ""}
|
|
</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{selectedMeta && libraryTarget === "SERIES" && (
|
|
<div className="downloads-subs">
|
|
<h3 className="card-subtitle">Seizoen / aflevering</h3>
|
|
{seasonBusy && <p className="muted">Seizoen laden…</p>}
|
|
<div className="downloads-toolbar">
|
|
<label className="downloads-field">
|
|
<span>Seizoen</span>
|
|
<select
|
|
value={seasonNumber}
|
|
onChange={(e) => void onSeasonChange(parseInt(e.target.value, 10))}
|
|
disabled={seasonBusy || seasonCount < 1}
|
|
>
|
|
{Array.from({ length: Math.max(seasonCount, 1) }, (_, i) => i + 1).map((n) => (
|
|
<option key={n} value={n}>
|
|
Seizoen {n}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
{seriesMode === "EPISODE" && (
|
|
<label className="downloads-field downloads-field-grow">
|
|
<span>Aflevering</span>
|
|
<select
|
|
value={episodeNumber}
|
|
onChange={(e) => void onEpisodeChange(parseInt(e.target.value, 10))}
|
|
disabled={seasonBusy || seasonEpisodes.length === 0}
|
|
>
|
|
{seasonEpisodes.map((ep) => (
|
|
<option key={ep.episodeNumber} value={ep.episodeNumber}>
|
|
E{String(ep.episodeNumber).padStart(2, "0")} · {ep.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
</div>
|
|
{seriesMode === "SEASON" && (
|
|
<p className="muted">
|
|
{seasonEpisodes.length} afleveringen worden verwacht. Ondertitels kies je per
|
|
episode ná import (checklist onder Voltooid).
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selectedMeta && !(libraryTarget === "SERIES" && seriesMode === "SEASON") && (
|
|
<div className="downloads-subs">
|
|
<h3 className="card-subtitle">Ondertitel (NL)</h3>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Bronnen: OpenSubtitles.com + OpenSubtitles.org
|
|
</p>
|
|
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
|
{subsMsg && <p className="muted">{subsMsg}</p>}
|
|
{!subsBusy && subsConfigured && (
|
|
<div className="match-list">
|
|
<button
|
|
type="button"
|
|
className={`match-item${selectedSubId == null ? " selected" : ""}`}
|
|
onClick={() => {
|
|
setSelectedSubId(null);
|
|
setSelectedSubSource(null);
|
|
}}
|
|
>
|
|
<span>
|
|
<strong>Geen ondertitel</strong>
|
|
<small className="muted">Alleen video importeren</small>
|
|
</span>
|
|
</button>
|
|
{subs.map((s) => (
|
|
<button
|
|
key={`${s.source || "com"}-${s.fileId}`}
|
|
type="button"
|
|
className={`match-item${
|
|
selectedSubId === s.fileId &&
|
|
(selectedSubSource || "com") === (s.source || "com")
|
|
? " selected"
|
|
: ""
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedSubId(s.fileId);
|
|
setSelectedSubSource(s.source === "org" ? "org" : "com");
|
|
}}
|
|
>
|
|
<span>
|
|
<strong>
|
|
{(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
|
|
{s.hearingImpaired ? " · HI" : ""}
|
|
{" · "}
|
|
{s.source === "org" ? "OS.org" : "OS.com"}
|
|
</strong>
|
|
<small className="muted">
|
|
{s.release || s.fileName}
|
|
{s.uploader ? ` · ${s.uploader}` : ""}
|
|
</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{jobErr && <p className="danger-text">{jobErr}</p>}
|
|
<div className="downloads-modal-actions">
|
|
<button
|
|
type="button"
|
|
disabled={jobBusy || !selectedMeta}
|
|
onClick={() => void confirmDownload()}
|
|
>
|
|
{jobBusy ? "Starten…" : "Bevestigen + downloaden"}
|
|
</button>
|
|
<button type="button" className="btn-logout" onClick={() => setPick(null)}>
|
|
Annuleren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{subPickJob && (
|
|
<div className="modal-backdrop" onClick={() => setSubPickJob(null)}>
|
|
<div className="modal-panel downloads-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="page-kicker">Ondertitel opnieuw</div>
|
|
<h2 className="downloads-modal-title">
|
|
{subPickJob.movieTitle}
|
|
{subPickJob.movieYear ? ` (${subPickJob.movieYear})` : ""}
|
|
</h2>
|
|
<p className="muted" style={{ marginTop: 0 }}>
|
|
Kies bij voorkeur <strong>OS.org</strong> — OpenSubtitles.com login faalt vaak (403 /
|
|
User-Agent).
|
|
</p>
|
|
{subsBusy && <p className="muted">OpenSubtitles zoeken…</p>}
|
|
{subsMsg && <p className="danger-text">{subsMsg}</p>}
|
|
{!subsBusy && (
|
|
<div className="match-list">
|
|
{subs.map((s) => (
|
|
<button
|
|
key={`${s.source || "com"}-${s.fileId}`}
|
|
type="button"
|
|
className={`match-item${
|
|
selectedSubId === s.fileId &&
|
|
(selectedSubSource || "com") === (s.source || "com")
|
|
? " selected"
|
|
: ""
|
|
}`}
|
|
onClick={() => {
|
|
setSelectedSubId(s.fileId);
|
|
setSelectedSubSource(s.source === "org" ? "org" : "com");
|
|
}}
|
|
>
|
|
<span>
|
|
<strong>
|
|
{(s.language || "nl").toUpperCase()} · {s.downloadCount} downloads
|
|
{s.hearingImpaired ? " · HI" : ""}
|
|
{" · "}
|
|
{s.source === "org" ? "OS.org" : "OS.com"}
|
|
</strong>
|
|
<small className="muted">
|
|
{s.release || s.fileName}
|
|
{s.uploader ? ` · ${s.uploader}` : ""}
|
|
</small>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="downloads-modal-actions">
|
|
<button
|
|
type="button"
|
|
disabled={subPickBusy || selectedSubId == null}
|
|
onClick={() => void confirmSubtitlePick()}
|
|
>
|
|
{subPickBusy
|
|
? "Schrijven…"
|
|
: selectedSubSource === "org"
|
|
? "Schrijf OS.org-ondertitel"
|
|
: "Schrijf ondertitel (.com kan falen)"}
|
|
</button>
|
|
<button type="button" className="btn-logout" onClick={() => setSubPickJob(null)}>
|
|
Annuleren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|