stremio/apps/admin-ui/src/app/downloads/page.tsx
Jos Vooges | STH c75bfc4a97 Only warn missing subtitle after import, not while downloading.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 02:34:40 +02:00

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>
)}
</>
);
}