+
+ {t("downloads.usage.title")}
+
+ {formatBytes(u.footprint_bytes)}
+ {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes)}`} ·{" "}
+ {u.unlimited ? t("downloads.usage.unlimited") : t("downloads.usage.items", { used: u.active_jobs, max: u.max_jobs })}
+
+
+ {!u.unlimited && (
+
+
90 ? "bg-red-400" : "bg-accent")} style={{ width: `${pct}%` }} />
+
+ )}
+
+ );
+}
+
+// --- Admin system tab -----------------------------------------------------------------------
+
+function QuotaModal({ userId, email, onClose }: { userId: number; email: string; onClose: () => void }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const q = useQuery({ queryKey: ["dl-quota", userId], queryFn: () => api.adminGetDownloadQuota(userId) });
+ const [form, setForm] = useState<{ gb: number; jobs: number; conc: number; unlimited: boolean } | null>(null);
+ const cur = q.data;
+ const state = form ?? (cur ? { gb: Math.round((cur.max_bytes / GiB) * 10) / 10, jobs: cur.max_jobs, conc: cur.max_concurrent, unlimited: cur.unlimited } : null);
+ const done = () => {
+ qc.invalidateQueries({ queryKey: ["dl-quota", userId] });
+ qc.invalidateQueries({ queryKey: ["admin-storage"] });
+ onClose();
+ };
+ const save = useMutation({
+ mutationFn: () =>
+ api.adminSetDownloadQuota(userId, {
+ max_bytes: Math.round((state!.gb) * GiB),
+ max_jobs: state!.jobs,
+ max_concurrent: state!.conc,
+ unlimited: state!.unlimited,
+ }),
+ onSuccess: done,
+ });
+ const reset = useMutation({ mutationFn: () => api.adminResetDownloadQuota(userId), onSuccess: done });
+ if (!state) return null;
+ const upd = (p: Partial
) => setForm({ ...state, ...p });
+ return (
+
+
+
+ );
+}
+
+// Pick ANY user to set a download quota for — even one who hasn't downloaded anything yet (the
+// footprint list below only shows users who already have files).
+function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }) => void }) {
+ const { t } = useTranslation();
+ const [q, setQ] = useState("");
+ const [open, setOpen] = useState(false);
+ const users = useQuery({ queryKey: ["admin-users"], queryFn: api.adminUsers });
+ const list = (users.data ?? []).filter((u) => !u.is_demo);
+ const filtered = useMemo(() => {
+ const s = q.trim().toLowerCase();
+ const base = s
+ ? list.filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
+ : list;
+ return base.slice(0, 8);
+ }, [q, list]);
+ return (
+
+
{
+ setQ(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setTimeout(() => setOpen(false), 150)}
+ placeholder={t("downloads.admin.quotaPickPlaceholder")}
+ className={inputCls}
+ />
+ {open && filtered.length > 0 && (
+
+ {filtered.map((u) => (
+ {
+ onPick({ id: u.id, email: u.email });
+ setQ("");
+ setOpen(false);
+ }}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
+ >
+ {u.display_name || u.email}
+ {u.display_name && {u.email} }
+
+ ))}
+
+ )}
+
+ );
+}
+
+function AdminSystem() {
+ const { t } = useTranslation();
+ const storage = useLiveQuery(["admin-storage"], api.adminDownloadStorage, { intervalMs: 5000 });
+ const jobs = useLiveQuery(["admin-downloads"], api.adminDownloads, { intervalMs: 5000 });
+ const [quotaFor, setQuotaFor] = useState<{ id: number; email: string } | null>(null);
+ const s = storage.data;
+ return (
+
+ {s && (
+
+ {[
+ [t("downloads.admin.readyFiles"), String(s.ready_files)],
+ [t("downloads.admin.totalSize"), formatBytes(s.total_bytes)],
+ [t("downloads.admin.cap"), s.total_cap_bytes ? formatBytes(s.total_cap_bytes) : t("downloads.admin.noCap")],
+ ].map(([label, val]) => (
+
+ ))}
+
+ )}
+
+ {s && (
+
+
+
{t("downloads.admin.perUser")}
+ {/* Set a quota for any user, regardless of whether they've downloaded anything. */}
+
+
+
+ {s.per_user.map((u) => (
+
+ {u.email}
+ {formatBytes(u.footprint_bytes)}
+ setQuotaFor({ id: u.user_id, email: u.email ?? "" })}
+ className="p-1 rounded hover:bg-surface text-muted hover:text-fg"
+ title={t("downloads.admin.editQuota")}
+ >
+
+
+
+ ))}
+ {s.per_user.length === 0 && (
+
{t("downloads.admin.noFootprint")}
+ )}
+
+
+ )}
+
+
{t("downloads.tabs.system")}
+
+ {(jobs.data ?? []).map((j) => (
+
+ {jobTitle(j)}
+ {j.user_email}
+
+ {j.asset?.size_bytes ? formatBytes(j.asset.size_bytes) : ""}
+
+ ))}
+ {jobs.data && jobs.data.length === 0 &&
{t("downloads.empty.admin")}
}
+
+
+ {quotaFor &&
setQuotaFor(null)} />}
+
+ );
+}
+
+// --- Main -----------------------------------------------------------------------------------
+
+export default function DownloadCenter({ me }: { me: Me }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const [tab, setTab] = usePersistedTab("siftlode.downloadTab", "queue");
+ const [addUrl, setAddUrl] = useState("");
+ const [addSource, setAddSource] = useState(null);
+ const [manage, setManage] = useState(false);
+ const [renameJob, setRenameJob] = useState(null);
+ const [shareJob, setShareJob] = useState(null);
+ const [editJob, setEditJob] = useState(null);
+
+ const jobsQ = useLiveQuery(["downloads"], api.downloads, { intervalMs: 2000 });
+ const sharedQ = useQuery({ queryKey: ["downloads-shared"], queryFn: api.downloadsShared, enabled: tab === "shared" });
+ const jobs = jobsQ.data ?? [];
+ const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
+ const library = jobs.filter((j) => j.status === "done");
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ qc.invalidateQueries({ queryKey: ["download-index"] });
+ qc.invalidateQueries({ queryKey: ["download-usage"] });
+ };
+ const act = useMutation({
+ mutationFn: ({ id, op }: { id: number; op: "pause" | "resume" | "cancel" | "delete" }) =>
+ op === "pause" ? api.pauseDownload(id)
+ : op === "resume" ? api.resumeDownload(id)
+ : op === "cancel" ? api.cancelDownload(id)
+ : api.deleteDownload(id),
+ onSuccess: invalidate,
+ });
+
+ // Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
+ const removeShared = useMutation({
+ mutationFn: (id: number) => api.removeSharedDownload(id),
+ onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads-shared"] }),
+ });
+
+ const confirmThen = async (title: string, body: string, fn: () => void) => {
+ if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn();
+ };
+
+ const tabs = [
+ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length },
+ { id: "done", label: t("downloads.tabs.done"), badge: library.length },
+ { id: "shared", label: t("downloads.tabs.shared") },
+ ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []),
+ ];
+
+ const saveBtn = (job: DownloadJob) => (
+
+
+
+ );
+
+ return (
+
+
+
{t("downloads.page.title")}
+ setManage(true)}
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
+ >
+ {t("downloads.page.manage")}
+
+
+
{t("downloads.page.subtitle")}
+
+
+
+ {tab === "queue" && (
+ <>
+
+
setAddUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
+ placeholder={t("downloads.page.addUrl")}
+ className={inputCls}
+ />
+
addUrl.trim() && setAddSource(addUrl.trim())}
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 transition shrink-0"
+ >
+ {t("downloads.page.add")}
+
+
+
+ {queue.map((job) => (
+
+ {(job.status === "queued" || job.status === "running") && (
+ act.mutate({ id: job.id, op: "pause" })} title={t("downloads.actions.pause")}>
+
+
+ )}
+ {(job.status === "paused" || job.status === "error") && (
+ act.mutate({ id: job.id, op: "resume" })} title={t(job.status === "error" ? "downloads.actions.retry" : "downloads.actions.resume")}>
+
+
+ )}
+ confirmThen(t("downloads.confirm.cancelTitle"), t("downloads.confirm.cancelBody"), () => act.mutate({ id: job.id, op: "cancel" }))}
+ title={t("downloads.actions.cancel")}
+ danger
+ >
+
+
+
+ ))}
+ {queue.length === 0 &&
{t("downloads.empty.queue")}
}
+
+ >
+ )}
+
+ {tab === "done" && (
+ <>
+
+
+ {library.map((job) => (
+
+ {saveBtn(job)}
+ {job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
+ setEditJob(job)} title={t("downloads.actions.edit")}>
+
+
+ )}
+ setRenameJob(job)} title={t("downloads.actions.rename")}>
+
+
+ setShareJob(job)} title={t("downloads.actions.share")}>
+
+
+ confirmThen(t("downloads.confirm.deleteTitle"), t("downloads.confirm.deleteBody"), () => act.mutate({ id: job.id, op: "delete" }))}
+ title={t("downloads.actions.delete")}
+ danger
+ >
+
+
+
+ ))}
+ {library.length === 0 &&
{t("downloads.empty.done")}
}
+
+ >
+ )}
+
+ {tab === "shared" && (
+
+ {(sharedQ.data ?? []).map((job) => (
+
+ {job.can_download && saveBtn(job)}
+ {job.can_download && (job.asset?.duration_s ?? 0) > 0 && (
+ setEditJob(job)} title={t("downloads.actions.edit")}>
+
+
+ )}
+
+ confirmThen(
+ t("downloads.confirm.removeSharedTitle"),
+ t("downloads.confirm.removeSharedBody"),
+ () => removeShared.mutate(job.id)
+ )
+ }
+ title={t("downloads.actions.removeShared")}
+ danger
+ >
+
+
+
+ ))}
+ {sharedQ.data && sharedQ.data.length === 0 && (
+
{t("downloads.empty.shared")}
+ )}
+
+ )}
+
+ {tab === "system" && me.role === "admin" &&
}
+
+ {addSource && (
+
{
+ setAddSource(null);
+ setAddUrl("");
+ }}
+ />
+ )}
+ {manage && setManage(false)} />}
+ {renameJob && setRenameJob(null)} />}
+ {shareJob && setShareJob(null)} />}
+ {editJob && setEditJob(null)} />}
+
+ );
+}
diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx
new file mode 100644
index 0000000..1f27474
--- /dev/null
+++ b/frontend/src/components/DownloadDialog.tsx
@@ -0,0 +1,117 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import Modal from "./Modal";
+import ProfileEditor from "./ProfileEditor";
+import { api } from "../lib/api";
+import { notify } from "../lib/notifications";
+import { navigateTo } from "../lib/nav";
+
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+
+// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
+// the Download Center. Enqueues via the API and points the user at the Download Center.
+export default function DownloadDialog({
+ source,
+ title,
+ onClose,
+}: {
+ source: string;
+ title?: string | null;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
+ const [profileId, setProfileId] = useState(null);
+ const [name, setName] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [manage, setManage] = useState(false);
+
+ const profiles = profilesQ.data ?? [];
+ const chosen = profileId ?? profiles[0]?.id ?? null;
+
+ const submit = async () => {
+ if (!chosen || busy) return;
+ setBusy(true);
+ try {
+ await api.enqueueDownload({
+ source,
+ profile_id: chosen,
+ display_name: name.trim() || undefined,
+ });
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ qc.invalidateQueries({ queryKey: ["download-index"] });
+ qc.invalidateQueries({ queryKey: ["download-usage"] });
+ notify({
+ level: "success",
+ message: t("downloads.dialog.added"),
+ action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
+ });
+ onClose();
+ } catch {
+ // Non-quiet request: the global error dialog already surfaced the reason (e.g. quota).
+ setBusy(false);
+ }
+ };
+
+ return (
+
+ {title && {title}
}
+
+ {t("downloads.dialog.profile")}
+ setProfileId(Number(e.target.value))}
+ className={inputCls}
+ >
+ {profiles.map((p) => (
+
+ {p.name}
+
+ ))}
+
+ setManage(true)}
+ className="text-xs text-accent hover:underline mt-1 mb-4"
+ >
+ {t("downloads.dialog.manage")}
+
+
+ {t("downloads.dialog.nameLabel")}
+ setName(e.target.value)}
+ placeholder={t("downloads.dialog.namePlaceholder")}
+ className={`${inputCls} mb-5`}
+ />
+
+
+
+ {t("downloads.actions.cancel")}
+
+
+ {t("downloads.dialog.submit")}
+
+
+
+ {manage && (
+ {
+ setManage(false);
+ profilesQ.refetch();
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 2dc8024..2967d18 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -2,6 +2,8 @@ import { useTranslation } from "react-i18next";
import { Search, X, Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
+import { pageTitleKey } from "../lib/pageMeta";
+import PageTitle from "./PageTitle";
// Contextual top bar over the content column. Primary navigation, account and the per-user sync
// status live in the left NavSidebar; the feed's scope toggle now lives in the filter sidebar.
@@ -72,24 +74,8 @@ export default function Header({
)}
) : (
-
- {page === "stats"
- ? t("header.usageStats")
- : page === "playlists"
- ? t("header.account.playlists")
- : page === "settings"
- ? t("settings.title")
- : page === "scheduler"
- ? t("header.scheduler")
- : page === "config"
- ? t("header.configuration")
- : page === "users"
- ? t("header.users")
- : page === "notifications"
- ? t("inbox.navLabel")
- : page === "messages"
- ? t("messages.navLabel")
- : t("header.channelManager")}
+
)}
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx
index db5dde3..965d6be 100644
--- a/frontend/src/components/NavSidebar.tsx
+++ b/frontend/src/components/NavSidebar.tsx
@@ -8,6 +8,7 @@ import {
Bell,
ChevronLeft,
ChevronRight,
+ Download,
Home,
Info,
ListVideo,
@@ -147,6 +148,13 @@ export default function NavSidebar({
{ intervalMs: 30000, enabled: !me.is_demo }
);
const msgUnread = msgUnreadQuery.data?.count ?? 0;
+ // Download center badge = items still working (queued/running/paused). Reuses the same
+ // lightweight per-user index the feed cards poll, so no extra request shape.
+ const dlIndexQuery = useLiveQuery(["download-index"], api.downloadIndex, {
+ intervalMs: 30000,
+ enabled: !me.is_demo,
+ });
+ const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length;
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// User-facing content modules vs. admin/system modules, separated by a divider in the rail.
@@ -159,6 +167,11 @@ export default function NavSidebar({
...(me.is_demo
? []
: [{ page: "messages" as Page, icon: MessageSquare, label: t("messages.navLabel"), badge: msgUnread }]),
+ // Download center — YouTube → server → your device. Hidden for the shared demo account
+ // (spends server disk + needs a real identity).
+ ...(me.is_demo
+ ? []
+ : [{ page: "downloads" as Page, icon: Download, label: t("downloads.navLabel"), badge: dlActive }]),
// Per-user sync status + your own API usage (admins get an extra system-wide tab inside).
{ page: "stats", icon: BarChart3, label: t("header.account.stats") },
];
@@ -243,8 +256,9 @@ export default function NavSidebar({
{!collapsed && (
setPage("feed")}
- className="text-lg font-bold tracking-tight select-none"
+ className="text-lg font-bold tracking-tight select-none cursor-pointer rounded-md -mx-1 px-1 hover:bg-card hover:opacity-90 transition"
title={t("header.feed")}
+ aria-label={t("header.feed")}
>
Siftlode
diff --git a/frontend/src/components/PageTitle.tsx b/frontend/src/components/PageTitle.tsx
new file mode 100644
index 0000000..1ea61ae
--- /dev/null
+++ b/frontend/src/components/PageTitle.tsx
@@ -0,0 +1,11 @@
+// The centered module title in the top bar. Every non-feed module renders through this one
+// component, so its styling lives in a single place. Design element (user-picked): a tracked
+// small-caps "eyebrow" with a leading accent dot.
+export default function PageTitle({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx
index 973ada7..f8456d9 100644
--- a/frontend/src/components/PlayerModal.tsx
+++ b/frontend/src/components/PlayerModal.tsx
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ExternalLink, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
+import DownloadButton from "./DownloadButton";
import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
@@ -630,6 +631,13 @@ export default function PlayerModal({
className="ml-auto shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg hover:bg-surface border border-border transition"
/>
)}
+ {!navigated && (
+
+ )}
{!navigated && (
setWatched(!watched)}
diff --git a/frontend/src/components/ProfileEditor.tsx b/frontend/src/components/ProfileEditor.tsx
new file mode 100644
index 0000000..4178dc7
--- /dev/null
+++ b/frontend/src/components/ProfileEditor.tsx
@@ -0,0 +1,215 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Lock, Pencil, Plus, Trash2 } from "lucide-react";
+import Modal from "./Modal";
+import { useConfirm } from "./ConfirmProvider";
+import { api, type DownloadProfile, type DownloadSpec } from "../lib/api";
+
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+const HEIGHTS = [null, 2160, 1440, 1080, 720, 480, 360];
+
+const BLANK: DownloadSpec = {
+ mode: "av",
+ max_height: 1080,
+ container: "mp4",
+ audio_format: "m4a",
+ vcodec: null,
+ embed_subs: false,
+ embed_chapters: true,
+ embed_thumbnail: true,
+ sponsorblock: false,
+};
+
+function Check({
+ label,
+ checked,
+ onChange,
+}: {
+ label: string;
+ checked: boolean;
+ onChange: (v: boolean) => void;
+}) {
+ return (
+
+ onChange(e.target.checked)} />
+ {label}
+
+ );
+}
+
+export default function ProfileEditor({ onClose }: { onClose: () => void }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const profilesQ = useQuery({ queryKey: ["download-profiles"], queryFn: api.downloadProfiles });
+ const profiles = profilesQ.data ?? [];
+ const builtins = profiles.filter((p) => p.is_builtin);
+ const mine = profiles.filter((p) => !p.is_builtin);
+
+ const [editing, setEditing] = useState(null);
+ const [name, setName] = useState("");
+ const [spec, setSpec] = useState(BLANK);
+ const [formOpen, setFormOpen] = useState(false);
+
+ const invalidate = () => qc.invalidateQueries({ queryKey: ["download-profiles"] });
+ const startNew = () => {
+ setEditing(null);
+ setName("");
+ setSpec(BLANK);
+ setFormOpen(true);
+ };
+ const startEdit = (p: DownloadProfile) => {
+ setEditing(p);
+ setName(p.name);
+ setSpec({ ...BLANK, ...p.spec });
+ setFormOpen(true);
+ };
+
+ const save = useMutation({
+ mutationFn: () =>
+ editing
+ ? api.updateDownloadProfile(editing.id, { name, spec })
+ : api.createDownloadProfile({ name, spec }),
+ onSuccess: () => {
+ invalidate();
+ setFormOpen(false);
+ },
+ });
+ const del = useMutation({
+ mutationFn: (id: number) => api.deleteDownloadProfile(id),
+ onSuccess: invalidate,
+ });
+
+ const onDelete = async (p: DownloadProfile) => {
+ if (await confirm({ title: t("downloads.profiles.delete"), message: t("downloads.profiles.deleteConfirm", { name: p.name }), confirmLabel: t("downloads.profiles.delete"), danger: true })) {
+ del.mutate(p.id);
+ }
+ };
+
+ const patch = (p: Partial) => setSpec((s) => ({ ...s, ...p }));
+ const set = (k: keyof DownloadSpec) => (e: React.ChangeEvent) =>
+ patch({ [k]: e.target.value === "" ? null : e.target.value } as Partial);
+
+ return (
+
+ {/* Existing formats */}
+
+
{t("downloads.profiles.builtin")}
+ {builtins.map((p) => (
+
+
+ {p.name}
+
+ ))}
+ {mine.length > 0 && (
+
{t("downloads.profiles.yours")}
+ )}
+ {mine.map((p) => (
+
+
{p.name}
+
startEdit(p)} title={t("downloads.actions.rename")} className="p-1 rounded hover:bg-surface text-muted hover:text-fg">
+
+
+
onDelete(p)} title={t("downloads.profiles.delete")} className="p-1 rounded hover:bg-surface text-muted hover:text-red-400">
+
+
+
+ ))}
+
+
+ {!formOpen ? (
+
+ {t("downloads.profiles.new")}
+
+ ) : (
+
+
+ {t("downloads.profiles.name")}
+ setName(e.target.value)} placeholder={t("downloads.profiles.namePlaceholder")} className={inputCls} />
+
+
+
+ {t("downloads.profiles.mode")}
+
+ {t("downloads.profiles.modeAv")}
+ {t("downloads.profiles.modeV")}
+ {t("downloads.profiles.modeA")}
+
+
+ {spec.mode !== "a" ? (
+
+ {t("downloads.profiles.quality")}
+ patch({ max_height: e.target.value === "" ? null : Number(e.target.value) })}
+ className={inputCls}
+ >
+ {HEIGHTS.map((h) => (
+
+ {h ? `${h}p` : t("downloads.profiles.best")}
+
+ ))}
+
+
+ ) : (
+
+ {t("downloads.profiles.audioFormat")}
+
+ M4A
+ MP3
+ Opus
+
+
+ )}
+ {spec.mode !== "a" && (
+ <>
+
+ {t("downloads.profiles.container")}
+
+ MP4
+ MKV
+ WebM
+
+
+
+ {t("downloads.profiles.vcodec")}
+
+ {t("downloads.profiles.any")}
+ H.264
+ VP9
+ AV1
+
+
+ >
+ )}
+
+
+ {spec.mode !== "a" && (
+ patch({ embed_subs: v })} />
+ )}
+ patch({ embed_chapters: v })} />
+ patch({ embed_thumbnail: v })} />
+ patch({ sponsorblock: v })} />
+
+
+ setFormOpen(false)} className="px-3 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition">
+ {t("downloads.actions.cancel")}
+
+ save.mutate()}
+ disabled={!name.trim() || save.isPending}
+ className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
+ >
+ {editing ? t("downloads.profiles.save") : t("downloads.profiles.create")}
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Scheduler.tsx b/frontend/src/components/Scheduler.tsx
index ba13bfe..92aadf6 100644
--- a/frontend/src/components/Scheduler.tsx
+++ b/frontend/src/components/Scheduler.tsx
@@ -142,7 +142,7 @@ function JobRow({
-
+
{t(`scheduler.jobs.${job.id}`, job.id)}
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx
new file mode 100644
index 0000000..6bacfd1
--- /dev/null
+++ b/frontend/src/components/ShareDialog.tsx
@@ -0,0 +1,255 @@
+import { useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
+import clsx from "clsx";
+import Modal from "./Modal";
+import { useConfirm } from "./ConfirmProvider";
+import { notify } from "../lib/notifications";
+import { api, type DownloadJob, type ShareLink } from "../lib/api";
+
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
+const EXPIRY_DAYS = [null, 1, 7, 30] as const;
+
+// --- share with a registered user (ACL) -----------------------------------------------------
+
+function UserShare({ job }: { job: DownloadJob }) {
+ const { t } = useTranslation();
+ const [q, setQ] = useState("");
+ const [open, setOpen] = useState(false);
+ const recipients = useQuery({ queryKey: ["share-recipients"], queryFn: api.shareRecipients });
+ const list = recipients.data ?? [];
+ const filtered = useMemo(() => {
+ const s = q.trim().toLowerCase();
+ if (!s) return list.slice(0, 8);
+ return list
+ .filter((u) => u.email.toLowerCase().includes(s) || (u.display_name ?? "").toLowerCase().includes(s))
+ .slice(0, 8);
+ }, [q, list]);
+
+ const share = useMutation({
+ mutationFn: (email: string) => api.shareDownload(job.id, email),
+ onSuccess: (r) => {
+ notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
+ setQ("");
+ setOpen(false);
+ },
+ onError: () => notify({ level: "error", message: t("downloads.share.failed") }),
+ });
+
+ return (
+
+
+ {t("downloads.share.userSection")}
+
+
{t("downloads.share.userHint")}
+
+
{
+ setQ(e.target.value);
+ setOpen(true);
+ }}
+ onFocus={() => setOpen(true)}
+ // Close on blur, but after a tick so a click on a dropdown row still registers.
+ onBlur={() => setTimeout(() => setOpen(false), 150)}
+ placeholder={t("downloads.share.pickPlaceholder")}
+ className={inputCls}
+ />
+ {open && filtered.length > 0 && (
+
+ {filtered.map((u) => (
+ share.mutate(u.email)}
+ disabled={share.isPending}
+ className="w-full text-left px-3 py-2 text-sm hover:bg-surface transition flex flex-col"
+ >
+ {u.display_name || u.email}
+ {u.display_name && {u.email} }
+
+ ))}
+
+ )}
+
+ {recipients.data && list.length === 0 && (
+
{t("downloads.share.noUsers")}
+ )}
+
+ );
+}
+
+// --- share by public link -------------------------------------------------------------------
+
+function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
+ const { t } = useTranslation();
+ const confirm = useConfirm();
+ const [copied, setCopied] = useState(false);
+ const fullUrl = window.location.origin + link.url;
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(fullUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ notify({ level: "error", message: t("downloads.share.copyFailed") });
+ }
+ };
+ const toggle = useMutation({
+ mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
+ onSuccess: onChanged,
+ });
+ const revoke = useMutation({
+ mutationFn: () => api.revokeDownloadLink(link.id),
+ onSuccess: onChanged,
+ });
+
+ const badges: string[] = [];
+ if (link.has_password) badges.push(t("downloads.share.hasPassword"));
+ if (link.allow_download) badges.push(t("downloads.share.downloadable"));
+ if (link.expires_at) badges.push(t("downloads.share.expiresOn", { date: new Date(link.expires_at).toLocaleDateString() }));
+
+ return (
+
+
+
+
+
+ {copied ? : }
+
+ {
+ if (await confirm({ title: t("downloads.share.revoke"), message: t("downloads.share.revokeConfirm"), confirmLabel: t("downloads.share.revoke"), danger: true }))
+ revoke.mutate();
+ }}
+ title={t("downloads.share.revoke")}
+ className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-red-400 transition"
+ >
+
+
+
+
+
{t("downloads.share.views", { n: link.view_count })}
+ {badges.map((b) => (
+
+ {b === t("downloads.share.hasPassword") && }
+ {b}
+
+ ))}
+
+
+ toggle.mutate()} />
+ {t("downloads.share.allowDownload")}
+
+
+
+ );
+}
+
+function LinkShare({ job }: { job: DownloadJob }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const links = useQuery({ queryKey: ["download-links", job.id], queryFn: () => api.downloadLinks(job.id) });
+ const [creating, setCreating] = useState(false);
+ const [allowDownload, setAllowDownload] = useState(false);
+ const [expiryDays, setExpiryDays] = useState(null);
+ const [password, setPassword] = useState("");
+ const invalidate = () => qc.invalidateQueries({ queryKey: ["download-links", job.id] });
+
+ const create = useMutation({
+ mutationFn: () =>
+ api.createDownloadLink(job.id, {
+ allow_download: allowDownload,
+ expires_days: expiryDays,
+ password: password.trim() || undefined,
+ }),
+ onSuccess: () => {
+ invalidate();
+ setCreating(false);
+ setAllowDownload(false);
+ setExpiryDays(null);
+ setPassword("");
+ },
+ });
+
+ return (
+
+
+ {t("downloads.share.linkSection")}
+
+
{t("downloads.share.linkHint")}
+
+
+ {(links.data ?? []).map((l) => (
+
+ ))}
+ {links.data && links.data.length === 0 && !creating && (
+
{t("downloads.share.noLinks")}
+ )}
+
+
+ {creating ? (
+
+
+ setAllowDownload(e.target.checked)} />
+ {t("downloads.share.allowDownload")}
+
+
+ {t("downloads.share.expiry")}:
+ {EXPIRY_DAYS.map((d) => (
+ setExpiryDays(d)}
+ className={clsx(
+ "px-2 py-1 rounded-md border text-xs transition",
+ expiryDays === d ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
+ )}
+ >
+ {d === null ? t("downloads.share.expiryNever") : t("downloads.share.days", { n: d })}
+
+ ))}
+
+
setPassword(e.target.value)}
+ placeholder={t("downloads.share.password")}
+ className={inputCls}
+ autoComplete="new-password"
+ />
+
+ setCreating(false)} className={clsx(btn, "text-muted hover:text-fg hover:bg-surface")}>
+ {t("common.cancel")}
+
+ create.mutate()} disabled={create.isPending} className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90")}>
+ {t("downloads.share.createLink")}
+
+
+
+ ) : (
+
setCreating(true)}
+ className={clsx(btn, "mt-2 border border-border text-muted hover:text-fg hover:border-muted inline-flex items-center gap-1.5")}
+ >
+ {t("downloads.share.newLink")}
+
+ )}
+
+ );
+}
+
+export default function ShareDialog({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/components/SyncStatus.tsx b/frontend/src/components/SyncStatus.tsx
index 435d70d..c3d7bcf 100644
--- a/frontend/src/components/SyncStatus.tsx
+++ b/frontend/src/components/SyncStatus.tsx
@@ -128,19 +128,29 @@ export default function SyncStatus({
- {showMain && {stateNode}
}
- {notFull > 0 && (
-
-
-
- {t("header.sync.withoutFullHistory", { count: notFull })}
-
-
+ {/* Pause sits inline at the right end of the primary status row (the sync state, or —
+ when idle with only deep-history pending — the "N without full history" row), never on
+ an orphaned line of its own. */}
+ {showMain && (
+
+ {stateNode}
+ {pauseBtn}
+
+ )}
+ {notFull > 0 && (
+
+
+
+
+ {t("header.sync.withoutFullHistory", { count: notFull })}
+
+
+ {!showMain && pauseBtn}
+
)}
- {pauseBtn && {pauseBtn}
}
);
}
diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx
index 3c77373..3d2c68e 100644
--- a/frontend/src/components/Tooltip.tsx
+++ b/frontend/src/components/Tooltip.tsx
@@ -1,10 +1,13 @@
-import { useRef, useState, useSyncExternalStore } from "react";
+import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom";
type Coords = { left: number; top: number; placement: Side };
+const MARGIN = 8;
+const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get
+
/** Wrap any element to show a short glass hint caption on hover — but only while the
* app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */
@@ -19,6 +22,7 @@ export default function Tooltip({
}) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef
(null);
+ const tipRef = useRef(null);
const [coords, setCoords] = useState(null);
function show() {
@@ -27,8 +31,12 @@ export default function Tooltip({
const r = el.getBoundingClientRect();
// Prefer the requested side; flip to bottom if there's no room above.
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
+ const center = r.left + r.width / 2;
+ // Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
+ // first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
+ // the centered box off-screen. The layout effect below refines this to the actual width.
setCoords({
- left: Math.min(Math.max(r.left + r.width / 2, 92), window.innerWidth - 92),
+ left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
top: placement === "top" ? r.top - 8 : r.bottom + 8,
placement,
});
@@ -37,6 +45,21 @@ export default function Tooltip({
setCoords(null);
}
+ // Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
+ // need the full 240px reservation), still clamped inside the viewport.
+ useLayoutEffect(() => {
+ const tip = tipRef.current;
+ const el = ref.current;
+ if (!coords || !tip || !el) return;
+ const r = el.getBoundingClientRect();
+ const half = tip.offsetWidth / 2;
+ const left = Math.min(
+ Math.max(r.left + r.width / 2, half + MARGIN),
+ window.innerWidth - half - MARGIN
+ );
+ if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
+ }, [coords]);
+
if (!enabled || !hint) return <>{children}>;
return (
@@ -52,6 +75,7 @@ export default function Tooltip({
{coords &&
createPortal(
+
seconds.
+function tc(s: number): string {
+ if (!isFinite(s) || s < 0) s = 0;
+ const m = Math.floor(s / 60);
+ return `${m}:${(s - m * 60).toFixed(1).padStart(4, "0")}`;
+}
+function parseTc(v: string): number | null {
+ v = v.trim();
+ if (!v) return null;
+ if (v.includes(":")) {
+ const [m, s] = v.split(":");
+ const mm = Number(m), ss = Number(s);
+ if (!isFinite(mm) || !isFinite(ss)) return null;
+ return mm * 60 + ss;
+ }
+ const n = Number(v);
+ return isFinite(n) ? n : null;
+}
+
+type Seg = { id: number; start: number; end: number; keep: boolean };
+type Frac = { x: number; y: number; w: number; h: number };
+type Drag = { type: "seek" } | { type: "boundary"; bi: number };
+
+const btn = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
+const inputCls =
+ "w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent/40";
+const numCls =
+ "w-24 rounded-md bg-surface border border-border px-2 py-1 text-sm tabular-nums focus:outline-none focus:ring-2 focus:ring-accent/40";
+const MIN_SEG = 0.1;
+
+export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const videoRef = useRef(null);
+ const trackRef = useRef(null);
+ const sid = useRef(1);
+ const nid = () => sid.current++;
+
+ const srcDur = job.asset?.duration_s ?? 0;
+ const [duration, setDuration] = useState(srcDur);
+ const [current, setCurrent] = useState(0);
+ const [playing, setPlaying] = useState(false);
+ const [trackW, setTrackW] = useState(0);
+
+ // The cut-list: contiguous segments covering [0, duration]. Starts as the whole video, kept.
+ const [segments, setSegments] = useState([{ id: 0, start: 0, end: srcDur, keep: true }]);
+ const [selId, setSelId] = useState(0);
+ const [output, setOutput] = useState<"separate" | "join">("separate");
+ const [cropOn, setCropOn] = useState(false);
+ const [crop, setCrop] = useState({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
+ const [accurate, setAccurate] = useState(true);
+ const [name, setName] = useState("");
+
+ const fileUrl = useMemo(() => api.downloadFileUrl(job.id), [job.id]);
+ const aspect =
+ (job.asset?.width && job.asset?.height ? job.asset.width / job.asset.height : 0) || 16 / 9;
+ const sb = useQuery({
+ queryKey: ["storyboard", job.id],
+ queryFn: () => api.downloadStoryboard(job.id),
+ staleTime: Infinity,
+ retry: false,
+ });
+ const strip = sb.data?.available ? sb.data : null;
+ const defaultName = job.display_name || job.asset?.title || "";
+
+ // Measure the track for aspect-correct filmstrip tiling.
+ useEffect(() => {
+ const el = trackRef.current;
+ if (!el || typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(() => setTrackW(el.clientWidth));
+ ro.observe(el);
+ setTrackW(el.clientWidth);
+ return () => ro.disconnect();
+ }, []);
+
+ const onLoaded = () => {
+ const v = videoRef.current;
+ if (!v) return;
+ const d = v.duration && isFinite(v.duration) ? v.duration : srcDur;
+ setDuration(d);
+ setSegments((s) => (s.length === 1 && s[0].end <= 0 ? [{ id: 0, start: 0, end: d, keep: true }] : s));
+ };
+ const onTimeUpdate = () => {
+ const v = videoRef.current;
+ if (v) setCurrent(v.currentTime);
+ };
+ const seek = (s: number) => {
+ const v = videoRef.current;
+ if (v) v.currentTime = Math.max(0, Math.min(duration, s));
+ };
+ const playPause = () => {
+ const v = videoRef.current;
+ if (!v) return;
+ v.paused ? v.play() : v.pause();
+ };
+
+ // --- cut-list mutations ---
+ const boundaries = useMemo(() => segments.slice(0, -1).map((s) => s.end), [segments]);
+ const splitAtPlayhead = () => {
+ const time = current;
+ setSegments((segs) => {
+ const i = segs.findIndex((s) => time > s.start + MIN_SEG && time < s.end - MIN_SEG);
+ if (i < 0) return segs;
+ const s = segs[i];
+ const b: Seg = { id: nid(), start: time, end: s.end, keep: s.keep };
+ const next = [...segs.slice(0, i), { ...s, end: time }, b, ...segs.slice(i + 1)];
+ return next;
+ });
+ };
+ const moveBoundary = (bi: number, time: number) =>
+ setSegments((segs) => {
+ if (bi < 0 || bi >= segs.length - 1) return segs;
+ const lo = segs[bi].start + MIN_SEG;
+ const hi = segs[bi + 1].end - MIN_SEG;
+ const tt = Math.max(lo, Math.min(hi, time));
+ const copy = [...segs];
+ copy[bi] = { ...copy[bi], end: tt };
+ copy[bi + 1] = { ...copy[bi + 1], start: tt };
+ return copy;
+ });
+ const deleteBoundary = (bi: number) =>
+ setSegments((segs) => {
+ if (segs.length < 2) return segs;
+ const merged = { ...segs[bi], end: segs[bi + 1].end };
+ return [...segs.slice(0, bi), merged, ...segs.slice(bi + 2)];
+ });
+ const toggleKeep = (id: number) =>
+ setSegments((segs) => segs.map((s) => (s.id === id ? { ...s, keep: !s.keep } : s)));
+
+ // --- timeline drag (seek / boundary) + hover preview ---
+ const [drag, setDrag] = useState(null);
+ const [hoverX, setHoverX] = useState(null);
+ const timeAt = useCallback(
+ (clientX: number) => {
+ const el = trackRef.current;
+ if (!el) return 0;
+ const r = el.getBoundingClientRect();
+ return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
+ },
+ [duration]
+ );
+ useEffect(() => {
+ if (!drag) return;
+ const move = (e: PointerEvent) => {
+ const tt = timeAt(e.clientX);
+ if (drag.type === "seek") {
+ setCurrent(tt);
+ seek(tt);
+ } else moveBoundary(drag.bi, tt);
+ };
+ const up = () => setDrag(null);
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ return () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ };
+ }, [drag, timeAt]);
+
+ // --- crop drag ---
+ const overlayRef = useRef(null);
+ const cropDrag = useRef(null);
+ const onCropDown = (e: React.PointerEvent, mode: "move" | "resize") => {
+ e.preventDefault();
+ e.stopPropagation();
+ (e.target as Element).setPointerCapture?.(e.pointerId);
+ cropDrag.current = { mode, px: e.clientX, py: e.clientY, orig: { ...crop } };
+ };
+ useEffect(() => {
+ const move = (e: PointerEvent) => {
+ const d = cropDrag.current;
+ const el = overlayRef.current;
+ if (!d || !el) return;
+ const r = el.getBoundingClientRect();
+ const dx = (e.clientX - d.px) / r.width;
+ const dy = (e.clientY - d.py) / r.height;
+ if (d.mode === "move")
+ setCrop({
+ ...d.orig,
+ x: Math.max(0, Math.min(1 - d.orig.w, d.orig.x + dx)),
+ y: Math.max(0, Math.min(1 - d.orig.h, d.orig.y + dy)),
+ });
+ else
+ setCrop({
+ ...d.orig,
+ w: Math.max(0.05, Math.min(1 - d.orig.x, d.orig.w + dx)),
+ h: Math.max(0.05, Math.min(1 - d.orig.y, d.orig.h + dy)),
+ });
+ };
+ const up = () => (cropDrag.current = null);
+ window.addEventListener("pointermove", move);
+ window.addEventListener("pointerup", up);
+ return () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ };
+ }, []);
+
+ // --- filmstrip tile helpers (percentage sprite positioning; aspect-correct cells) ---
+ const pct = (v: number) => `${(duration ? (v / duration) * 100 : 0).toFixed(3)}%`;
+ const tileStyle = (idx: number): React.CSSProperties => {
+ const cols = strip?.cols ?? 12;
+ const rows = strip?.rows ?? 1;
+ const i = Math.max(0, Math.min((strip?.count ?? 1) - 1, idx));
+ const col = i % cols;
+ const row = Math.floor(i / cols);
+ return {
+ backgroundImage: `url(${api.storyboardImageUrl(job.id)})`,
+ backgroundSize: `${cols * 100}% ${rows * 100}%`,
+ backgroundPosition: `${cols > 1 ? (col / (cols - 1)) * 100 : 0}% ${rows > 1 ? (row / (rows - 1)) * 100 : 0}%`,
+ };
+ };
+ const stripCells = strip && trackW ? Math.max(1, Math.floor(trackW / (44 * aspect))) : 0;
+ const hoverTime = hoverX != null ? timeAt(hoverX) : null;
+ // Clamp the hover-scrub popover inside the track so it never overflows the modal (which would
+ // add a horizontal scrollbar): center it on the cursor, but keep both edges within [0, trackW].
+ const HOVER_W = 148;
+ const hoverLeft =
+ hoverX != null
+ ? Math.max(
+ 0,
+ Math.min(
+ Math.max(0, (trackW || HOVER_W) - HOVER_W),
+ hoverX - (trackRef.current?.getBoundingClientRect().left ?? 0) - HOVER_W / 2
+ )
+ )
+ : 0;
+
+ // --- create ---
+ const kept = segments.filter((s) => s.keep);
+ const keptDur = kept.reduce((a, s) => a + (s.end - s.start), 0);
+ const isFullSingle =
+ segments.length === 1 && kept.length === 1 && kept[0].start <= 0.01 && kept[0].end >= duration - 0.01;
+ const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle);
+ const willJoin = output === "join" && kept.length > 1;
+ const reencode = cropOn || (willJoin ? accurate : accurate);
+
+ const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
+ const v = videoRef.current;
+ const vw = v?.videoWidth || job.asset?.width || 0;
+ const vh = v?.videoHeight || job.asset?.height || 0;
+ if (!cropOn || !vw || !vh) return undefined;
+ const even = (n: number) => Math.max(2, Math.round(n / 2) * 2);
+ return { x: even(crop.x * vw), y: even(crop.y * vh), w: even(crop.w * vw), h: even(crop.h * vh) };
+ };
+
+ const create = useMutation({
+ mutationFn: async () => {
+ const cp = cropPx();
+ const base = name.trim() || defaultName;
+ if (willJoin) {
+ const spec: EditSpec = {
+ segments: kept.map((s) => ({ start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) })),
+ accurate,
+ };
+ if (cp) spec.crop = cp;
+ await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: name.trim() || undefined });
+ return { files: 1 };
+ }
+ const N = kept.length;
+ for (let i = 0; i < N; i++) {
+ const s = kept[i];
+ const spec: EditSpec = { trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) } };
+ if (cp) spec.crop = cp;
+ else spec.accurate = accurate;
+ const dn = N > 1 ? `${base} (${i + 1}/${N})` : name.trim() || undefined;
+ await api.enqueueEdit({ source_job_id: job.id, edit_spec: spec, display_name: dn });
+ }
+ return { files: N };
+ },
+ onSuccess: ({ files }) => {
+ qc.invalidateQueries({ queryKey: ["downloads"] });
+ qc.invalidateQueries({ queryKey: ["download-usage"] });
+ notify({ level: "success", message: t("editor.created", { count: files }) });
+ onClose();
+ },
+ onError: () => notify({ level: "error", message: t("editor.failed") }),
+ });
+
+ const sel = segments.find((s) => s.id === selId) ?? segments[0];
+ const selIdx = segments.findIndex((s) => s.id === sel?.id);
+
+ return (
+
+
+ {/* video + crop overlay */}
+
+
setPlaying(true)}
+ onPause={() => setPlaying(false)}
+ className="w-full max-h-[42vh] mx-auto block"
+ playsInline
+ />
+ {cropOn && (
+
+
onCropDown(e, "move")}
+ >
+
onCropDown(e, "resize")}
+ />
+
+
+ )}
+
+
+ {/* transport */}
+
+
+ {playing ? : }
+
+
{tc(current)} / {tc(duration)}
+
+
+ {t("editor.splitHere")}
+
+
+
+ {/* timeline: filmstrip + segments (keep/drop tint) + markers + playhead + hover preview */}
+
+
{
+ setDrag({ type: "seek" });
+ seek(timeAt(e.clientX));
+ }}
+ onPointerMove={(e) => setHoverX(e.clientX)}
+ onPointerLeave={() => setHoverX(null)}
+ >
+ {/* aspect-correct filmstrip */}
+ {stripCells > 0 && (
+
+ {Array.from({ length: stripCells }).map((_, i) => (
+
+ ))}
+
+ )}
+ {/* segment tint: dropped = dimmed + hatched */}
+ {segments.map((s, i) => (
+
setSelId(s.id)}
+ title={s.keep ? t("editor.keptSeg") : t("editor.droppedSeg")}
+ >
+ {/* keep/drop toggle */}
+ {
+ e.stopPropagation();
+ toggleKeep(s.id);
+ }}
+ className={clsx(
+ "absolute top-1 left-1 p-0.5 rounded bg-black/50 backdrop-blur-sm",
+ s.keep ? "text-accent" : "text-muted"
+ )}
+ title={s.keep ? t("editor.drop") : t("editor.keep")}
+ >
+ {s.keep ? : }
+
+
+ ))}
+ {/* draggable boundary markers */}
+ {boundaries.map((b, bi) => (
+
+
{
+ e.stopPropagation();
+ setDrag({ type: "boundary", bi });
+ }}
+ />
+ {
+ e.stopPropagation();
+ deleteBoundary(bi);
+ }}
+ className="absolute -top-0 -right-2 p-0.5 rounded-full bg-black/70 text-muted hover:text-red-400"
+ title={t("editor.removeCut")}
+ >
+
+
+
+ ))}
+ {/* playhead */}
+
+
+
+ {/* hover-scrub thumbnail */}
+ {strip && hoverTime != null && (
+
+ )}
+
+
+ {/* selected-segment editor */}
+ {sel && (
+
+
{t("editor.segment", { n: selIdx + 1 })}
+
toggleKeep(sel.id)}
+ className={clsx(
+ "inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium",
+ sel.keep ? "bg-accent/15 text-accent" : "bg-surface text-muted"
+ )}
+ >
+ {sel.keep ? : }
+ {sel.keep ? t("editor.kept") : t("editor.dropped")}
+
+
+
+ {t("editor.in")}
+ {
+ const v = parseTc(e.target.value);
+ if (v != null && selIdx > 0) moveBoundary(selIdx - 1, v);
+ }}
+ className={numCls}
+ />
+
+
+ {t("editor.out")}
+ {
+ const v = parseTc(e.target.value);
+ if (v != null && selIdx < segments.length - 1) moveBoundary(selIdx, v);
+ }}
+ className={numCls}
+ />
+
+
{tc(sel.end - sel.start)}
+
+ )}
+
+ {/* output mode + summary */}
+
+
{t("editor.output.label")}:
+ {(["separate", "join"] as const).map((m) => (
+
setOutput(m)}
+ disabled={m === "join" && kept.length < 2}
+ className={clsx(
+ "px-3 py-1.5 rounded-lg border transition disabled:opacity-40",
+ output === m ? "border-accent bg-accent/10 text-accent" : "border-border text-muted hover:text-fg"
+ )}
+ >
+ {t(`editor.output.${m}`)}
+
+ ))}
+
+
+ {t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
+
+
+
+ {/* crop + precision */}
+
+
setCropOn((v) => !v)}
+ className={clsx(
+ "flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition",
+ cropOn ? "border-accent text-accent bg-accent/10" : "border-border text-muted hover:text-fg"
+ )}
+ >
+ {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
+
+ {!cropOn ? (
+
+ {(["accurate", "fast"] as const).map((m) => {
+ const on = (m === "accurate") === accurate;
+ return (
+
setAccurate(m === "accurate")}
+ className={clsx(
+ "flex-1 px-3 py-2 rounded-lg border text-left transition",
+ on ? "border-accent bg-accent/10" : "border-border hover:border-muted"
+ )}
+ title={t(`editor.${m}.hint`)}
+ >
+ {t(`editor.${m}.label`)}
+
+ );
+ })}
+
+ ) : (
+
{t("editor.cropReencode")}
+ )}
+
+
+
+ {t("editor.nameLabel")}
+ setName(e.target.value)} placeholder={defaultName} className={inputCls} />
+
+
+
+
{reencode ? t("editor.willReencode") : t("editor.willCopy")}
+
+
+ {t("common.cancel")}
+
+ create.mutate()}
+ disabled={!valid || create.isPending}
+ className={clsx(btn, "bg-accent text-accent-fg hover:opacity-90 inline-flex items-center gap-1.5")}
+ >
+ {create.isPending ? : }
+ {willJoin
+ ? t("editor.createJoin")
+ : kept.length > 1
+ ? t("editor.createN", { count: kept.length })
+ : t("editor.create")}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/WatchPage.tsx b/frontend/src/components/WatchPage.tsx
new file mode 100644
index 0000000..ecec257
--- /dev/null
+++ b/frontend/src/components/WatchPage.tsx
@@ -0,0 +1,178 @@
+import { useEffect, useState } from "react";
+import { Download, Lock, PlayCircle } from "lucide-react";
+
+// Standalone, login-free video page for a shared /watch/ link. Rendered OUTSIDE the app
+// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
+// self-contained i18n dict (the public viewer has no app language preference).
+
+type Meta = {
+ needs_password: boolean;
+ title?: string | null;
+ uploader?: string | null;
+ duration_s?: number | null;
+ allow_download?: boolean;
+ file_url?: string;
+};
+
+const STR = {
+ en: {
+ loading: "Loading…",
+ gone: "This link is no longer available.",
+ goneHint: "It may have expired or been revoked by the owner.",
+ passwordTitle: "This video is password-protected",
+ passwordPlaceholder: "Enter password",
+ watch: "Watch",
+ wrong: "Wrong password.",
+ download: "Download",
+ brand: "Shared via Siftlode",
+ },
+ hu: {
+ loading: "Betöltés…",
+ gone: "Ez a link már nem elérhető.",
+ goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
+ passwordTitle: "Ez a videó jelszóval védett",
+ passwordPlaceholder: "Add meg a jelszót",
+ watch: "Megnézem",
+ wrong: "Hibás jelszó.",
+ download: "Letöltés",
+ brand: "Megosztva a Siftlode-dal",
+ },
+ de: {
+ loading: "Wird geladen…",
+ gone: "Dieser Link ist nicht mehr verfügbar.",
+ goneHint: "Er ist möglicherweise abgelaufen oder wurde widerrufen.",
+ passwordTitle: "Dieses Video ist passwortgeschützt",
+ passwordPlaceholder: "Passwort eingeben",
+ watch: "Ansehen",
+ wrong: "Falsches Passwort.",
+ download: "Herunterladen",
+ brand: "Geteilt über Siftlode",
+ },
+} as const;
+
+const lang = ((navigator.language || "en").slice(0, 2) as keyof typeof STR) in STR
+ ? ((navigator.language || "en").slice(0, 2) as keyof typeof STR)
+ : "en";
+const T = STR[lang];
+
+export default function WatchPage() {
+ const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
+ const [status, setStatus] = useState<"loading" | "ready" | "password" | "gone">("loading");
+ const [meta, setMeta] = useState (null);
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState(null);
+ const [unlocking, setUnlocking] = useState(false);
+
+ useEffect(() => {
+ if (!token) {
+ setStatus("gone");
+ return;
+ }
+ fetch(`/api/public/watch/${encodeURIComponent(token)}`)
+ .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
+ .then((m: Meta) => {
+ if (m.needs_password) setStatus("password");
+ else {
+ setMeta(m);
+ setStatus("ready");
+ }
+ })
+ .catch(() => setStatus("gone"));
+ }, [token]);
+
+ const unlock = async () => {
+ setUnlocking(true);
+ setError(null);
+ try {
+ const r = await fetch(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password }),
+ });
+ if (r.status === 403) {
+ setError(T.wrong);
+ return;
+ }
+ if (!r.ok) {
+ setStatus("gone");
+ return;
+ }
+ setMeta(await r.json());
+ setStatus("ready");
+ } finally {
+ setUnlocking(false);
+ }
+ };
+
+ return (
+
+
+
+
+ {status === "loading" &&
{T.loading}
}
+
+ {status === "gone" && (
+
+
+
{T.gone}
+
{T.goneHint}
+
+
+ )}
+
+ {status === "password" && (
+
+
+
+ {T.passwordTitle}
+
+
setPassword(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && password && unlock()}
+ placeholder={T.passwordPlaceholder}
+ className="w-full rounded-lg bg-slate-800 border border-slate-700 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-teal-500/40"
+ />
+ {error &&
{error}
}
+
+ {T.watch}
+
+
+
+ )}
+
+ {status === "ready" && meta && (
+
+
+
+
+
+
+
{meta.title || "Video"}
+ {meta.uploader &&
{meta.uploader}
}
+
+ {meta.allow_download && (
+
+ {T.download}
+
+ )}
+
+
+ )}
+
+
{T.brand}
+
+ );
+}
diff --git a/frontend/src/i18n/locales/de/downloads.json b/frontend/src/i18n/locales/de/downloads.json
new file mode 100644
index 0000000..0054699
--- /dev/null
+++ b/frontend/src/i18n/locales/de/downloads.json
@@ -0,0 +1,177 @@
+{
+ "navLabel": "Downloads",
+ "button": {
+ "label": "Herunterladen",
+ "queued": "In Warteschlange",
+ "downloaded": "Heruntergeladen"
+ },
+ "dialog": {
+ "title": "Video herunterladen",
+ "profile": "Format",
+ "nameLabel": "Dateiname (optional)",
+ "namePlaceholder": "Leer lassen, um den Videotitel zu verwenden",
+ "submit": "Zu Downloads hinzufügen",
+ "added": "Zu Downloads hinzugefügt",
+ "view": "Öffnen",
+ "manage": "Formate verwalten"
+ },
+ "page": {
+ "title": "Downloads",
+ "subtitle": "Videos auf den Server laden und dann auf dein Gerät speichern.",
+ "addUrl": "YouTube-Link oder Video-ID einfügen…",
+ "add": "Hinzufügen",
+ "manage": "Formate verwalten"
+ },
+ "tabs": {
+ "queue": "Warteschlange",
+ "done": "Bibliothek",
+ "shared": "Mit mir geteilt",
+ "system": "System"
+ },
+ "status": {
+ "queued": "Wartet",
+ "running": "Lädt",
+ "paused": "Pausiert",
+ "done": "Fertig",
+ "error": "Fehlgeschlagen",
+ "canceled": "Abgebrochen",
+ "expired": "Abgelaufen"
+ },
+ "phase": {
+ "video": "Video wird geladen",
+ "audio": "Audio wird geladen",
+ "merging": "Zusammenführen",
+ "audio_extract": "Audio wird extrahiert",
+ "thumbnail": "Vorschaubild einbetten",
+ "sponsorblock": "Sponsoren entfernen",
+ "metadata": "Metadaten schreiben",
+ "processing": "Verarbeitung",
+ "editing": "Bearbeitung"
+ },
+ "actions": {
+ "pause": "Pause",
+ "resume": "Fortsetzen",
+ "cancel": "Abbrechen",
+ "delete": "Entfernen",
+ "retry": "Erneut",
+ "download": "Auf mein Gerät speichern",
+ "rename": "Umbenennen",
+ "share": "Teilen",
+ "edit": "Bearbeiten / schneiden",
+ "removeShared": "Aus meiner Liste entfernen"
+ },
+ "empty": {
+ "queue": "Die Warteschlange ist leer.",
+ "done": "Noch keine Downloads.",
+ "shared": "Es wurde nichts mit dir geteilt.",
+ "admin": "Auf dieser Instanz gibt es noch keine Downloads."
+ },
+ "usage": {
+ "title": "Dein Speicher",
+ "items": "{{used}} / {{max}} Elemente",
+ "unlimited": "Unbegrenzt"
+ },
+ "rename": {
+ "title": "Download umbenennen",
+ "label": "Anzeigename",
+ "hint": "Ändert nur den angezeigten und beim Speichern verwendeten Namen — die gespeicherte Datei behält ihren Namen.",
+ "save": "Speichern"
+ },
+ "share": {
+ "title": "Download teilen",
+ "label": "E-Mail des Empfängers",
+ "placeholder": "user@example.com",
+ "submit": "Teilen",
+ "done": "Geteilt mit {{email}}",
+ "failed": "Teilen fehlgeschlagen.",
+ "userSection": "Mit einem Nutzer teilen",
+ "userHint": "Erscheint bei ihm unter „Mit mir geteilt”.",
+ "pickPlaceholder": "Nutzer suchen…",
+ "noUsers": "Keine weiteren Nutzer.",
+ "linkSection": "Per Link teilen",
+ "linkHint": "Jeder mit dem Link kann zusehen — ohne Konto.",
+ "noLinks": "Noch keine Links.",
+ "newLink": "Link erstellen",
+ "createLink": "Erstellen",
+ "allowDownload": "Download erlauben",
+ "expiry": "Läuft ab",
+ "expiryNever": "Nie",
+ "days": "{{n}} Tage",
+ "password": "Passwort (optional)",
+ "copy": "Link kopieren",
+ "copyFailed": "Kopieren fehlgeschlagen.",
+ "revoke": "Widerrufen",
+ "revokeConfirm": "Der Link funktioniert sofort nicht mehr.",
+ "views": "{{n}} Aufrufe",
+ "expiresOn": "läuft ab {{date}}",
+ "hasPassword": "Passwort",
+ "downloadable": "herunterladbar"
+ },
+ "confirm": {
+ "cancelTitle": "Download abbrechen?",
+ "cancelBody": "Dies stoppt den Download und entfernt ihn aus deiner Warteschlange.",
+ "deleteTitle": "Download entfernen?",
+ "deleteBody": "Dies entfernt ihn aus deiner Liste. Die Datei kann bis zum Ablauf für andere Nutzer im Cache bleiben.",
+ "removeSharedTitle": "Aus deiner Liste entfernen?",
+ "removeSharedBody": "Entfernt es nur aus deiner geteilten Liste — die Datei des Besitzers wird nicht gelöscht."
+ },
+ "profiles": {
+ "manage": "Formate verwalten",
+ "title": "Download-Formate",
+ "builtin": "Integriert",
+ "yours": "Deine Formate",
+ "new": "Neues Format",
+ "name": "Name",
+ "namePlaceholder": "Mein Format",
+ "mode": "Inhalt",
+ "modeAv": "Video + Audio",
+ "modeV": "Nur Video",
+ "modeA": "Nur Audio",
+ "quality": "Max. Qualität",
+ "best": "Beste",
+ "container": "Container",
+ "audioFormat": "Audioformat",
+ "vcodec": "Video-Codec",
+ "any": "Beliebig",
+ "embedSubs": "Untertitel einbetten",
+ "embedChapters": "Kapitel einbetten",
+ "embedThumbnail": "Vorschaubild einbetten",
+ "sponsorblock": "Sponsor-Segmente überspringen (SponsorBlock)",
+ "save": "Speichern",
+ "create": "Erstellen",
+ "delete": "Löschen",
+ "deleteConfirm": "Das Format „{{name}}“ löschen?"
+ },
+ "admin": {
+ "storageTitle": "Speicher",
+ "readyFiles": "Fertige Dateien",
+ "totalSize": "Gesamtgröße",
+ "cap": "Cache-Limit",
+ "noCap": "kein Limit",
+ "perUser": "Speicher pro Nutzer",
+ "user": "Nutzer",
+ "footprint": "Belegung",
+ "editQuota": "Kontingent bearbeiten",
+ "quotaTitle": "Download-Kontingent — {{email}}",
+ "maxBytes": "Speicherlimit (GB)",
+ "maxJobs": "Max. Downloads",
+ "maxConcurrent": "Max. gleichzeitig",
+ "unlimited": "Unbegrenzt (Speicherlimit umgehen)",
+ "reset": "Auf Standard zurücksetzen",
+ "save": "Speichern",
+ "custom": "individuell",
+ "default": "Standard",
+ "quotaPickPlaceholder": "Kontingent für einen Nutzer festlegen…",
+ "noFootprint": "Noch keine Downloads."
+ },
+ "cols": {
+ "title": "Titel",
+ "channel": "Kanal",
+ "format": "Format",
+ "size": "Größe",
+ "status": "Status",
+ "added": "Hinzugefügt",
+ "user": "Nutzer"
+ },
+ "clipBadge": "Clip"
+}
diff --git a/frontend/src/i18n/locales/de/editor.json b/frontend/src/i18n/locales/de/editor.json
new file mode 100644
index 0000000..e82903c
--- /dev/null
+++ b/frontend/src/i18n/locales/de/editor.json
@@ -0,0 +1,45 @@
+{
+ "title": "Bearbeiten: „{{name}}“",
+ "playPause": "Wiedergabe / Pause",
+ "setIn": "Start hier",
+ "setOut": "Ende hier",
+ "in": "Start",
+ "out": "Ende",
+ "selected": "Auswahl",
+ "cropOff": "Zuschneiden",
+ "cropOn": "Zuschneiden aktiv",
+ "cropReencode": "Zuschneiden kodiert immer neu (bildgenau).",
+ "split": "Aufteilen in",
+ "accurate": {
+ "label": "Genauer Schnitt",
+ "hint": "Bildgenau. Kodiert den Clip neu."
+ },
+ "fast": {
+ "label": "Schneller Schnitt",
+ "hint": "Sofort, schneidet aber an Keyframes (±1–2 s)."
+ },
+ "nameLabel": "Clip-Name (optional)",
+ "willReencode": "Wird neu kodiert",
+ "willCopy": "Sofort (Stream-Kopie)",
+ "create": "Clip erstellen",
+ "createN": "{{count}} Clips erstellen",
+ "created_one": "{{count}} Clip zu deinen Downloads hinzugefügt",
+ "created_other": "{{count}} Clips zu deinen Downloads hinzugefügt",
+ "failed": "Clip konnte nicht erstellt werden.",
+ "splitHere": "Hier teilen",
+ "output": {
+ "label": "Ausgabe",
+ "separate": "Einzelne Dateien",
+ "join": "Zu einer zusammenfügen"
+ },
+ "segment": "Segment {{n}}",
+ "keep": "Behalten",
+ "drop": "Verwerfen",
+ "kept": "Behalten",
+ "dropped": "Verworfen",
+ "keptSeg": "Behaltenes Segment",
+ "droppedSeg": "Verworfenes Segment",
+ "removeCut": "Schnitt entfernen",
+ "keptSummary": "{{n}} behalten · {{dur}}",
+ "createJoin": "Zusammengefügten Clip erstellen"
+}
diff --git a/frontend/src/i18n/locales/de/scheduler.json b/frontend/src/i18n/locales/de/scheduler.json
index 8c68143..4fca278 100644
--- a/frontend/src/i18n/locales/de/scheduler.json
+++ b/frontend/src/i18n/locales/de/scheduler.json
@@ -67,7 +67,8 @@
"subscriptions": "Importiert die YouTube-Abos jedes Nutzers neu. Fällt er aus, werden auf YouTube hinzugefügte oder entfernte Abos hier nicht übernommen.",
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
- "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen."
+ "explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
+ "download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben."
},
"jobs": {
"rss_poll": "RSS-Abfrage (neue Uploads)",
@@ -79,7 +80,8 @@
"playlist_sync": "YouTube-Wiedergabelisten-Sync",
"maintenance": "Wartung + Validierung",
"demo_reset": "Demo-Konto-Reset",
- "explore_cleanup": "Bereinigung erkundeter Kanäle"
+ "explore_cleanup": "Bereinigung erkundeter Kanäle",
+ "download_gc": "Download-Bereinigung"
},
"queue": {
"recentPending": "Zu synchronisierende Kanäle",
diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json
new file mode 100644
index 0000000..06db1f6
--- /dev/null
+++ b/frontend/src/i18n/locales/en/downloads.json
@@ -0,0 +1,177 @@
+{
+ "navLabel": "Downloads",
+ "button": {
+ "label": "Download",
+ "queued": "In queue",
+ "downloaded": "Downloaded"
+ },
+ "dialog": {
+ "title": "Download video",
+ "profile": "Format",
+ "nameLabel": "File name (optional)",
+ "namePlaceholder": "Leave blank to use the video title",
+ "submit": "Add to downloads",
+ "added": "Added to downloads",
+ "view": "View",
+ "manage": "Manage formats"
+ },
+ "page": {
+ "title": "Downloads",
+ "subtitle": "Download videos to the server, then save them to your device.",
+ "addUrl": "Paste a YouTube link or video id…",
+ "add": "Add",
+ "manage": "Manage formats"
+ },
+ "tabs": {
+ "queue": "Queue",
+ "done": "Library",
+ "shared": "Shared with me",
+ "system": "System"
+ },
+ "status": {
+ "queued": "Queued",
+ "running": "Downloading",
+ "paused": "Paused",
+ "done": "Ready",
+ "error": "Failed",
+ "canceled": "Canceled",
+ "expired": "Expired"
+ },
+ "phase": {
+ "video": "Downloading video",
+ "audio": "Downloading audio",
+ "merging": "Merging",
+ "audio_extract": "Extracting audio",
+ "thumbnail": "Embedding thumbnail",
+ "sponsorblock": "Removing sponsors",
+ "metadata": "Writing metadata",
+ "processing": "Processing",
+ "editing": "Editing"
+ },
+ "actions": {
+ "pause": "Pause",
+ "resume": "Resume",
+ "cancel": "Cancel",
+ "delete": "Remove",
+ "retry": "Retry",
+ "download": "Save to my device",
+ "rename": "Rename",
+ "share": "Share",
+ "edit": "Edit / trim",
+ "removeShared": "Remove from my list"
+ },
+ "empty": {
+ "queue": "Nothing in the queue.",
+ "done": "No downloads yet.",
+ "shared": "Nothing has been shared with you.",
+ "admin": "No downloads on this instance yet."
+ },
+ "usage": {
+ "title": "Your storage",
+ "items": "{{used}} / {{max}} items",
+ "unlimited": "Unlimited"
+ },
+ "rename": {
+ "title": "Rename download",
+ "label": "Display name",
+ "hint": "Only changes the name you see and download with — the stored file keeps its own name.",
+ "save": "Save"
+ },
+ "share": {
+ "title": "Share download",
+ "label": "Recipient email",
+ "placeholder": "user@example.com",
+ "submit": "Share",
+ "done": "Shared with {{email}}",
+ "failed": "Couldn’t share.",
+ "userSection": "Share with a user",
+ "userHint": "They’ll find it under “Shared with me”.",
+ "pickPlaceholder": "Search a user…",
+ "noUsers": "No other users yet.",
+ "linkSection": "Share a link",
+ "linkHint": "Anyone with the link can watch — no account needed.",
+ "noLinks": "No links yet.",
+ "newLink": "Create a link",
+ "createLink": "Create link",
+ "allowDownload": "Allow download",
+ "expiry": "Expires",
+ "expiryNever": "Never",
+ "days": "{{n}} days",
+ "password": "Password (optional)",
+ "copy": "Copy link",
+ "copyFailed": "Couldn’t copy.",
+ "revoke": "Revoke",
+ "revokeConfirm": "This link will stop working immediately.",
+ "views": "{{n}} views",
+ "expiresOn": "expires {{date}}",
+ "hasPassword": "password",
+ "downloadable": "downloadable"
+ },
+ "confirm": {
+ "cancelTitle": "Cancel download?",
+ "cancelBody": "This stops the download and removes it from your queue.",
+ "deleteTitle": "Remove download?",
+ "deleteBody": "This removes it from your list. The file may stay cached for other users until it expires.",
+ "removeSharedTitle": "Remove from your list?",
+ "removeSharedBody": "This removes it from your shared list only — it won't delete the owner's file."
+ },
+ "profiles": {
+ "manage": "Manage formats",
+ "title": "Download formats",
+ "builtin": "Built-in",
+ "yours": "Your formats",
+ "new": "New format",
+ "name": "Name",
+ "namePlaceholder": "My format",
+ "mode": "Content",
+ "modeAv": "Video + audio",
+ "modeV": "Video only",
+ "modeA": "Audio only",
+ "quality": "Max quality",
+ "best": "Best",
+ "container": "Container",
+ "audioFormat": "Audio format",
+ "vcodec": "Video codec",
+ "any": "Any",
+ "embedSubs": "Embed subtitles",
+ "embedChapters": "Embed chapters",
+ "embedThumbnail": "Embed thumbnail",
+ "sponsorblock": "Skip sponsor segments (SponsorBlock)",
+ "save": "Save",
+ "create": "Create",
+ "delete": "Delete",
+ "deleteConfirm": "Delete the format “{{name}}”?"
+ },
+ "admin": {
+ "storageTitle": "Storage",
+ "readyFiles": "Ready files",
+ "totalSize": "Total size",
+ "cap": "Cache cap",
+ "noCap": "no cap",
+ "perUser": "Per-user footprint",
+ "user": "User",
+ "footprint": "Footprint",
+ "editQuota": "Edit quota",
+ "quotaTitle": "Download quota — {{email}}",
+ "maxBytes": "Storage limit (GB)",
+ "maxJobs": "Max downloads",
+ "maxConcurrent": "Max concurrent",
+ "unlimited": "Unlimited (bypass storage limit)",
+ "reset": "Reset to default",
+ "save": "Save",
+ "custom": "custom",
+ "default": "default",
+ "quotaPickPlaceholder": "Set a user's quota…",
+ "noFootprint": "No downloads yet."
+ },
+ "cols": {
+ "title": "Title",
+ "channel": "Channel",
+ "format": "Format",
+ "size": "Size",
+ "status": "Status",
+ "added": "Added",
+ "user": "User"
+ },
+ "clipBadge": "Clip"
+}
diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json
new file mode 100644
index 0000000..2429f0a
--- /dev/null
+++ b/frontend/src/i18n/locales/en/editor.json
@@ -0,0 +1,45 @@
+{
+ "title": "Edit “{{name}}”",
+ "playPause": "Play / pause",
+ "setIn": "Set start",
+ "setOut": "Set end",
+ "in": "Start",
+ "out": "End",
+ "selected": "Selection",
+ "cropOff": "Add crop",
+ "cropOn": "Cropping",
+ "cropReencode": "Cropping always re-encodes (frame-accurate).",
+ "split": "Split into",
+ "accurate": {
+ "label": "Precise cut",
+ "hint": "Frame-accurate. Re-encodes the clip."
+ },
+ "fast": {
+ "label": "Fast cut",
+ "hint": "Instant, but cuts snap to keyframes (±1–2s)."
+ },
+ "nameLabel": "Clip name (optional)",
+ "willReencode": "Will re-encode",
+ "willCopy": "Instant (stream copy)",
+ "create": "Create clip",
+ "createN": "Create {{count}} clips",
+ "created_one": "{{count}} clip added to your downloads",
+ "created_other": "{{count}} clips added to your downloads",
+ "failed": "Couldn’t create the clip.",
+ "splitHere": "Split here",
+ "output": {
+ "label": "Output",
+ "separate": "Separate files",
+ "join": "Join into one"
+ },
+ "segment": "Segment {{n}}",
+ "keep": "Keep",
+ "drop": "Drop",
+ "kept": "Kept",
+ "dropped": "Dropped",
+ "keptSeg": "Kept segment",
+ "droppedSeg": "Dropped segment",
+ "removeCut": "Remove cut",
+ "keptSummary": "{{n}} kept · {{dur}}",
+ "createJoin": "Create joined clip"
+}
diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json
index 9106858..c8c8fce 100644
--- a/frontend/src/i18n/locales/en/scheduler.json
+++ b/frontend/src/i18n/locales/en/scheduler.json
@@ -67,7 +67,8 @@
"subscriptions": "Re-imports every user's YouTube subscriptions. If it stops, channels subscribed to or dropped on YouTube aren't reflected here.",
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
- "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger."
+ "explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
+ "download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -79,7 +80,8 @@
"playlist_sync": "YouTube playlist sync",
"maintenance": "Maintenance + validation",
"demo_reset": "Demo account reset",
- "explore_cleanup": "Explored-channel cleanup"
+ "explore_cleanup": "Explored-channel cleanup",
+ "download_gc": "Download cleanup"
},
"queue": {
"recentPending": "Channels to sync",
diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json
new file mode 100644
index 0000000..4091429
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/downloads.json
@@ -0,0 +1,177 @@
+{
+ "navLabel": "Letöltések",
+ "button": {
+ "label": "Letöltés",
+ "queued": "Sorban",
+ "downloaded": "Letöltve"
+ },
+ "dialog": {
+ "title": "Videó letöltése",
+ "profile": "Formátum",
+ "nameLabel": "Fájlnév (opcionális)",
+ "namePlaceholder": "Üresen hagyva a videó címét használja",
+ "submit": "Hozzáadás a letöltésekhez",
+ "added": "Hozzáadva a letöltésekhez",
+ "view": "Megnyitás",
+ "manage": "Formátumok kezelése"
+ },
+ "page": {
+ "title": "Letöltések",
+ "subtitle": "Töltsd le a videókat a szerverre, majd mentsd a saját gépedre.",
+ "addUrl": "Illessz be egy YouTube linket vagy videó azonosítót…",
+ "add": "Hozzáad",
+ "manage": "Formátumok kezelése"
+ },
+ "tabs": {
+ "queue": "Sor",
+ "done": "Könyvtár",
+ "shared": "Velem megosztva",
+ "system": "Rendszer"
+ },
+ "status": {
+ "queued": "Várakozik",
+ "running": "Letöltés",
+ "paused": "Szüneteltetve",
+ "done": "Kész",
+ "error": "Sikertelen",
+ "canceled": "Megszakítva",
+ "expired": "Lejárt"
+ },
+ "phase": {
+ "video": "Videósáv letöltése",
+ "audio": "Hangsáv letöltése",
+ "merging": "Összefűzés",
+ "audio_extract": "Hang kinyerése",
+ "thumbnail": "Bélyegkép beágyazása",
+ "sponsorblock": "Szponzorok eltávolítása",
+ "metadata": "Metaadat írása",
+ "processing": "Feldolgozás",
+ "editing": "Szerkesztés"
+ },
+ "actions": {
+ "pause": "Szünet",
+ "resume": "Folytatás",
+ "cancel": "Megszakítás",
+ "delete": "Eltávolítás",
+ "retry": "Újra",
+ "download": "Mentés a gépemre",
+ "rename": "Átnevezés",
+ "share": "Megosztás",
+ "edit": "Szerkesztés / vágás",
+ "removeShared": "Eltávolítás a listámból"
+ },
+ "empty": {
+ "queue": "A sor üres.",
+ "done": "Még nincs letöltés.",
+ "shared": "Még nem osztottak meg veled semmit.",
+ "admin": "Ezen a példányon még nincs letöltés."
+ },
+ "usage": {
+ "title": "Tárhelyed",
+ "items": "{{used}} / {{max}} elem",
+ "unlimited": "Korlátlan"
+ },
+ "rename": {
+ "title": "Letöltés átnevezése",
+ "label": "Megjelenített név",
+ "hint": "Csak a megjelenített és letöltéskor használt nevet módosítja — a tárolt fájl neve nem változik.",
+ "save": "Mentés"
+ },
+ "share": {
+ "title": "Letöltés megosztása",
+ "label": "Címzett e-mail címe",
+ "placeholder": "user@example.com",
+ "submit": "Megosztás",
+ "done": "Megosztva vele: {{email}}",
+ "failed": "A megosztás nem sikerült.",
+ "userSection": "Megosztás felhasználóval",
+ "userHint": "A „Velem megosztott” fülön találja meg.",
+ "pickPlaceholder": "Keress egy felhasználót…",
+ "noUsers": "Nincs más felhasználó.",
+ "linkSection": "Megosztás linkkel",
+ "linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
+ "noLinks": "Még nincs link.",
+ "newLink": "Link létrehozása",
+ "createLink": "Létrehozás",
+ "allowDownload": "Letöltés engedélyezése",
+ "expiry": "Lejárat",
+ "expiryNever": "Soha",
+ "days": "{{n}} nap",
+ "password": "Jelszó (opcionális)",
+ "copy": "Link másolása",
+ "copyFailed": "Nem sikerült a másolás.",
+ "revoke": "Visszavonás",
+ "revokeConfirm": "A link azonnal használhatatlanná válik.",
+ "views": "{{n}} megtekintés",
+ "expiresOn": "lejár: {{date}}",
+ "hasPassword": "jelszó",
+ "downloadable": "letölthető"
+ },
+ "confirm": {
+ "cancelTitle": "Megszakítod a letöltést?",
+ "cancelBody": "Ez leállítja a letöltést és eltávolítja a sorból.",
+ "deleteTitle": "Eltávolítod a letöltést?",
+ "deleteBody": "Ez eltávolítja a listádról. A fájl a lejáratáig még a gyorsítótárban maradhat mások számára.",
+ "removeSharedTitle": "Eltávolítod a listádból?",
+ "removeSharedBody": "Csak a te megosztott listádból tűnik el — a tulajdonos fájlját nem törli."
+ },
+ "profiles": {
+ "manage": "Formátumok kezelése",
+ "title": "Letöltési formátumok",
+ "builtin": "Beépített",
+ "yours": "Saját formátumaid",
+ "new": "Új formátum",
+ "name": "Név",
+ "namePlaceholder": "Saját formátum",
+ "mode": "Tartalom",
+ "modeAv": "Videó + hang",
+ "modeV": "Csak videó",
+ "modeA": "Csak hang",
+ "quality": "Max. minőség",
+ "best": "Legjobb",
+ "container": "Konténer",
+ "audioFormat": "Hangformátum",
+ "vcodec": "Videó codec",
+ "any": "Bármelyik",
+ "embedSubs": "Feliratok beágyazása",
+ "embedChapters": "Fejezetek beágyazása",
+ "embedThumbnail": "Bélyegkép beágyazása",
+ "sponsorblock": "Szponzorált szakaszok kihagyása (SponsorBlock)",
+ "save": "Mentés",
+ "create": "Létrehozás",
+ "delete": "Törlés",
+ "deleteConfirm": "Törlöd a(z) „{{name}}” formátumot?"
+ },
+ "admin": {
+ "storageTitle": "Tárhely",
+ "readyFiles": "Kész fájlok",
+ "totalSize": "Teljes méret",
+ "cap": "Gyorsítótár-korlát",
+ "noCap": "nincs korlát",
+ "perUser": "Felhasználónkénti tárhelyhasználat",
+ "user": "Felhasználó",
+ "footprint": "Használat",
+ "editQuota": "Kvóta szerkesztése",
+ "quotaTitle": "Letöltési kvóta — {{email}}",
+ "maxBytes": "Tárhelykorlát (GB)",
+ "maxJobs": "Max. letöltés",
+ "maxConcurrent": "Max. egyidejű",
+ "unlimited": "Korlátlan (tárhelykorlát kikapcsolása)",
+ "reset": "Visszaállítás alapértékre",
+ "save": "Mentés",
+ "custom": "egyedi",
+ "default": "alapértelmezett",
+ "quotaPickPlaceholder": "Kvóta beállítása egy felhasználónak…",
+ "noFootprint": "Még nincs letöltés."
+ },
+ "cols": {
+ "title": "Cím",
+ "channel": "Csatorna",
+ "format": "Formátum",
+ "size": "Méret",
+ "status": "Állapot",
+ "added": "Hozzáadva",
+ "user": "Felhasználó"
+ },
+ "clipBadge": "Klip"
+}
diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json
new file mode 100644
index 0000000..2030538
--- /dev/null
+++ b/frontend/src/i18n/locales/hu/editor.json
@@ -0,0 +1,45 @@
+{
+ "title": "Szerkesztés: „{{name}}”",
+ "playPause": "Lejátszás / szünet",
+ "setIn": "Kezdet itt",
+ "setOut": "Vég itt",
+ "in": "Kezdet",
+ "out": "Vég",
+ "selected": "Kijelölés",
+ "cropOff": "Vágókeret",
+ "cropOn": "Vágókeret bekapcsolva",
+ "cropReencode": "A vágókeret mindig újrakódol (frame-pontos).",
+ "split": "Feldarabolás",
+ "accurate": {
+ "label": "Pontos vágás",
+ "hint": "Frame-pontos. Újrakódolja a klipet."
+ },
+ "fast": {
+ "label": "Gyors vágás",
+ "hint": "Azonnali, de kulcskockára ugrik (±1–2 mp)."
+ },
+ "nameLabel": "Klip neve (opcionális)",
+ "willReencode": "Újrakódolás lesz",
+ "willCopy": "Azonnali (másolás)",
+ "create": "Klip létrehozása",
+ "createN": "{{count}} klip létrehozása",
+ "created_one": "{{count}} klip a letöltésekhez adva",
+ "created_other": "{{count}} klip a letöltésekhez adva",
+ "failed": "A klip létrehozása nem sikerült.",
+ "splitHere": "Vágás itt",
+ "output": {
+ "label": "Kimenet",
+ "separate": "Külön fájlok",
+ "join": "Egy fájlba fűzve"
+ },
+ "segment": "{{n}}. szegmens",
+ "keep": "Megtart",
+ "drop": "Eldob",
+ "kept": "Megtartva",
+ "dropped": "Eldobva",
+ "keptSeg": "Megtartott szegmens",
+ "droppedSeg": "Eldobott szegmens",
+ "removeCut": "Vágás törlése",
+ "keptSummary": "{{n}} megtartva · {{dur}}",
+ "createJoin": "Összefűzött klip"
+}
diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json
index dc90f61..ba01c02 100644
--- a/frontend/src/i18n/locales/hu/scheduler.json
+++ b/frontend/src/i18n/locales/hu/scheduler.json
@@ -67,7 +67,8 @@
"subscriptions": "Újraimportálja minden felhasználó YouTube-feliratkozásait. Ha leáll, a YouTube-on felvett vagy törölt feliratkozások nem tükröződnek itt.",
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
- "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak."
+ "explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
+ "download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -79,7 +80,8 @@
"playlist_sync": "YouTube lejátszási listák szinkronja",
"maintenance": "Karbantartás + ellenőrzés",
"demo_reset": "Demo fiók visszaállítása",
- "explore_cleanup": "Felfedezett csatornák takarítása"
+ "explore_cleanup": "Felfedezett csatornák takarítása",
+ "download_gc": "Letöltések takarítása"
},
"queue": {
"recentPending": "Szinkronra váró csatorna",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 94d297f..0e60943 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -623,6 +623,137 @@ export interface AdminUserRow {
created_at: string | null;
}
+// --- download center ---
+export interface DownloadSpec {
+ mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
+ max_height: number | null;
+ container: string | null;
+ audio_format: string | null;
+ vcodec: string | null;
+ embed_subs: boolean;
+ embed_chapters: boolean;
+ embed_thumbnail: boolean;
+ sponsorblock: boolean;
+}
+
+export interface DownloadProfile {
+ id: number;
+ name: string;
+ spec: DownloadSpec;
+ is_builtin: boolean;
+ sort_order: number;
+}
+
+export interface DownloadAsset {
+ id: number;
+ status: string;
+ title: string | null;
+ uploader: string | null;
+ upload_date: string | null;
+ duration_s: number | null;
+ size_bytes: number | null;
+ width: number | null;
+ height: number | null;
+ container: string | null;
+ thumbnail_url: string | null;
+ expires_at: string | null;
+}
+
+export type DownloadStatus =
+ | "queued"
+ | "running"
+ | "paused"
+ | "done"
+ | "error"
+ | "canceled";
+
+// Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into
+// one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes).
+export interface EditSpec {
+ trim?: { start_s: number; end_s?: number };
+ segments?: { start_s: number; end_s: number }[];
+ crop?: { x: number; y: number; w: number; h: number };
+ accurate?: boolean;
+}
+
+export interface StoryboardMeta {
+ available: boolean;
+ cols?: number;
+ rows?: number;
+ count?: number;
+ interval_s?: number;
+ url?: string;
+}
+
+export interface DownloadJob {
+ id: number;
+ status: DownloadStatus;
+ progress: number;
+ speed_bps: number | null;
+ eta_s: number | null;
+ phase: string | null;
+ error: string | null;
+ queue_pos: number;
+ created_at: string | null;
+ source_kind: string;
+ source_ref: string;
+ profile_id: number | null;
+ spec: DownloadSpec;
+ display_name: string | null;
+ job_kind?: string; // "download" (default) | "edit"
+ source_job_id?: number | null; // parent download an edit was cut from
+ edit_spec?: EditSpec | null;
+ can_download: boolean;
+ expired: boolean;
+ asset: DownloadAsset | null;
+ shared?: boolean;
+ user_email?: string;
+}
+
+export interface ShareRecipient {
+ id: number;
+ email: string;
+ display_name: string | null;
+}
+
+export interface ShareLink {
+ id: number;
+ token: string;
+ url: string; // path like "/watch/" — prepend window.location.origin to share
+ allow_download: boolean;
+ has_password: boolean;
+ expires_at: string | null;
+ view_count: number;
+ created_at: string | null;
+}
+
+export interface DownloadUsage {
+ footprint_bytes: number;
+ active_jobs: number;
+ running_jobs: number;
+ max_bytes: number;
+ max_concurrent: number;
+ max_jobs: number;
+ unlimited: boolean;
+}
+
+export interface AdminStorage {
+ ready_files: number;
+ total_bytes: number;
+ total_assets: number;
+ total_cap_bytes: number;
+ per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
+}
+
+export interface AdminDownloadQuota {
+ user_id: number;
+ custom: boolean;
+ max_bytes: number;
+ max_concurrent: number;
+ max_jobs: number;
+ unlimited: boolean;
+}
+
export const api = {
me: (): Promise => req("/api/me"),
accounts: (): Promise => req("/api/me/accounts"),
@@ -914,6 +1045,97 @@ export const api = {
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }),
+
+ // --- download center ---
+ downloadProfiles: (): Promise => req("/api/downloads/profiles"),
+ createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise =>
+ req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
+ updateDownloadProfile: (
+ id: number,
+ patch: { name?: string; spec?: DownloadSpec }
+ ): Promise =>
+ req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ deleteDownloadProfile: (id: number) =>
+ req(`/api/downloads/profiles/${id}`, { method: "DELETE" }),
+
+ enqueueDownload: (body: {
+ source: string;
+ profile_id?: number;
+ spec?: DownloadSpec;
+ display_name?: string;
+ }): Promise =>
+ req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
+ enqueueEdit: (body: {
+ source_job_id: number;
+ edit_spec: EditSpec;
+ display_name?: string;
+ }): Promise =>
+ req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
+ downloadStoryboard: (id: number): Promise =>
+ req(`/api/downloads/${id}/storyboard`),
+ downloads: (): Promise => req("/api/downloads"),
+ downloadsShared: (): Promise => req("/api/downloads/shared"),
+ // Remove a shared-with-me item from your list (deletes only your share grant, not the file).
+ removeSharedDownload: (jobId: number): Promise<{ removed: number }> =>
+ req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }),
+ downloadUsage: (): Promise => req("/api/downloads/usage"),
+ downloadIndex: (): Promise> => req("/api/downloads/index"),
+ pauseDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
+ resumeDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
+ cancelDownload: (id: number): Promise =>
+ req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }),
+ deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }),
+ renameDownload: (id: number, display_name: string): Promise =>
+ req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify({ display_name }) }),
+ shareDownload: (id: number, email: string): Promise<{ shared_with: string }> =>
+ req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }),
+ unshareDownload: (id: number, email: string) =>
+ req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }),
+ // Registered users this user can share with (for the internal picker).
+ shareRecipients: (): Promise => req("/api/downloads/recipients"),
+ // Public watch links (share by link).
+ downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`),
+ createDownloadLink: (
+ jobId: number,
+ body: { allow_download?: boolean; expires_days?: number | null; password?: string }
+ ): Promise =>
+ req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }),
+ updateDownloadLink: (
+ linkId: number,
+ patch: { allow_download?: boolean; expires_days?: number | null; password?: string }
+ ): Promise =>
+ req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
+ revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
+ req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
+ // A plain navigation can't send the X-Siftlode-Account header, so pass the active
+ // account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the
+ // download resolves to the session-default account and 404s for a per-tab account's file.
+ downloadFileUrl: (id: number): string => {
+ const a = getActiveAccount();
+ return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`;
+ },
+ // Filmstrip sprite (an /background src → direct nav, so it needs the ?account= wallet gate).
+ storyboardImageUrl: (id: number): string => {
+ const a = getActiveAccount();
+ return a != null
+ ? `/api/downloads/${id}/storyboard.jpg?account=${a}`
+ : `/api/downloads/${id}/storyboard.jpg`;
+ },
+
+ // admin
+ adminDownloads: (): Promise => req("/api/admin/downloads"),
+ adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"),
+ adminGetDownloadQuota: (userId: number): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`),
+ adminSetDownloadQuota: (
+ userId: number,
+ patch: Partial>
+ ): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
+ adminResetDownloadQuota: (userId: number): Promise =>
+ req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
};
export { HttpError };
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index e0bb0d4..04b7700 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -5,6 +5,26 @@ export function relativeTime(iso: string | null): string {
return relativeFromMs(new Date(iso).getTime());
}
+/** Human file size, binary units (1024). e.g. 501747 -> "490 KB", 5368709120 -> "5.0 GB". */
+export function formatBytes(bytes: number | null | undefined): string {
+ const n = Number(bytes);
+ if (!n || n < 0) return "0 B";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let i = 0;
+ let v = n;
+ while (v >= 1024 && i < units.length - 1) {
+ v /= 1024;
+ i++;
+ }
+ return `${v >= 100 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
+}
+
+/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
+export function formatSpeed(bps: number | null | undefined): string {
+ if (!bps) return "";
+ return `${formatBytes(bps)}/s`;
+}
+
/** Relative-time label from an epoch-millis timestamp (the ms-based half of relativeTime, for
* client-side notifications whose timestamps are already numbers). One implementation, one
* set of `time.*` strings — shared instead of re-implemented per component. */
diff --git a/frontend/src/lib/nav.ts b/frontend/src/lib/nav.ts
new file mode 100644
index 0000000..e0bb440
--- /dev/null
+++ b/frontend/src/lib/nav.ts
@@ -0,0 +1,14 @@
+import type { Page } from "./urlState";
+
+// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
+// Center) can switch pages without threading setPage through the whole tree. App registers
+// its setPage on mount; navigateTo is a no-op until then.
+let _navigate: ((p: Page) => void) | null = null;
+
+export function setNavigator(fn: (p: Page) => void): void {
+ _navigate = fn;
+}
+
+export function navigateTo(p: Page): void {
+ _navigate?.(p);
+}
diff --git a/frontend/src/lib/pageMeta.ts b/frontend/src/lib/pageMeta.ts
new file mode 100644
index 0000000..19dd9d9
--- /dev/null
+++ b/frontend/src/lib/pageMeta.ts
@@ -0,0 +1,32 @@
+import type { Page } from "./urlState";
+
+// The i18n key for a page's human title — shared by the top-bar header and the browser tab title
+// so the two never drift. Keep in sync with the nav modules in NavSidebar / App's page switch.
+export function pageTitleKey(page: Page): string {
+ switch (page) {
+ case "feed":
+ return "header.account.feed";
+ case "channels":
+ return "header.channelManager";
+ case "playlists":
+ return "header.account.playlists";
+ case "notifications":
+ return "inbox.navLabel";
+ case "messages":
+ return "messages.navLabel";
+ case "downloads":
+ return "downloads.navLabel";
+ case "stats":
+ return "header.usageStats";
+ case "scheduler":
+ return "header.scheduler";
+ case "config":
+ return "header.configuration";
+ case "users":
+ return "header.users";
+ case "settings":
+ return "settings.title";
+ default:
+ return "header.account.feed";
+ }
+}
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index 5d5d5eb..abf8efa 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -14,6 +14,20 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
+ {
+ version: "0.22.0",
+ date: "2026-07-04",
+ summary: "The Download Center — save videos to the server, edit them into clips, and share them.",
+ features: [
+ "Download Center: save any video (or a search result) to the server with yt-dlp, laid out Plex-style with a poster + info file. Choose a built-in format preset or make your own, keep an eye on your per-user storage quota, and save the finished file to your device.",
+ "Built-in video editor: trim, crop, and cut a download into segments — keep the parts you want as separate clips or join them into one file. Pick a precise (frame-accurate) or fast cut, with a scrub timeline and a hover preview.",
+ "Sharing: share a download with another user (it appears in their “Shared with me”, where they can edit their own copy or remove it), or hand out a public watch link that plays on a clean, login-free page — with an optional password, an expiry, and a stream-only vs downloadable toggle.",
+ "Tidier video titles across the feed, search and downloads — clickbait ALL-CAPS and trailing hashtag clutter are cleaned up for display (the original title is kept underneath).",
+ ],
+ fixes: [
+ "The browser tab now carries an app icon and shows the section you're on; clicking the Siftlode logo returns you to the feed.",
+ ],
+ },
{
version: "0.21.0",
date: "2026-07-02",
diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts
index 36b0e11..9265993 100644
--- a/frontend/src/lib/urlState.ts
+++ b/frontend/src/lib/urlState.ts
@@ -100,6 +100,7 @@ export const PAGES = [
"users",
"notifications",
"messages",
+ "downloads",
] as const;
export type Page = (typeof PAGES)[number];
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 88bb2c6..8923e7d 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import PrivacyPolicy from "./components/legal/PrivacyPolicy";
import Terms from "./components/legal/Terms";
+import WatchPage from "./components/WatchPage";
import "./i18n";
import "./index.css";
@@ -19,7 +20,8 @@ const queryClient = new QueryClient({
const path = window.location.pathname;
const root =
path === "/privacy" ? :
- path === "/terms" ? : (
+ path === "/terms" ? :
+ path.startsWith("/watch/") ? : (
diff --git a/install.ps1 b/install.ps1
index bd2f2e9..c3069de 100644
--- a/install.ps1
+++ b/install.ps1
@@ -28,6 +28,10 @@ POSTGRES_PASSWORD=$pgPass
SECRET_KEY=$secretKey
TOKEN_ENCRYPTION_KEY=$fernet
OAUTH_REDIRECT_URL=$url/auth/callback
+
+# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead
+# of a Docker volume. Must be writable by uid 1000 (chown -R 1000:1000 ).
+# DOWNLOAD_HOST_PATH=/mnt/media/youtube
"@ | Set-Content -Path .env -Encoding ascii
Write-Host "Wrote .env (secrets generated)."
}
diff --git a/install.sh b/install.sh
index f9e3773..c6ad65b 100755
--- a/install.sh
+++ b/install.sh
@@ -26,6 +26,10 @@ POSTGRES_PASSWORD=$POSTGRES_PASSWORD
SECRET_KEY=$SECRET_KEY
TOKEN_ENCRYPTION_KEY=$TOKEN_ENCRYPTION_KEY
OAUTH_REDIRECT_URL=$URL/auth/callback
+
+# Optional: store Download Center media in a host folder (e.g. one your Plex server reads) instead
+# of a Docker volume. Must be writable by uid 1000 (sudo chown -R 1000:1000 ).
+# DOWNLOAD_HOST_PATH=/mnt/media/youtube
EOF
chmod 600 .env
echo "Wrote .env (secrets generated)."