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;
|
nodeId: string;
|
||||||
nodeName: string;
|
nodeName: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
libraryTarget?: LibraryTarget;
|
||||||
sourceTitle: string;
|
sourceTitle: string;
|
||||||
movieTitle: string;
|
movieTitle: string;
|
||||||
movieYear: number | null;
|
movieYear: number | null;
|
||||||
|
|
@ -64,6 +65,18 @@ interface JobRow {
|
||||||
importedPath?: string | null;
|
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 {
|
function formatBytes(n: number): string {
|
||||||
if (!n || n <= 0) return "—";
|
if (!n || n <= 0) return "—";
|
||||||
const u = ["B", "KB", "MB", "GB", "TB"];
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
|
@ -115,6 +128,7 @@ export default function DownloadsPage() {
|
||||||
const [nodeId, setNodeId] = useState("");
|
const [nodeId, setNodeId] = useState("");
|
||||||
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
const [libraryTarget, setLibraryTarget] = useState<LibraryTarget>("FOUR_K");
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||||
const [searchErr, setSearchErr] = useState<string | null>(null);
|
const [searchErr, setSearchErr] = useState<string | null>(null);
|
||||||
|
|
@ -176,6 +190,15 @@ export default function DownloadsPage() {
|
||||||
return nodes.filter((n) => ids.has(n.id));
|
return nodes.filter((n) => ids.has(n.id));
|
||||||
}, [nodes, configs]);
|
}, [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() {
|
async function runSearch() {
|
||||||
if (!query.trim() || !nodeId) return;
|
if (!query.trim() || !nodeId) return;
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
|
|
@ -186,7 +209,7 @@ export default function DownloadsPage() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "Content-Type": "application/json" },
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|
@ -293,6 +316,7 @@ export default function DownloadsPage() {
|
||||||
nodeId,
|
nodeId,
|
||||||
uri: pick.uri,
|
uri: pick.uri,
|
||||||
sourceTitle: pick.title,
|
sourceTitle: pick.title,
|
||||||
|
libraryTarget,
|
||||||
tmdbId: selectedMeta.tmdbId,
|
tmdbId: selectedMeta.tmdbId,
|
||||||
imdbId: selectedMeta.imdbId ?? undefined,
|
imdbId: selectedMeta.imdbId ?? undefined,
|
||||||
movieTitle: selectedMeta.title,
|
movieTitle: selectedMeta.title,
|
||||||
|
|
@ -329,6 +353,35 @@ export default function DownloadsPage() {
|
||||||
loadJobs();
|
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) {
|
async function openSubtitlePicker(job: JobRow) {
|
||||||
setSubPickJob(job);
|
setSubPickJob(job);
|
||||||
setSubs([]);
|
setSubs([]);
|
||||||
|
|
@ -414,7 +467,7 @@ export default function DownloadsPage() {
|
||||||
<div className="container downloads-page">
|
<div className="container downloads-page">
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<div>
|
<div>
|
||||||
<div className="page-kicker">Synology · 4K</div>
|
<div className="page-kicker">Synology · bibliotheek</div>
|
||||||
<h1>Downloads</h1>
|
<h1>Downloads</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -432,6 +485,25 @@ export default function DownloadsPage() {
|
||||||
|
|
||||||
<section className="stack-section">
|
<section className="stack-section">
|
||||||
<div className="card downloads-search-card">
|
<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">
|
<div className="downloads-toolbar">
|
||||||
<label className="downloads-field">
|
<label className="downloads-field">
|
||||||
<span>Node</span>
|
<span>Node</span>
|
||||||
|
|
@ -453,7 +525,13 @@ export default function DownloadsPage() {
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
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()}
|
onKeyDown={(e) => e.key === "Enter" && void runSearch()}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -532,12 +610,14 @@ export default function DownloadsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="list-cards">
|
<div className="list-cards">
|
||||||
{jobs.length === 0 && <p className="muted">Nog geen downloads.</p>}
|
{activeJobs.length === 0 && <p className="muted">Geen actieve downloads.</p>}
|
||||||
{jobs.map((j) => (
|
{activeJobs.map((j) => (
|
||||||
<article key={j.id} className="list-card">
|
<article key={j.id} className="list-card">
|
||||||
<div className="list-card-top">
|
<div className="list-card-top">
|
||||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
<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>
|
||||||
<div className="list-card-title">
|
<div className="list-card-title">
|
||||||
{j.movieTitle}
|
{j.movieTitle}
|
||||||
|
|
@ -559,11 +639,6 @@ export default function DownloadsPage() {
|
||||||
Opnieuw importeren
|
Opnieuw importeren
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{j.status === "IMPORTED" && j.importedPath && !j.subtitlePath && (
|
|
||||||
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
|
||||||
Ondertitel kiezen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
|
|
@ -573,26 +648,23 @@ export default function DownloadsPage() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Film</th>
|
<th>Doel</th>
|
||||||
|
<th>Titel</th>
|
||||||
<th>Bron</th>
|
<th>Bron</th>
|
||||||
<th>Node</th>
|
<th>Node</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{jobs.map((j) => (
|
{activeJobs.map((j) => (
|
||||||
<tr key={j.id}>
|
<tr key={j.id}>
|
||||||
<td>
|
<td>
|
||||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="muted">{targetLabel(j.libraryTarget)}</td>
|
||||||
<td>
|
<td>
|
||||||
{j.movieTitle}
|
{j.movieTitle}
|
||||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||||
{j.subtitlePath && (
|
|
||||||
<div className="ok-text">
|
|
||||||
Sub: {j.subtitlePath.split(/[/\\]/).pop()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{j.hasSubtitle && !j.subtitlePath && (
|
{j.hasSubtitle && !j.subtitlePath && (
|
||||||
<div className="danger-text">
|
<div className="danger-text">
|
||||||
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
|
{j.subtitleError || j.error || "Ondertitel ontbreekt"}
|
||||||
|
|
@ -608,18 +680,119 @@ export default function DownloadsPage() {
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</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)}>
|
<button type="button" onClick={() => void openSubtitlePicker(j)}>
|
||||||
Sub kiezen
|
Sub kiezen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button type="button" className="btn-logout" onClick={() => void deleteCompletedJob(j.id)}>
|
||||||
|
Verwijderen
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{jobs.length === 0 && (
|
{completedJobs.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="muted">
|
<td colSpan={6} className="muted">
|
||||||
Nog geen downloads.
|
Nog geen voltooide jobs.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ a:hover {
|
||||||
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
|
text-shadow: 0 0 24px rgba(34, 211, 238, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Nav ——— */
|
/* ??? Nav ??? */
|
||||||
.topnav {
|
.topnav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -371,7 +371,7 @@ a:hover {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Tables ——— */
|
/* ??? Tables ??? */
|
||||||
.table-wrap,
|
.table-wrap,
|
||||||
.card > table {
|
.card > table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -412,7 +412,7 @@ td {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Status ——— */
|
/* ??? Status ??? */
|
||||||
.status {
|
.status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -454,7 +454,7 @@ td {
|
||||||
border-color: rgba(244, 63, 94, 0.3);
|
border-color: rgba(244, 63, 94, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Buttons ——— */
|
/* ??? Buttons ??? */
|
||||||
button,
|
button,
|
||||||
.btn {
|
.btn {
|
||||||
background: linear-gradient(180deg, rgba(34, 211, 238, 0.22), rgba(34, 211, 238, 0.08));
|
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);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Forms ——— */
|
/* ??? Forms ??? */
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
|
|
@ -547,7 +547,7 @@ input::placeholder {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Login ——— */
|
/* ??? Login ??? */
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -686,7 +686,7 @@ pre.token-display {
|
||||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
|
box-shadow: 0 0 10px rgba(34, 211, 238, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Layout sections & mobile cards ——— */
|
/* ??? Layout sections & mobile cards ??? */
|
||||||
.stack-section {
|
.stack-section {
|
||||||
margin-bottom: 1.75rem;
|
margin-bottom: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
@ -1329,7 +1329,7 @@ tr.row-selected {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Settings tabs ——— */
|
/* ??? Settings tabs ??? */
|
||||||
.settings-tabs {
|
.settings-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|
@ -1370,11 +1370,54 @@ tr.row-selected {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ——— Downloads (mobile-first) ——— */
|
/* ??? Downloads (mobile-first) ??? */
|
||||||
.downloads-banner p {
|
.downloads-banner p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
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 {
|
.downloads-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -1453,6 +1496,9 @@ tr.row-selected {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.downloads-target-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.downloads-toolbar {
|
.downloads-toolbar {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
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 {
|
.legal-page {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
padding: 2rem 1rem 3rem;
|
padding: 2rem 1rem 3rem;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,9 @@ interface DsConfig {
|
||||||
username: string;
|
username: string;
|
||||||
downloadDestination: string;
|
downloadDestination: string;
|
||||||
downloadHostPath: string;
|
downloadHostPath: string;
|
||||||
|
filmsHostPath: string;
|
||||||
fourKHostPath: string;
|
fourKHostPath: string;
|
||||||
|
seriesHostPath: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
lastOkAt?: string | null;
|
lastOkAt?: string | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
|
|
@ -66,7 +68,9 @@ const emptyConfig = (nodeId = ""): DsConfig => ({
|
||||||
username: "",
|
username: "",
|
||||||
downloadDestination: "Downloads",
|
downloadDestination: "Downloads",
|
||||||
downloadHostPath: "/volume1/Downloads",
|
downloadHostPath: "/volume1/Downloads",
|
||||||
|
filmsHostPath: "/volume1/Media/Films/Films",
|
||||||
fourKHostPath: "/volume1/Media/Films/4K",
|
fourKHostPath: "/volume1/Media/Films/4K",
|
||||||
|
seriesHostPath: "/volume1/Media/Series/Series",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -211,7 +215,7 @@ export default function SettingsPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!nodeId) return;
|
if (!nodeId) return;
|
||||||
const existing = configs.find((c) => c.nodeId === nodeId);
|
const existing = configs.find((c) => c.nodeId === nodeId);
|
||||||
setCfg(existing ? { ...existing } : emptyConfig(nodeId));
|
setCfg(existing ? { ...emptyConfig(nodeId), ...existing } : emptyConfig(nodeId));
|
||||||
setPassword("");
|
setPassword("");
|
||||||
setCfgMsg(null);
|
setCfgMsg(null);
|
||||||
setCfgErr(null);
|
setCfgErr(null);
|
||||||
|
|
@ -651,8 +655,8 @@ export default function SettingsPage() {
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h2 className="section-title">Download Station</h2>
|
<h2 className="section-title">Download Station</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
Synology DS-login en vaste paden voor 4K-import. Zoeken doe je onder{" "}
|
Synology DS-login, staging-pad en bibliotheekdoelen (Films / 4K / Series). Zoeken
|
||||||
<Link href="/downloads">Downloads</Link>.
|
doe je onder <Link href="/downloads">Downloads</Link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|
@ -702,13 +706,21 @@ export default function SettingsPage() {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Download host-pad
|
Download host-pad (staging)
|
||||||
<input
|
<input
|
||||||
value={cfg.downloadHostPath}
|
value={cfg.downloadHostPath}
|
||||||
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
|
onChange={(e) => setCfg({ ...cfg, downloadHostPath: e.target.value })}
|
||||||
placeholder="/volume1/Downloads"
|
placeholder="/volume1/Downloads"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
<label>
|
||||||
4K bibliotheek host-pad
|
4K bibliotheek host-pad
|
||||||
<input
|
<input
|
||||||
|
|
@ -717,6 +729,14 @@ export default function SettingsPage() {
|
||||||
placeholder="/volume1/Media/Films/4K"
|
placeholder="/volume1/Media/Films/4K"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
||||||
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
<div className="inline-form" style={{ marginTop: "0.75rem" }}>
|
||||||
<button type="button" disabled={cfgBusy || !nodeId} onClick={() => void saveConfig()}>
|
<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")
|
@@map("playback_sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Synology Download Station credentials + fixed 4K import paths (per node). */
|
/** Synology Download Station credentials + library import paths (per node). */
|
||||||
model SynologyDsConfig {
|
model SynologyDsConfig {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nodeId String @unique @map("node_id")
|
nodeId String @unique @map("node_id")
|
||||||
|
|
@ -576,8 +576,12 @@ model SynologyDsConfig {
|
||||||
downloadDestination String @map("download_destination")
|
downloadDestination String @map("download_destination")
|
||||||
/** Absolute host path of that destination on the media-node */
|
/** Absolute host path of that destination on the media-node */
|
||||||
downloadHostPath String @map("download_host_path")
|
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")
|
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)
|
enabled Boolean @default(true)
|
||||||
lastOkAt DateTime? @map("last_ok_at")
|
lastOkAt DateTime? @map("last_ok_at")
|
||||||
lastError String? @map("last_error")
|
lastError String? @map("last_error")
|
||||||
|
|
@ -589,10 +593,18 @@ model SynologyDsConfig {
|
||||||
@@map("synology_ds_configs")
|
@@map("synology_ds_configs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DownloadLibraryTarget {
|
||||||
|
FILMS
|
||||||
|
FOUR_K
|
||||||
|
SERIES
|
||||||
|
}
|
||||||
|
|
||||||
model DownloadJob {
|
model DownloadJob {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
nodeId String @map("node_id")
|
nodeId String @map("node_id")
|
||||||
status DownloadJobStatus @default(DOWNLOADING)
|
status DownloadJobStatus @default(DOWNLOADING)
|
||||||
|
/** Where to import after DS finishes */
|
||||||
|
libraryTarget DownloadLibraryTarget @default(FOUR_K) @map("library_target")
|
||||||
sourceUri String @map("source_uri")
|
sourceUri String @map("source_uri")
|
||||||
sourceTitle String @map("source_title")
|
sourceTitle String @map("source_title")
|
||||||
dsTaskId String? @map("ds_task_id")
|
dsTaskId String? @map("ds_task_id")
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,20 @@ async function main() {
|
||||||
await prisma.$executeRawUnsafe(
|
await prisma.$executeRawUnsafe(
|
||||||
`ALTER TABLE "download_jobs" ADD COLUMN IF NOT EXISTS "subtitle_path" TEXT`
|
`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(`
|
await prisma.$executeRawUnsafe(`
|
||||||
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
CREATE TABLE IF NOT EXISTS "integration_settings" (
|
||||||
"id" TEXT NOT NULL,
|
"id" TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,34 @@ import { requireAdmin } from "../auth/routes";
|
||||||
import type { Config } from "../config";
|
import type { Config } from "../config";
|
||||||
import { MetadataService } from "../metadata/service";
|
import { MetadataService } from "../metadata/service";
|
||||||
import { AppError } from "../security/errors";
|
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 {
|
export function registerDownloadRoutes(app: FastifyInstance, config: Config): DownloadService {
|
||||||
const metadata = new MetadataService(config.TMDB_API_KEY);
|
const metadata = new MetadataService(config.TMDB_API_KEY);
|
||||||
|
|
@ -23,7 +50,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
password?: string;
|
password?: string;
|
||||||
downloadDestination?: string;
|
downloadDestination?: string;
|
||||||
downloadHostPath?: string;
|
downloadHostPath?: string;
|
||||||
|
filmsHostPath?: string;
|
||||||
fourKHostPath?: string;
|
fourKHostPath?: string;
|
||||||
|
seriesHostPath?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
if (!body.nodeId || !body.baseUrl || !body.username) {
|
if (!body.nodeId || !body.baseUrl || !body.username) {
|
||||||
|
|
@ -36,7 +65,9 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
password: body.password,
|
password: body.password,
|
||||||
downloadDestination: body.downloadDestination ?? "",
|
downloadDestination: body.downloadDestination ?? "",
|
||||||
downloadHostPath: body.downloadHostPath ?? "",
|
downloadHostPath: body.downloadHostPath ?? "",
|
||||||
|
filmsHostPath: body.filmsHostPath ?? "",
|
||||||
fourKHostPath: body.fourKHostPath ?? "",
|
fourKHostPath: body.fourKHostPath ?? "",
|
||||||
|
seriesHostPath: body.seriesHostPath ?? "",
|
||||||
enabled: body.enabled,
|
enabled: body.enabled,
|
||||||
});
|
});
|
||||||
return { config: row };
|
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) => {
|
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()) {
|
if (!body.nodeId || !body.q?.trim()) {
|
||||||
throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400);
|
throw new AppError("INVALID_REQUEST", "nodeId en q verplicht", 400);
|
||||||
}
|
}
|
||||||
const results = await downloads.search(body.nodeId, body.q);
|
const target: LibraryTarget = isLibraryTarget(body.libraryTarget)
|
||||||
return { results };
|
? 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) => {
|
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) => {
|
app.get("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
|
||||||
const q = request.query as { limit?: string };
|
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);
|
const jobs = await downloads.listJobs(limit);
|
||||||
return {
|
return { jobs: jobs.map(mapJob) };
|
||||||
jobs: jobs.map((j) => ({
|
});
|
||||||
id: j.id,
|
|
||||||
nodeId: j.nodeId,
|
app.delete("/api/v1/admin/downloads/jobs/completed", { preHandler: requireAdmin }, async () => {
|
||||||
nodeName: j.node.name,
|
return downloads.clearCompletedJobs();
|
||||||
status: j.status,
|
});
|
||||||
sourceTitle: j.sourceTitle,
|
|
||||||
movieTitle: j.movieTitle,
|
app.delete("/api/v1/admin/downloads/jobs/:id", { preHandler: requireAdmin }, async (request) => {
|
||||||
movieYear: j.movieYear,
|
const { id } = request.params as { id: string };
|
||||||
posterUrl: j.posterUrl,
|
return downloads.deleteJob(id);
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/api/v1/admin/downloads/jobs", { preHandler: requireAdmin }, async (request) => {
|
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;
|
nodeId?: string;
|
||||||
uri?: string;
|
uri?: string;
|
||||||
sourceTitle?: string;
|
sourceTitle?: string;
|
||||||
|
libraryTarget?: string;
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
movieTitle?: string;
|
movieTitle?: string;
|
||||||
|
|
@ -130,10 +150,14 @@ export function registerDownloadRoutes(app: FastifyInstance, config: Config): Do
|
||||||
if (!body.nodeId || !body.uri || !body.movieTitle) {
|
if (!body.nodeId || !body.uri || !body.movieTitle) {
|
||||||
throw new AppError("INVALID_REQUEST", "nodeId, uri en movieTitle verplicht", 400);
|
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({
|
const job = await downloads.createJob({
|
||||||
nodeId: body.nodeId,
|
nodeId: body.nodeId,
|
||||||
uri: body.uri,
|
uri: body.uri,
|
||||||
sourceTitle: body.sourceTitle || body.movieTitle,
|
sourceTitle: body.sourceTitle || body.movieTitle,
|
||||||
|
libraryTarget: body.libraryTarget,
|
||||||
tmdbId: body.tmdbId,
|
tmdbId: body.tmdbId,
|
||||||
imdbId: body.imdbId,
|
imdbId: body.imdbId,
|
||||||
movieTitle: body.movieTitle,
|
movieTitle: body.movieTitle,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,15 @@ import {
|
||||||
} from "../opensubtitles/client";
|
} from "../opensubtitles/client";
|
||||||
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
|
import { OpenSubtitlesOrgClient } from "../opensubtitles/org-client";
|
||||||
import { createOpenSubtitlesClientFromDb, isOpenSubtitlesEnabled } from "../settings/opensubtitles";
|
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 {
|
function normalizeBaseUrl(raw: string): string {
|
||||||
let u = raw.trim().replace(/\/+$/, "");
|
let u = raw.trim().replace(/\/+$/, "");
|
||||||
|
|
@ -30,6 +39,42 @@ function normalizeHostPath(p: string): string {
|
||||||
return p.trim().replace(/\/+$/, "");
|
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 = {
|
export type DsConfigInput = {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -37,7 +82,9 @@ export type DsConfigInput = {
|
||||||
password?: string;
|
password?: string;
|
||||||
downloadDestination: string;
|
downloadDestination: string;
|
||||||
downloadHostPath: string;
|
downloadHostPath: string;
|
||||||
|
filmsHostPath: string;
|
||||||
fourKHostPath: string;
|
fourKHostPath: string;
|
||||||
|
seriesHostPath: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -269,7 +316,9 @@ export class DownloadService {
|
||||||
hasPassword: true,
|
hasPassword: true,
|
||||||
downloadDestination: r.downloadDestination,
|
downloadDestination: r.downloadDestination,
|
||||||
downloadHostPath: r.downloadHostPath,
|
downloadHostPath: r.downloadHostPath,
|
||||||
|
filmsHostPath: r.filmsHostPath,
|
||||||
fourKHostPath: r.fourKHostPath,
|
fourKHostPath: r.fourKHostPath,
|
||||||
|
seriesHostPath: r.seriesHostPath,
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
lastOkAt: r.lastOkAt,
|
lastOkAt: r.lastOkAt,
|
||||||
lastError: r.lastError,
|
lastError: r.lastError,
|
||||||
|
|
@ -289,9 +338,22 @@ export class DownloadService {
|
||||||
const username = input.username.trim();
|
const username = input.username.trim();
|
||||||
const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
const downloadDestination = input.downloadDestination.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
||||||
const downloadHostPath = normalizeHostPath(input.downloadHostPath);
|
const downloadHostPath = normalizeHostPath(input.downloadHostPath);
|
||||||
|
const filmsHostPath = normalizeHostPath(input.filmsHostPath);
|
||||||
const fourKHostPath = normalizeHostPath(input.fourKHostPath);
|
const fourKHostPath = normalizeHostPath(input.fourKHostPath);
|
||||||
if (!username || !downloadDestination || !downloadHostPath || !fourKHostPath) {
|
const seriesHostPath = normalizeHostPath(input.seriesHostPath);
|
||||||
throw new AppError("INVALID_REQUEST", "Vul alle verplichte velden in", 400);
|
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()
|
const passwordEnc = input.password?.trim()
|
||||||
|
|
@ -316,7 +378,9 @@ export class DownloadService {
|
||||||
passwordEnc,
|
passwordEnc,
|
||||||
downloadDestination,
|
downloadDestination,
|
||||||
downloadHostPath,
|
downloadHostPath,
|
||||||
|
filmsHostPath,
|
||||||
fourKHostPath,
|
fourKHostPath,
|
||||||
|
seriesHostPath,
|
||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
lastOkAt: new Date(),
|
lastOkAt: new Date(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
|
@ -327,7 +391,9 @@ export class DownloadService {
|
||||||
passwordEnc,
|
passwordEnc,
|
||||||
downloadDestination,
|
downloadDestination,
|
||||||
downloadHostPath,
|
downloadHostPath,
|
||||||
|
filmsHostPath,
|
||||||
fourKHostPath,
|
fourKHostPath,
|
||||||
|
seriesHostPath,
|
||||||
enabled: input.enabled ?? true,
|
enabled: input.enabled ?? true,
|
||||||
lastOkAt: new Date(),
|
lastOkAt: new Date(),
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
|
@ -341,7 +407,9 @@ export class DownloadService {
|
||||||
username: row.username,
|
username: row.username,
|
||||||
downloadDestination: row.downloadDestination,
|
downloadDestination: row.downloadDestination,
|
||||||
downloadHostPath: row.downloadHostPath,
|
downloadHostPath: row.downloadHostPath,
|
||||||
|
filmsHostPath: row.filmsHostPath,
|
||||||
fourKHostPath: row.fourKHostPath,
|
fourKHostPath: row.fourKHostPath,
|
||||||
|
seriesHostPath: row.seriesHostPath,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
lastOkAt: row.lastOkAt,
|
lastOkAt: row.lastOkAt,
|
||||||
};
|
};
|
||||||
|
|
@ -357,7 +425,11 @@ export class DownloadService {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(nodeId: string, query: string): Promise<
|
async search(
|
||||||
|
nodeId: string,
|
||||||
|
query: string,
|
||||||
|
target: LibraryTarget = "FOUR_K"
|
||||||
|
): Promise<
|
||||||
Array<{
|
Array<{
|
||||||
title: string;
|
title: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
|
@ -368,10 +440,12 @@ export class DownloadService {
|
||||||
suggestQuery: string;
|
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[];
|
let hits: DsSearchHit[];
|
||||||
try {
|
try {
|
||||||
hits = await client.search(query);
|
hits = await client.search(searchQ);
|
||||||
await prisma.synologyDsConfig.update({
|
await prisma.synologyDsConfig.update({
|
||||||
where: { nodeId },
|
where: { nodeId },
|
||||||
data: { lastOkAt: new Date(), lastError: null },
|
data: { lastOkAt: new Date(), lastError: null },
|
||||||
|
|
@ -403,6 +477,7 @@ export class DownloadService {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
uri: string;
|
uri: string;
|
||||||
sourceTitle: string;
|
sourceTitle: string;
|
||||||
|
libraryTarget: LibraryTarget;
|
||||||
tmdbId?: number;
|
tmdbId?: number;
|
||||||
imdbId?: string;
|
imdbId?: string;
|
||||||
movieTitle: string;
|
movieTitle: string;
|
||||||
|
|
@ -420,8 +495,12 @@ export class DownloadService {
|
||||||
if (!input.tmdbId && !input.imdbId) {
|
if (!input.tmdbId && !input.imdbId) {
|
||||||
throw new AppError("INVALID_REQUEST", "Bevestig met TMDB- of IMDb-id", 400);
|
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);
|
const { client, config } = await this.clientForNode(input.nodeId);
|
||||||
|
requireLibraryPath(config, input.libraryTarget);
|
||||||
|
|
||||||
let tmdbId = input.tmdbId;
|
let tmdbId = input.tmdbId;
|
||||||
let imdbId = input.imdbId?.trim() || null;
|
let imdbId = input.imdbId?.trim() || null;
|
||||||
|
|
@ -535,6 +614,7 @@ export class DownloadService {
|
||||||
data: {
|
data: {
|
||||||
nodeId: input.nodeId,
|
nodeId: input.nodeId,
|
||||||
status: "DOWNLOADING",
|
status: "DOWNLOADING",
|
||||||
|
libraryTarget: input.libraryTarget,
|
||||||
sourceUri: uri,
|
sourceUri: uri,
|
||||||
sourceTitle: input.sourceTitle.trim() || title,
|
sourceTitle: input.sourceTitle.trim() || title,
|
||||||
dsTaskId,
|
dsTaskId,
|
||||||
|
|
@ -583,12 +663,29 @@ export class DownloadService {
|
||||||
|
|
||||||
async listJobs(limit = 50) {
|
async listJobs(limit = 50) {
|
||||||
return prisma.downloadJob.findMany({
|
return prisma.downloadJob.findMany({
|
||||||
take: Math.min(100, Math.max(1, limit)),
|
take: Math.min(200, Math.max(1, limit)),
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: { node: { select: { id: true, name: true } } },
|
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) {
|
async retryImport(jobId: string) {
|
||||||
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
|
const job = await prisma.downloadJob.findUnique({ where: { id: jobId } });
|
||||||
if (!job) throw new AppError("NOT_FOUND", "Job niet gevonden", 404);
|
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 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 =
|
const sourcePath =
|
||||||
job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`;
|
job.sourcePath || `${normalizeHostPath(config.downloadHostPath)}/${job.sourceTitle}`;
|
||||||
|
|
||||||
|
|
@ -760,7 +868,7 @@ export class DownloadService {
|
||||||
movieTitle: job.movieTitle,
|
movieTitle: job.movieTitle,
|
||||||
movieYear: job.movieYear,
|
movieYear: job.movieYear,
|
||||||
releaseTags,
|
releaseTags,
|
||||||
qualityHint: "2160p",
|
qualityHint: qualityHintForTarget(job.libraryTarget),
|
||||||
subtitle,
|
subtitle,
|
||||||
},
|
},
|
||||||
120_000
|
120_000
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue