Add Films/4K/Series download targets and completed jobs panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
19aa18e964
commit
a4bb2c591a
8 changed files with 484 additions and 76 deletions
|
|
@ -50,6 +50,7 @@ interface JobRow {
|
|||
nodeId: string;
|
||||
nodeName: string;
|
||||
status: string;
|
||||
libraryTarget?: LibraryTarget;
|
||||
sourceTitle: string;
|
||||
movieTitle: string;
|
||||
movieYear: number | null;
|
||||
|
|
@ -64,6 +65,18 @@ interface JobRow {
|
|||
importedPath?: string | null;
|
||||
}
|
||||
|
||||
type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES";
|
||||
|
||||
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 formatBytes(n: number): string {
|
||||
if (!n || n <= 0) return "—";
|
||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
|
@ -115,6 +128,7 @@ export default function DownloadsPage() {
|
|||
const [nodeId, setNodeId] = useState("");
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K");
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [searchErr, setSearchErr] = useState<string | null>(null);
|
||||
|
|
@ -176,6 +190,15 @@ export default function DownloadsPage() {
|
|||
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);
|
||||
|
|
@ -186,7 +209,7 @@ export default function DownloadsPage() {
|
|||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ nodeId, q: query.trim() }),
|
||||
body: JSON.stringify({ nodeId, q: query.trim(), libraryTarget }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
|
|
@ -293,6 +316,7 @@ export default function DownloadsPage() {
|
|||
nodeId,
|
||||
uri: pick.uri,
|
||||
sourceTitle: pick.title,
|
||||
libraryTarget,
|
||||
tmdbId: selectedMeta.tmdbId,
|
||||
imdbId: selectedMeta.imdbId ?? undefined,
|
||||
movieTitle: selectedMeta.title,
|
||||
|
|
@ -329,6 +353,35 @@ export default function DownloadsPage() {
|
|||
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) {
|
||||
setSubPickJob(job);
|
||||
setSubs([]);
|
||||
|
|
@ -414,7 +467,7 @@ export default function DownloadsPage() {
|
|||
<div className="container downloads-page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-kicker">Synology · 4K</div>
|
||||
<div className="page-kicker">Synology · bibliotheek</div>
|
||||
<h1>Downloads</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -432,6 +485,25 @@ export default function DownloadsPage() {
|
|||
|
||||
<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>
|
||||
<div className="downloads-toolbar">
|
||||
<label className="downloads-field">
|
||||
<span>Node</span>
|
||||
|
|
@ -453,7 +525,13 @@ export default function DownloadsPage() {
|
|||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Film + 2160p / UHD…"
|
||||
placeholder={
|
||||
libraryTarget === "FOUR_K"
|
||||
? "Filmnaam…"
|
||||
: libraryTarget === "FILMS"
|
||||
? "Filmnaam…"
|
||||
: "Serienaam…"
|
||||
}
|
||||
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -532,12 +610,14 @@ export default function DownloadsPage() {
|
|||
</div>
|
||||
<div className="card">
|
||||
<div className="list-cards">
|
||||
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>}
|
||||
{jobs.map((j) => (
|
||||
{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">{j.nodeName}</span>
|
||||
<span className="muted">
|
||||
{targetLabel(j.libraryTarget)} · {j.nodeName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="list-card-title">
|
||||
{j.movieTitle}
|
||||
|
|
@ -559,11 +639,6 @@ export default function DownloadsPage() {
|
|||
Opnieuw importeren
|
||||
</button>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
|
||||
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
||||
Ondertitel kiezen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
|
@ -573,26 +648,23 @@ export default function DownloadsPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Film</th>
|
||||
<th>Doel</th>
|
||||
<th>Titel</th>
|
||||
<th>Bron</th>
|
||||
<th>Node</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((j) => (
|
||||
{activeJobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td>
|
||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||
</td>
|
||||
<td className="muted">{targetLabel(j.libraryTarget)}</td>
|
||||
<td>
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
{j.subtitlePath && (
|
||||
<div className="ok-text">
|
||||
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
|
||||
</div>
|
||||
)}
|
||||
{j.hasSubtitle && !j.subtitlePath && (
|
||||
<div className="danger-text">
|
||||
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
|
||||
|
|
@ -608,18 +680,119 @@ export default function DownloadsPage() {
|
|||
Retry
|
||||
</button>
|
||||
)}
|
||||
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{activeJobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} 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">
|
||||
{targetLabel(j.libraryTarget)} · {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.subtitlePath && (
|
||||
<div className="ok-text">Ondertitel: {j.subtitlePath.split(/[/\\]/).pop()}</div>
|
||||
)}
|
||||
{j.hasSubtitle && !j.subtitlePath && (
|
||||
<div className="danger-text">
|
||||
{j.subtitleError || j.error || "Ondertitel ontbreekt in map"}
|
||||
</div>
|
||||
)}
|
||||
<div className="action-row">
|
||||
{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">{targetLabel(j.libraryTarget)}</td>
|
||||
<td>
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
{j.subtitlePath && (
|
||||
<div className="ok-text">
|
||||
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
|
||||
</div>
|
||||
)}
|
||||
{j.hasSubtitle && !j.subtitlePath && (
|
||||
<div className="danger-text">
|
||||
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="muted">{j.sourceTitle}</td>
|
||||
<td>{j.nodeName}</td>
|
||||
<td className="downloads-job-actions">
|
||||
{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>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
{completedJobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="muted">
|
||||
Nog geen downloads.
|
||||
<td colSpan={6} className="muted">
|
||||
Nog geen voltooide jobs.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ a:hover {
|
|||
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
|
||||
}
|
||||
|
||||
/* ——— Nav ——— */
|
||||
/* ??? Nav ??? */
|
||||
.topnav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
|
|
@ -371,7 +371,7 @@ a:hover {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* ——— Tables ——— */
|
||||
/* ??? Tables ??? */
|
||||
.table-wrap,
|
||||
.card > table {
|
||||
width: 100%;
|
||||
|
|
@ -412,7 +412,7 @@ td {
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ——— Status ——— */
|
||||
/* ??? Status ??? */
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -454,7 +454,7 @@ td {
|
|||
border-color: rgba(244, 63, 94, 0.3);
|
||||
}
|
||||
|
||||
/* ——— Buttons ——— */
|
||||
/* ??? Buttons ??? */
|
||||
button,
|
||||
.btn {
|
||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08));
|
||||
|
|
@ -504,7 +504,7 @@ button.ghost {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ——— Forms ——— */
|
||||
/* ??? Forms ??? */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
|
|
@ -547,7 +547,7 @@ input::placeholder {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ——— Login ——— */
|
||||
/* ??? Login ??? */
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -686,7 +686,7 @@ pre.token-display {
|
|||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
|
||||
}
|
||||
|
||||
/* ——— Layout sections & mobile cards ——— */
|
||||
/* ??? Layout sections & mobile cards ??? */
|
||||
.stack-section {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
|
@ -1329,7 +1329,7 @@ tr.row-selected {
|
|||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ——— Settings tabs ——— */
|
||||
/* ??? Settings tabs ??? */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
|
|
@ -1370,11 +1370,54 @@ tr.row-selected {
|
|||
}
|
||||
}
|
||||
|
||||
/* ——— Downloads (mobile-first) ——— */
|
||||
/* ??? Downloads (mobile-first) ??? */
|
||||
.downloads-banner p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.downloads-target-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.downloads-target {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
text-align: left;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
min-height: 52px;
|
||||
}
|
||||
.downloads-target strong {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.downloads-target .muted {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.downloads-target.on {
|
||||
border-color: var(--accent, #3b82f6);
|
||||
box-shadow: inset 0 0 0 1px var(--accent, #3b82f6);
|
||||
background: var(--bg);
|
||||
}
|
||||
.downloads-completed-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.downloads-job-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.downloads-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -1453,6 +1496,9 @@ tr.row-selected {
|
|||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.downloads-target-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.downloads-toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
|
@ -1484,7 +1530,7 @@ tr.row-selected {
|
|||
}
|
||||
}
|
||||
|
||||
/* <EFBFBD><EFBFBD> Public legal pages (privacy, etc.) <20><> */
|
||||
/* ?? Public legal pages (privacy, etc.) ?? */
|
||||
.legal-page {
|
||||
min-height: 100dvh;
|
||||
padding: 2rem 1rem 3rem;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@ interface DsConfig {
|
|||
username: string;
|
||||
downloadDestination: string;
|
||||
downloadHostPath: string;
|
||||
filmsHostPath: string;
|
||||
fourKHostPath: string;
|
||||
seriesHostPath: string;
|
||||
enabled: boolean;
|
||||
lastOkAt?: string | null;
|
||||
lastError?: string | null;
|
||||
|
|
@ -66,7 +68,9 @@ const emptyConfig = (nodeId = ""): DsConfig => ({
|
|||
username: "",
|
||||
downloadDestination: "Downloads",
|
||||
downloadHostPath: "/volume1/Downloads",
|
||||
filmsHostPath: "/volume1/Media/Films/Films",
|
||||
fourKHostPath: "/volume1/Media/Films/4K",
|
||||
seriesHostPath: "/volume1/Media/Series/Series",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
|
|
@ -211,7 +215,7 @@ export default function SettingsPage() {
|
|||
useEffect(() => {
|
||||
if (!nodeId) return;
|
||||
const existing = configs.find((c) => c.nodeId === nodeId);
|
||||
setCfg(existing ? { ...existing } : emptyConfig(nodeId));
|
||||
setCfg(existing ? { ...emptyConfig(nodeId), ...existing } : emptyConfig(nodeId));
|
||||
setPassword("");
|
||||
setCfgMsg(null);
|
||||
setCfgErr(null);
|
||||
|
|
@ -651,8 +655,8 @@ export default function SettingsPage() {
|
|||
<div className="section-head">
|
||||
<h2 className="section-title">Download Station</h2>
|
||||
<p className="muted">
|
||||
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "}
|
||||
<Link href="/downloads">Downloads</Link>.
|
||||
Synology DS-login, staging-pad en bibliotheekdoelen (Films / 4K / Series). Zoeken
|
||||
doe je onder <Link href="/downloads">Downloads</Link>.
|
||||
</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
|
|
@ -702,13 +706,21 @@ export default function SettingsPage() {
|
|||
/>
|
||||
</label>
|
||||
<label>
|
||||
Download host-pad
|
||||
Download host-pad (staging)
|
||||
<input
|
||||
value={cfg.downloadHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
|
||||
placeholder="/volume1/Downloads"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Films 1080 host-pad
|
||||
<input
|
||||
value={cfg.filmsHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, filmsHostPath: e.target.value })}
|
||||
placeholder="/volume1/Media/Films/Films"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
4K bibliotheek host-pad
|
||||
<input
|
||||
|
|
@ -717,6 +729,14 @@ export default function SettingsPage() {
|
|||
placeholder="/volume1/Media/Films/4K"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Series host-pad
|
||||
<input
|
||||
value={cfg.seriesHostPath}
|
||||
onChange={(e) => setCfg({ ...cfg, seriesHostPath: e.target.value })}
|
||||
placeholder="/volume1/Media/Series/Series"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||
<button type="button" disabled={cfgBusy || !nodeId} onClick={() => void saveConfig()}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "films_host_path" TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "series_host_path" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- CreateEnum
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "DownloadLibraryTarget" AS ENUM ('FILMS', 'FOUR_K', 'SERIES');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K';
|
||||
|
|
@ -564,7 +564,7 @@ model PlaybackSession {
|
|||
@@map("playback_sessions")
|
||||
}
|
||||
|
||||
/** Synology Download Station credentials + fixed 4K import paths (per node). */
|
||||
/** Synology Download Station credentials + library import paths (per node). */
|
||||
model SynologyDsConfig {
|
||||
id String @id @default(uuid())
|
||||
nodeId String @unique @map("node_id")
|
||||
|
|
@ -576,8 +576,12 @@ model SynologyDsConfig {
|
|||
downloadDestination String @map("download_destination")
|
||||
/** Absolute host path of that destination on the media-node */
|
||||
downloadHostPath String @map("download_host_path")
|
||||
/** Absolute host path for 4K movies library folder */
|
||||
/** Absolute host path for 1080p movies (e.g. …/Media/Films/Films) */
|
||||
filmsHostPath String @default("") @map("films_host_path")
|
||||
/** Absolute host path for 4K movies (e.g. …/Media/Films/4K) */
|
||||
fourKHostPath String @map("four_k_host_path")
|
||||
/** Absolute host path for series (e.g. …/Media/Series/Series) */
|
||||
seriesHostPath String @default("") @map("series_host_path")
|
||||
enabled Boolean @default(true)
|
||||
lastOkAt DateTime? @map("last_ok_at")
|
||||
lastError String? @map("last_error")
|
||||
|
|
@ -589,10 +593,18 @@ model SynologyDsConfig {
|
|||
@@map("synology_ds_configs")
|
||||
}
|
||||
|
||||
enum DownloadLibraryTarget {
|
||||
FILMS
|
||||
FOUR_K
|
||||
SERIES
|
||||
}
|
||||
|
||||
model DownloadJob {
|
||||
id String @id @default(uuid())
|
||||
nodeId String @map("node_id")
|
||||
status DownloadJobStatus @default(DOWNLOADING)
|
||||
/** Where to import after DS finishes */
|
||||
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
|
||||
sourceUri String @map("source_uri")
|
||||
sourceTitle String @map("source_title")
|
||||
dsTaskId String? @map("ds_task_id")
|
||||
|
|
|
|||
|
|
@ -303,6 +303,20 @@ async function main() {
|
|||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT`
|
||||
);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "films_host_path" TEXT NOT NULL DEFAULT ''
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE "synology_ds_configs" ADD COLUMN IF NOT EXISTS "series_host_path" TEXT NOT NULL DEFAULT ''
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "DownloadLibraryTarget" AS ENUM ('FILMS', 'FOUR_K', 'SERIES');
|
||||
EXCEPTION WHEN duplicate_object THEN null; END $$;
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "library_target" "DownloadLibraryTarget" NOT NULL DEFAULT 'FOUR_K'
|
||||
`);
|
||||
await prisma.$executeRawUnsafe(`
|
||||
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,34 @@ import { requireAdmin } from "../auth/routes";
|
|||
import type { Config } from "../config";
|
||||
import { MetadataService } from "../metadata/service";
|
||||
import { AppError } from "../security/errors";
|
||||
import { DownloadService, startDownloadPoller } from "./service";
|
||||
import { DownloadService, isLibraryTarget, startDownloadPoller, type LibraryTarget } from "./service";
|
||||
|
||||
function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
|
||||
return {
|
||||
id: j.id,
|
||||
nodeId: j.nodeId,
|
||||
nodeName: j.node.name,
|
||||
status: j.status,
|
||||
libraryTarget: j.libraryTarget,
|
||||
sourceTitle: j.sourceTitle,
|
||||
movieTitle: j.movieTitle,
|
||||
movieYear: j.movieYear,
|
||||
posterUrl: j.posterUrl,
|
||||
tmdbId: j.tmdbId,
|
||||
imdbId: j.imdbId,
|
||||
destFolderName: j.destFolderName,
|
||||
releaseTags: j.releaseTags,
|
||||
subtitleLang: j.subtitleLang,
|
||||
subtitleSource: j.subtitleSource,
|
||||
subtitlePath: j.subtitlePath,
|
||||
subtitleError: j.subtitleError,
|
||||
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
|
||||
importedPath: j.importedPath,
|
||||
error: j.error,
|
||||
createdAt: j.createdAt,
|
||||
updatedAt: j.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
|
||||
const metadata = new MetadataService(config.TMDB_API_KEY);
|
||||
|
|
@ -23,7 +50,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
password?: string;
|
||||
downloadDestination?: string;
|
||||
downloadHostPath?: string;
|
||||
filmsHostPath?: string;
|
||||
fourKHostPath?: string;
|
||||
seriesHostPath?: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
if (!body.nodeId || !body.baseUrl || !body.username) {
|
||||
|
|
@ -36,7 +65,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
password: body.password,
|
||||
downloadDestination: body.downloadDestination ?? "",
|
||||
downloadHostPath: body.downloadHostPath ?? "",
|
||||
filmsHostPath: body.filmsHostPath ?? "",
|
||||
fourKHostPath: body.fourKHostPath ?? "",
|
||||
seriesHostPath: body.seriesHostPath ?? "",
|
||||
enabled: body.enabled,
|
||||
});
|
||||
return { config: row };
|
||||
|
|
@ -49,12 +80,15 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
});
|
||||
|
||||
app.post("/api/v1/admin/downloads/search", { preHandler: requireAdmin }, async (request) => {
|
||||
const body = request.body as { nodeId?: string; q?: string };
|
||||
const body = request.body as { nodeId?: string; q?: string; libraryTarget?: string };
|
||||
if (!body.nodeId || !body.q?.trim()) {
|
||||
throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400);
|
||||
}
|
||||
const results = await downloads.search(body.nodeId, body.q);
|
||||
return { results };
|
||||
const target: LibraryTarget = isLibraryTarget(body.libraryTarget)
|
||||
? body.libraryTarget
|
||||
: "FOUR_K";
|
||||
const results = await downloads.search(body.nodeId, body.q, target);
|
||||
return { results, libraryTarget: target };
|
||||
});
|
||||
|
||||
app.get("/api/v1/admin/downloads/subtitles", { preHandler: requireAdmin }, async (request) => {
|
||||
|
|
@ -84,33 +118,18 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
|
||||
app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
|
||||
const q = request.query as { limit?: string };
|
||||
const limit = q.limit ? parseInt(q.limit, 10) : 50;
|
||||
const limit = q.limit ? parseInt(q.limit, 10) : 80;
|
||||
const jobs = await downloads.listJobs(limit);
|
||||
return {
|
||||
jobs: jobs.map((j) => ({
|
||||
id: j.id,
|
||||
nodeId: j.nodeId,
|
||||
nodeName: j.node.name,
|
||||
status: j.status,
|
||||
sourceTitle: j.sourceTitle,
|
||||
movieTitle: j.movieTitle,
|
||||
movieYear: j.movieYear,
|
||||
posterUrl: j.posterUrl,
|
||||
tmdbId: j.tmdbId,
|
||||
imdbId: j.imdbId,
|
||||
destFolderName: j.destFolderName,
|
||||
releaseTags: j.releaseTags,
|
||||
subtitleLang: j.subtitleLang,
|
||||
subtitleSource: j.subtitleSource,
|
||||
subtitlePath: j.subtitlePath,
|
||||
subtitleError: j.subtitleError,
|
||||
hasSubtitle: !!(j.subtitleFileId || j.subtitleContent),
|
||||
importedPath: j.importedPath,
|
||||
error: j.error,
|
||||
createdAt: j.createdAt,
|
||||
updatedAt: j.updatedAt,
|
||||
})),
|
||||
};
|
||||
return { jobs: jobs.map(mapJob) };
|
||||
});
|
||||
|
||||
app.delete("/api/v1/admin/downloads/jobs/completed", { preHandler: requireAdmin }, async () => {
|
||||
return downloads.clearCompletedJobs();
|
||||
});
|
||||
|
||||
app.delete("/api/v1/admin/downloads/jobs/:id", { preHandler: requireAdmin }, async (request) => {
|
||||
const { id } = request.params as { id: string };
|
||||
return downloads.deleteJob(id);
|
||||
});
|
||||
|
||||
app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
|
||||
|
|
@ -118,6 +137,7 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
nodeId?: string;
|
||||
uri?: string;
|
||||
sourceTitle?: string;
|
||||
libraryTarget?: string;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
movieTitle?: string;
|
||||
|
|
@ -130,10 +150,14 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
|||
if (!body.nodeId || !body.uri || !body.movieTitle) {
|
||||
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
|
||||
}
|
||||
if (!isLibraryTarget(body.libraryTarget)) {
|
||||
throw new AppError("INVALID_REQUEST", "libraryTarget verplicht (FILMS, FOUR_K of SERIES)", 400);
|
||||
}
|
||||
const job = await downloads.createJob({
|
||||
nodeId: body.nodeId,
|
||||
uri: body.uri,
|
||||
sourceTitle: body.sourceTitle || body.movieTitle,
|
||||
libraryTarget: body.libraryTarget,
|
||||
tmdbId: body.tmdbId,
|
||||
imdbId: body.imdbId,
|
||||
movieTitle: body.movieTitle,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ import {
|
|||
} from "../opensubtitles/client";
|
||||
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
|
||||
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
|
||||
import type { DownloadLibraryTarget, SynologyDsConfig } from "@prisma/client";
|
||||
|
||||
export type LibraryTarget = DownloadLibraryTarget;
|
||||
|
||||
const LIBRARY_TARGETS: LibraryTarget[] = ["FILMS", "FOUR_K", "SERIES"];
|
||||
|
||||
export function isLibraryTarget(v: unknown): v is LibraryTarget {
|
||||
return typeof v === "string" && LIBRARY_TARGETS.includes(v as LibraryTarget);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(raw: string): string {
|
||||
let u = raw.trim().replace(/\/+$/, "");
|
||||
|
|
@ -30,6 +39,42 @@ function normalizeHostPath(p: string): string {
|
|||
return p.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Append quality hint only when the user did not already specify one. */
|
||||
export function searchQueryForTarget(query: string, target: LibraryTarget): string {
|
||||
const q = query.trim();
|
||||
if (!q) return q;
|
||||
if (/\b(2160p|1080p|720p|480p|4k|uhd)\b/i.test(q)) return q;
|
||||
if (target === "FOUR_K") return `${q} 2160p`;
|
||||
if (target === "FILMS") return `${q} 1080p`;
|
||||
return q;
|
||||
}
|
||||
|
||||
function qualityHintForTarget(target: LibraryTarget): string {
|
||||
if (target === "FOUR_K") return "2160p";
|
||||
if (target === "FILMS") return "1080p";
|
||||
return "1080p";
|
||||
}
|
||||
|
||||
function libraryHostPath(config: SynologyDsConfig, target: LibraryTarget): string {
|
||||
if (target === "FILMS") return normalizeHostPath(config.filmsHostPath || "");
|
||||
if (target === "SERIES") return normalizeHostPath(config.seriesHostPath || "");
|
||||
return normalizeHostPath(config.fourKHostPath);
|
||||
}
|
||||
|
||||
function requireLibraryPath(config: SynologyDsConfig, target: LibraryTarget): string {
|
||||
const path = libraryHostPath(config, target);
|
||||
if (!path) {
|
||||
const label =
|
||||
target === "FILMS" ? "Films (1080)" : target === "SERIES" ? "Series" : "4K";
|
||||
throw new AppError(
|
||||
"NOT_CONFIGURED",
|
||||
`Bibliotheekpad voor ${label} ontbreekt — vul dit in onder Instellingen → Download Station`,
|
||||
400
|
||||
);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export type DsConfigInput = {
|
||||
nodeId: string;
|
||||
baseUrl: string;
|
||||
|
|
@ -37,7 +82,9 @@ export type DsConfigInput = {
|
|||
password?: string;
|
||||
downloadDestination: string;
|
||||
downloadHostPath: string;
|
||||
filmsHostPath: string;
|
||||
fourKHostPath: string;
|
||||
seriesHostPath: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -269,7 +316,9 @@ export class DownloadService {
|
|||
hasPassword: true,
|
||||
downloadDestination: r.downloadDestination,
|
||||
downloadHostPath: r.downloadHostPath,
|
||||
filmsHostPath: r.filmsHostPath,
|
||||
fourKHostPath: r.fourKHostPath,
|
||||
seriesHostPath: r.seriesHostPath,
|
||||
enabled: r.enabled,
|
||||
lastOkAt: r.lastOkAt,
|
||||
lastError: r.lastError,
|
||||
|
|
@ -289,9 +338,22 @@ export class DownloadService {
|
|||
const username = input.username.trim();
|
||||
const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
const downloadHostPath = normalizeHostPath(input.downloadHostPath);
|
||||
const filmsHostPath = normalizeHostPath(input.filmsHostPath);
|
||||
const fourKHostPath = normalizeHostPath(input.fourKHostPath);
|
||||
if (!username || !downloadDestination || !downloadHostPath || !fourKHostPath) {
|
||||
throw new AppError("INVALID_REQUEST", "Vul alle verplichte velden in", 400);
|
||||
const seriesHostPath = normalizeHostPath(input.seriesHostPath);
|
||||
if (
|
||||
!username ||
|
||||
!downloadDestination ||
|
||||
!downloadHostPath ||
|
||||
!filmsHostPath ||
|
||||
!fourKHostPath ||
|
||||
!seriesHostPath
|
||||
) {
|
||||
throw new AppError(
|
||||
"INVALID_REQUEST",
|
||||
"Vul alle verplichte velden in (inclusief Films-, 4K- en Series-pad)",
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
const passwordEnc = input.password?.trim()
|
||||
|
|
@ -316,7 +378,9 @@ export class DownloadService {
|
|||
passwordEnc,
|
||||
downloadDestination,
|
||||
downloadHostPath,
|
||||
filmsHostPath,
|
||||
fourKHostPath,
|
||||
seriesHostPath,
|
||||
enabled: input.enabled ?? true,
|
||||
lastOkAt: new Date(),
|
||||
lastError: null,
|
||||
|
|
@ -327,7 +391,9 @@ export class DownloadService {
|
|||
passwordEnc,
|
||||
downloadDestination,
|
||||
downloadHostPath,
|
||||
filmsHostPath,
|
||||
fourKHostPath,
|
||||
seriesHostPath,
|
||||
enabled: input.enabled ?? true,
|
||||
lastOkAt: new Date(),
|
||||
lastError: null,
|
||||
|
|
@ -341,7 +407,9 @@ export class DownloadService {
|
|||
username: row.username,
|
||||
downloadDestination: row.downloadDestination,
|
||||
downloadHostPath: row.downloadHostPath,
|
||||
filmsHostPath: row.filmsHostPath,
|
||||
fourKHostPath: row.fourKHostPath,
|
||||
seriesHostPath: row.seriesHostPath,
|
||||
enabled: row.enabled,
|
||||
lastOkAt: row.lastOkAt,
|
||||
};
|
||||
|
|
@ -357,7 +425,11 @@ export class DownloadService {
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
async search(nodeId: string, query: string): Promise<
|
||||
async search(
|
||||
nodeId: string,
|
||||
query: string,
|
||||
target: LibraryTarget = "FOUR_K"
|
||||
): Promise<
|
||||
Array<{
|
||||
title: string;
|
||||
size: number;
|
||||
|
|
@ -368,10 +440,12 @@ export class DownloadService {
|
|||
suggestQuery: string;
|
||||
}>
|
||||
> {
|
||||
const { client } = await this.clientForNode(nodeId);
|
||||
const { client, config } = await this.clientForNode(nodeId);
|
||||
requireLibraryPath(config, target);
|
||||
const searchQ = searchQueryForTarget(query, target);
|
||||
let hits: DsSearchHit[];
|
||||
try {
|
||||
hits = await client.search(query);
|
||||
hits = await client.search(searchQ);
|
||||
await prisma.synologyDsConfig.update({
|
||||
where: { nodeId },
|
||||
data: { lastOkAt: new Date(), lastError: null },
|
||||
|
|
@ -403,6 +477,7 @@ export class DownloadService {
|
|||
nodeId: string;
|
||||
uri: string;
|
||||
sourceTitle: string;
|
||||
libraryTarget: LibraryTarget;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
movieTitle: string;
|
||||
|
|
@ -420,8 +495,12 @@ export class DownloadService {
|
|||
if (!input.tmdbId && !input.imdbId) {
|
||||
throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400);
|
||||
}
|
||||
if (!isLibraryTarget(input.libraryTarget)) {
|
||||
throw new AppError("INVALID_REQUEST", "Ongeldig bibliotheekdoel", 400);
|
||||
}
|
||||
|
||||
const { client, config } = await this.clientForNode(input.nodeId);
|
||||
requireLibraryPath(config, input.libraryTarget);
|
||||
|
||||
let tmdbId = input.tmdbId;
|
||||
let imdbId = input.imdbId?.trim() || null;
|
||||
|
|
@ -535,6 +614,7 @@ export class DownloadService {
|
|||
data: {
|
||||
nodeId: input.nodeId,
|
||||
status: "DOWNLOADING",
|
||||
libraryTarget: input.libraryTarget,
|
||||
sourceUri: uri,
|
||||
sourceTitle: input.sourceTitle.trim() || title,
|
||||
dsTaskId,
|
||||
|
|
@ -583,12 +663,29 @@ export class DownloadService {
|
|||
|
||||
async listJobs(limit = 50) {
|
||||
return prisma.downloadJob.findMany({
|
||||
take: Math.min(100, Math.max(1, limit)),
|
||||
take: Math.min(200, Math.max(1, limit)),
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: { node: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteJob(jobId: string) {
|
||||
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
|
||||
if (job.status !== "IMPORTED") {
|
||||
throw new AppError("INVALID_REQUEST", "Alleen voltooide jobs kunnen uit de lijst", 400);
|
||||
}
|
||||
await prisma.downloadJob.delete({ where: { id: jobId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async clearCompletedJobs() {
|
||||
const result = await prisma.downloadJob.deleteMany({
|
||||
where: { status: "IMPORTED" },
|
||||
});
|
||||
return { deleted: result.count };
|
||||
}
|
||||
|
||||
async retryImport(jobId: string) {
|
||||
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
|
||||
|
|
@ -694,7 +791,18 @@ export class DownloadService {
|
|||
}
|
||||
|
||||
const destFolder = job.destFolderName || folderNameForMovie(job.movieTitle, job.movieYear);
|
||||
const destDir = `${normalizeHostPath(config.fourKHostPath)}/${destFolder}`;
|
||||
let destRoot: string;
|
||||
try {
|
||||
destRoot = requireLibraryPath(config, job.libraryTarget);
|
||||
} catch (err) {
|
||||
const message = err instanceof AppError ? err.message : "Bibliotheekpad ontbreekt";
|
||||
await prisma.downloadJob.update({
|
||||
where: { id: jobId },
|
||||
data: { status: "FAILED", error: message },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const destDir = `${destRoot}/${destFolder}`;
|
||||
const sourcePath =
|
||||
job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`;
|
||||
|
||||
|
|
@ -760,7 +868,7 @@ export class DownloadService {
|
|||
movieTitle: job.movieTitle,
|
||||
movieYear: job.movieYear,
|
||||
releaseTags,
|
||||
qualityHint: "2160p",
|
||||
qualityHint: qualityHintForTarget(job.libraryTarget),
|
||||
subtitle,
|
||||
},
|
||||
120_000
|
||||
|
|
|
|||
Loading…
Reference in a new issue