diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index 8f3c6b14..386d6248 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,14 +1,11 @@ -import { lazy, Suspense, useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useEffect, 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, @@ -19,11 +16,9 @@ import { 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")); @@ -35,734 +30,10 @@ 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-warn", - done: "text-success", - error: "text-danger", - 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 OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so - // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields - // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for - // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). - const remote = job.asset?.thumbnail_url ?? null; - // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see - // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the - // poster resolves to the session-default account and 404s for a per-tab account's download — which, - // now that the poster is the primary image, reads as a blank Library thumbnail. - const poster = job.poster_url ? api.posterImageUrl(job.id) : null; - const primary = poster || remote; - // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. - const [src, setSrc] = useState(primary); - useEffect(() => setSrc(primary), [primary]); - return ( -
- {src ? ( - { - if (remote && src !== remote) setSrc(remote); - }} - /> - ) : 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-danger" : "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 ----------------------------------------------------------------------------------- +import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; +import { MetaEditModal, ErrorModal } from "./downloadModals"; +import { AdminSystem } from "./downloadAdmin"; export default function DownloadCenter() { const me = useMe(); diff --git a/frontend/src/components/downloadAdmin.tsx b/frontend/src/components/downloadAdmin.tsx new file mode 100644 index 00000000..c0f4a965 --- /dev/null +++ b/frontend/src/components/downloadAdmin.tsx @@ -0,0 +1,267 @@ +import { useMemo, useState } from "react"; +import { qk } from "../lib/queryKeys"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Settings2 } from "lucide-react"; +import Modal from "./Modal"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { api } from "../lib/api"; +import { formatBytes } from "../lib/format"; +import { inputCls } from "./ui/form"; +import { StatusPill, jobTitle } from "./downloadRow"; + +// The admin "System" tab — storage overview, per-user footprint, quota editor, and the global job +// list. Split out of DownloadCenter (self-contained, admin-only surface). + +const GiB = 1024 ** 3; + +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) => ( + + ))} +
+ )} +
+ ); +} + +export 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)} /> + )} +
+ ); +} diff --git a/frontend/src/components/downloadModals.tsx b/frontend/src/components/downloadModals.tsx new file mode 100644 index 00000000..785836e6 --- /dev/null +++ b/frontend/src/components/downloadModals.tsx @@ -0,0 +1,178 @@ +import { useState } from "react"; +import { qk } from "../lib/queryKeys"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Plus, Trash2 } from "lucide-react"; +import Modal from "./Modal"; +import { api, type DownloadJob } from "../lib/api"; +import { inputCls } from "./ui/form"; +import { IconBtn } from "./downloadRow"; + +// Per-item user modals split out of DownloadCenter (self-contained, prop-driven): rename/metadata +// editing and the failure-detail window. + +// 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. +export 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")}

+
+ +
+
+
+ ); +} + +// 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. +export 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 && ( + + )} + +
+
+
+ ); +} diff --git a/frontend/src/components/downloadRow.tsx b/frontend/src/components/downloadRow.tsx new file mode 100644 index 00000000..c9227d3e --- /dev/null +++ b/frontend/src/components/downloadRow.tsx @@ -0,0 +1,309 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { CalendarClock, Copy, Link2, Scissors } from "lucide-react"; +import clsx from "clsx"; +import { qk } from "../lib/queryKeys"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { api, type DownloadJob } from "../lib/api"; +import { notify } from "../lib/notifications"; +import { copyText } from "../lib/clipboard"; +import { formatBytes, formatDuration, formatEta, formatSpeed, relativeTime } from "../lib/format"; + +// Download-row presentation + the small shared primitives (status pill, icon button, usage bar), +// split out of DownloadCenter (all purely prop-driven, no shared state). Rendered by the queue, +// library, shared, and admin tabs. + +const STATUS_CLS: Record = { + queued: "text-muted", + running: "text-accent", + paused: "text-warn", + done: "text-success", + error: "text-danger", + 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", +]; + +export 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 OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so + // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields + // rule on the source domain can't break it. The source's remote thumbnail_url is only a fallback for + // the rare poster-less asset (a thumbnail-less source whose ffmpeg frame-cut also failed). + const remote = job.asset?.thumbnail_url ?? null; + // `job.poster_url` only SIGNALS a poster exists; the actual src must carry ?account= (see + // api.posterImageUrl). A plain can't send the X-Siftlode-Account header, so without it the + // poster resolves to the session-default account and 404s for a per-tab account's download — which, + // now that the poster is the primary image, reads as a blank Library thumbnail. + const poster = job.poster_url ? api.posterImageUrl(job.id) : null; + const primary = poster || remote; + // On load failure (a genuinely missing poster file) fall back to the remote source thumbnail. + const [src, setSrc] = useState(primary); + useEffect(() => setSrc(primary), [primary]); + return ( +
+ {src ? ( + { + if (remote && src !== remote) setSrc(remote); + }} + /> + ) : null} +
+ ); +} + +export 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, + }; +} + +export function IconBtn({ + onClick, + title, + danger, + children, +}: { + onClick: () => void; + title: string; + danger?: boolean; + children: 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 ( + + ); +} + +// 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 ( + + ); + } + return
{name}
; +} + +export function DownloadRow({ + job, + onShowError, + children, +}: { + job: DownloadJob; + onShowError?: (job: DownloadJob) => void; + children?: 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}
+
+ ); +} + +export 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-danger" : "bg-accent")} + style={{ width: `${pct}%` }} + /> +
+ )} +
+ ); +}