import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, Copy, Download, Link2, Pause, Play, Plus, Scissors, Settings2, Share2, Pencil, Trash2, } from "lucide-react"; import clsx from "clsx"; import Tabs, { usePersistedTab } from "./Tabs"; import Modal from "./Modal"; // Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until // used (the video editor in particular is heavy — filmstrip + scrubber). const DownloadDialog = lazy(() => import("./DownloadDialog")); const ProfileEditor = lazy(() => import("./ProfileEditor")); const VideoEditor = lazy(() => import("./VideoEditor")); const ShareDialog = lazy(() => import("./ShareDialog")); import { useConfirm } from "./ConfirmProvider"; import { useLiveQuery } from "../lib/useLiveQuery"; import { api, type DownloadJob, type Me } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format"; 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 GiB = 1024 ** 3; const STATUS_CLS: Record = { queued: "text-muted", running: "text-accent", paused: "text-amber-400", done: "text-emerald-400", error: "text-red-400", canceled: "text-muted", }; // Byte-progress phases (show a % bar) vs ffmpeg post-steps (no %, indeterminate pulse). // "editing" (the phase-2 editor) reports a real % from ffmpeg's out_time, so it gets a bar. const DOWNLOAD_PHASES = ["video", "audio", "editing"]; const PHASE_KEYS = [ "video", "audio", "editing", "merging", "audio_extract", "thumbnail", "sponsorblock", "metadata", "processing", ]; function StatusPill({ job }: { job: DownloadJob }) { const { t } = useTranslation(); // While downloading, show the concrete phase (video / audio / merging) so the user isn't // confused by the bar resetting between the separate video and audio streams. if (job.status === "running" && job.phase && PHASE_KEYS.includes(job.phase)) { return {t(`downloads.phase.${job.phase}`)}; } const key = job.expired ? "expired" : job.status; return ( {t(`downloads.status.${key}`)} ); } function Thumb({ job }: { job: DownloadJob }) { // Prefer the source thumbnail; fall back to our generated poster for thumbnail-less sources. const url = job.asset?.thumbnail_url || job.poster_url; return (
{url ? : null}
); } function jobTitle(job: DownloadJob): string { return job.display_name || job.asset?.title || job.source_ref; } // The channel to show under the title: a user override wins over the auto-extracted asset value. // `url` is null for sources without a channel link (e.g. reddit) — then it renders as plain text. function jobChannel(job: DownloadJob): { name: string; url: string | null } { return { name: job.display_uploader || job.asset?.uploader || job.source_ref, url: job.display_uploader_url || job.asset?.uploader_url || null, }; } function IconBtn({ onClick, title, danger, children, }: { onClick: () => void; title: string; danger?: boolean; children: React.ReactNode; }) { return ( ); } // Compact display of a URL: drop the protocol + trailing slash so "youtube.com/watch?v=…" fits. function displayUrl(url: string): string { try { const u = new URL(url); return (u.host + u.pathname + u.search).replace(/\/$/, ""); } catch { return url; } } // "Downloaded from" reference: opens the original page in a new tab, or copies the link. function SourceRef({ url }: { url: string }) { const { t } = useTranslation(); const copy = async () => { try { await navigator.clipboard.writeText(url); notify({ level: "success", message: t("downloads.source.copied") }); } catch { notify({ level: "error", message: t("downloads.source.copyFailed") }); } }; return (
{displayUrl(url)}
); } // Channel under the title — a clickable link when a channel URL is known, plain text otherwise. function ChannelLine({ job }: { job: DownloadJob }) { const { name, url } = jobChannel(job); if (url) { return (
{name}
); } return
{name}
; } function DownloadRow({ job, children, }: { job: DownloadJob; children?: React.ReactNode; }) { const { t } = useTranslation(); const running = job.status === "running"; return (
{job.job_kind === "edit" && ( {t("downloads.clipBadge")} )} {jobTitle(job)}
{job.asset?.size_bytes ? · {formatBytes(job.asset.size_bytes)} : null} {job.asset?.duration_s ? · {formatDuration(job.asset.duration_s)} : null} {job.error ? · {job.error} : null}
{job.source_url && } {job.extra_links?.map((url) => )} {running && (
{job.phase && !DOWNLOAD_PHASES.includes(job.phase) ? ( // ffmpeg post-steps aren't byte-progress — show an indeterminate pulse, no %.
) : ( <>
{job.progress}% {job.speed_bps ? {formatSpeed(job.speed_bps)} : null} {job.eta_s ? {formatEta(job.eta_s)} : null}
)}
)}
{children}
); } // --- Rename + Share small modals ------------------------------------------------------------ // Edit a download's display metadata: title, channel (name + link), and extra reference links. // All are display-only overrides — the on-disk file is never renamed. function MetaEditModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) { const { t } = useTranslation(); const qc = useQueryClient(); const [name, setName] = useState(job.display_name || job.asset?.title || ""); const [channel, setChannel] = useState(job.display_uploader || job.asset?.uploader || ""); const [channelUrl, setChannelUrl] = useState(job.display_uploader_url || job.asset?.uploader_url || ""); // Editable extra-link rows; keep one blank row so there's always somewhere to type. const [links, setLinks] = useState(() => [...(job.extra_links ?? []), ""]); const setLink = (i: number, v: string) => setLinks((ls) => ls.map((l, j) => (j === i ? v : l))); const addLink = () => setLinks((ls) => [...ls, ""]); const removeLink = (i: number) => setLinks((ls) => ls.filter((_, j) => j !== i)); const save = useMutation({ mutationFn: () => api.updateDownloadMeta(job.id, { display_name: name.trim(), display_uploader: channel.trim(), display_uploader_url: channelUrl.trim(), extra_links: links.map((l) => l.trim()).filter(Boolean), }), onSuccess: () => { qc.invalidateQueries({ queryKey: ["downloads"] }); onClose(); }, }); return (
{t("downloads.edit.extraLinks")}
{links.map((l, i) => (
setLink(i, e.target.value)} placeholder="https://…" className={inputCls} /> removeLink(i)} title={t("downloads.edit.removeLink")} danger>
))}

{t("downloads.edit.hint")}

); } // --- Usage bar ------------------------------------------------------------------------------ function UsageBar() { const { t } = useTranslation(); // Poll live (like the library list) so the footprint updates the moment the worker finishes an // edit/download — enqueue only creates a queued job (0 bytes), the size lands asynchronously, so // a one-shot fetch at enqueue time would show a stale 0 B until a manual reload. const usage = useLiveQuery(["download-usage"], api.downloadUsage, { intervalMs: 2000 }); const u = usage.data; if (!u) return null; const pct = u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); return (
{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) => ( ))}
)}
); } 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]) => (
{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)}
))} {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 [metaJob, setMetaJob] = 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 = () => invalidateDownloads(qc); 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") }] : []), ]; // A persisted tab can point at one no longer available (e.g. "system" restored for a now-non-admin // account) — that would render a blank page with no active tab. Fall back to the default. useEffect(() => { if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, me.role]); const saveBtn = (job: DownloadJob) => ( ); return (

{t("downloads.page.title")}

{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} />
{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")}> )} setMetaJob(job)} title={t("downloads.actions.editDetails")}> 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)} />} {metaJob && setMetaJob(null)} />} {shareJob && setShareJob(null)} />} {editJob && setEditJob(null)} />}
); }