import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Ban, CalendarClock, ClipboardPaste, Copy, Download, Link2, Pause, Play, Plus, RotateCcw, Scissors, Settings2, Share2, Pencil, Trash2, } from "lucide-react"; import clsx from "clsx"; import Tabs from "./Tabs"; import { PageToolbar } from "./PageShell"; import { LoadingState, StateMessage } from "./QueryState"; 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 { useMe } from "../lib/useMe"; import { api, type DownloadJob } from "../lib/api"; import { invalidateDownloads } from "../lib/downloads"; import { notify } from "../lib/notifications"; import { copyText } from "../lib/clipboard"; import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; import { inputCls } from "./ui/form"; 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 primary = job.asset?.thumbnail_url || job.poster_url; // A remote i.ytimg.com thumbnail 404s once the source video is deleted/privated, leaving a // broken image. On load failure fall back to our always-present self-hosted poster. const [src, setSrc] = useState(primary); useEffect(() => setSrc(primary), [primary]); return (
{src ? ( { if (job.poster_url && src !== job.poster_url) setSrc(job.poster_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 () => { if (await copyText(url)) { notify({ level: "success", message: t("downloads.source.copied") }); } else { 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, onShowError, children, }: { job: DownloadJob; onShowError?: (job: DownloadJob) => void; 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.status === "done" && job.created_at ? ( · {relativeTime(job.created_at)} ) : null} {job.status === "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: qk.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")}

); } // --- Error detail modal --------------------------------------------------------------------- // Transient failures worth an in-modal Retry button; the rest are permanent (private/geo/DRM/…), // where a retry can't help — the queue row still keeps its own retry control regardless. const RETRYABLE_ERRORS = ["postprocess", "network", "unknown"]; // A readable "why did this fail" window for a failed job: a category headline + plain explanation, // the raw yt-dlp/ffmpeg text behind a disclosure, and a Retry when the failure may be transient. function ErrorModal({ job, onRetry, onClose, }: { job: DownloadJob; onRetry: () => void; onClose: () => void; }) { const { t } = useTranslation(); const code = job.error_code || "unknown"; const [showRaw, setShowRaw] = useState(false); const retryable = RETRYABLE_ERRORS.includes(code); return (
{t(`downloads.errors.fail.cat.${code}.short`)}

{t(`downloads.errors.fail.cat.${code}.body`)}

{job.error && (
{showRaw && (
                {job.error}
              
)}
)}
{retryable && ( )}
); } // --- 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(qk.downloadUsage(), 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: qk.dlQuota(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: qk.dlQuota(userId) }); qc.invalidateQueries({ queryKey: qk.adminStorage() }); 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: qk.adminUsers(), 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(qk.adminStorage(), api.adminDownloadStorage, { intervalMs: 5000 }); const jobs = useLiveQuery(qk.adminDownloads(), 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() { const me = useMe(); const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); // Always land on the queue tab (not a persisted last-tab) — the queue is where new work and // any failures are, so it's the useful default every visit. const [tab, setTab] = useState("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 [errorJob, setErrorJob] = useState(null); const jobsQ = useLiveQuery(qk.downloads(), api.downloads, { intervalMs: 2000 }); const sharedQ = useQuery({ queryKey: qk.downloadsShared(), 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" | "redownload"; }) => op === "pause" ? api.pauseDownload(id) : op === "resume" ? api.resumeDownload(id) : op === "cancel" ? api.cancelDownload(id) : op === "redownload" ? api.redownloadDownload(id) : api.deleteDownload(id), onSuccess: invalidate, // No onError: a failed job action returns 400/409/422/500, already surfaced by the global // error modal (api.ts req) with the backend's specific message — a caller toast would double it. }); // 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: qk.downloadsShared() }), }); const confirmThen = async (title: string, body: string, fn: () => void) => { if (await confirm({ title, message: body, confirmLabel: title, danger: true })) fn(); }; // Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context + // a user gesture/permission (iOS Safari/Brave are strict) — so this is best-effort: the paste // button is an explicit gesture (reliable), and the input's onFocus tries silently (ignored if // the browser refuses without a click). Only accept an http(s) URL, don't clobber existing text. const pasteFromClipboard = async (force = false) => { if (!force && addUrl.trim()) return; try { const text = (await navigator.clipboard?.readText())?.trim(); if (text && /^https?:\/\/\S+$/i.test(text)) setAddUrl(text); } catch { /* permission denied or unsupported — the manual paste button still works on a click */ } }; 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") }] : []), ]; // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) // — 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 ( <> {/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}

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

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

{tab === "queue" && ( <>
setAddUrl(e.target.value)} onFocus={() => pasteFromClipboard()} 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 && (jobsQ.isError && !jobsQ.data ? ( jobsQ.refetch()}> {t("common.loadError")} ) : jobsQ.isLoading ? ( ) : (
{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.redownloadTitle"), t("downloads.confirm.redownloadBody"), () => act.mutate({ id: job.id, op: "redownload" }), ) } title={t("downloads.actions.redownload")} > 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 && (jobsQ.isError && !jobsQ.data ? ( jobsQ.refetch()}> {t("common.loadError")} ) : jobsQ.isLoading ? ( ) : (
{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)} />} {errorJob && ( act.mutate({ id: errorJob.id, op: "resume" })} onClose={() => setErrorJob(null)} /> )}
); }