"use client"; import "../widgets-common.css"; import "./styles.css"; import { useCallback, useMemo, useState } from "react"; import { ActionIcon, Avatar, Badge, Box, Center, Divider, Group, Menu, Portal, Progress, RingProgress, SimpleGrid, Stack, Text, Tooltip, UnstyledButton, } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconChevronDown, IconChevronUp, IconCopy, IconDatabase, IconDownload, IconFilter, IconFilterOff, IconInfoCircle, IconPlayerPause, IconPlayerPlay, IconTrash, IconTrashX, IconUpload, } from "@tabler/icons-react"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; import { DataTable } from "mantine-datatable"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common"; import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions"; import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; dayjs.extend(relativeTime); interface SizeConfig { fontSize: "xs" | "sm"; iconSize: number; cellPadding: number; showSpeedColumns: boolean; showTimeColumn: boolean; showStateColumn: boolean; } const getSizeConfig = (width: number): SizeConfig => { if (width < 300) { return { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false }; } if (width < 500) { return { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false }; } return { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; }; const stateColorMap: Record = { downloading: "blue", leeching: "blue", seeding: "green", completed: "green", processing: "cyan", queued: "gray", paused: "yellow", stalled: "orange", failed: "red", unknown: "gray", }; const progressColor = (state: ExtendedDownloadClientItem["state"], progress: number): string => { if (state === "paused") return "yellow"; if (progress >= 1) return "green"; return "blue"; }; const rowBgAlpha: Record = { failed: "rgba(255, 0, 0, 0.04)", paused: "rgba(255, 200, 0, 0.03)", downloading: undefined, leeching: undefined, seeding: undefined, completed: undefined, processing: undefined, queued: undefined, stalled: undefined, unknown: undefined, }; interface ContextMenuState { x: number; y: number; item: ExtendedDownloadClientItem; } export default function DownloadClientsWidget({ isEditMode, integrationIds, options, width, }: WidgetComponentProps<"downloads">) { const integrationsWithInteractions = useIntegrationsWithInteractAccess().flatMap(({ id }) => integrationIds.includes(id) ? [id] : [], ); const { data: currentItems = [] } = clientApi.widget.downloads.getJobsAndStatuses.useQuery({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); const t = useScopedI18n("widget.downloads"); const [clientFilter, setClientFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); const [contextMenu, setContextMenu] = useState(null); const [showStats, { toggle: toggleStats }] = useDisclosure(false); const utils = clientApi.useUtils(); const invalidateDownloads = { onSettled: () => void utils.widget.downloads.getJobsAndStatuses.invalidate() }; const { mutate: mutateResumeItem } = clientApi.widget.downloads.resumeItem.useMutation(invalidateDownloads); const { mutate: mutatePauseItem } = clientApi.widget.downloads.pauseItem.useMutation(invalidateDownloads); const { mutate: mutateDeleteItem } = clientApi.widget.downloads.deleteItem.useMutation(invalidateDownloads); const { mutate: mutateResumeQueue } = clientApi.widget.downloads.resume.useMutation(invalidateDownloads); const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(invalidateDownloads); const [sortStatus, setSortStatus] = useState>({ columnAccessor: options.defaultSort, direction: options.descendingDefaultSort ? "desc" : "asc", }); const data = useMemo(() => { return currentItems .filter(({ integration }) => integrationIds.includes(integration.id)) .flatMap((pair) => pair.data.items .filter(({ category }) => options.categoryFilter.length === 0 || options.filterIsWhitelist === options.categoryFilter.some((filter) => (Array.isArray(category) ? category : [category]).includes(filter), ), ) .filter(({ type, progress, upSpeed }) => { if (progress < 1) return true; if (type === "torrent") return options.showCompletedTorrent && (upSpeed ?? 0) >= Number(options.activeTorrentThreshold) * 1024; if (type === "usenet") return options.showCompletedUsenet; return options.showCompletedHttp; }) .filter(({ state }) => (clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) && (statusFilter.length === 0 || statusFilter.includes(state)), ) .map((item): ExtendedDownloadClientItem => { const received = item.received ?? Math.floor(item.size * item.progress); const ids = [pair.integration.id]; return { integration: pair.integration, ...item, category: item.category !== undefined && item.category.length > 0 ? item.category : undefined, received, ratio: item.sent !== undefined ? item.sent / item.size : undefined, actions: integrationsWithInteractions.includes(pair.integration.id) ? { resume: () => mutateResumeItem({ integrationIds: ids, item }), pause: () => mutatePauseItem({ integrationIds: ids, item }), delete: ({ fromDisk }) => mutateDeleteItem({ integrationIds: ids, item, fromDisk }), } : undefined, }; }), ); }, [ currentItems, integrationIds, integrationsWithInteractions, mutateDeleteItem, mutatePauseItem, mutateResumeItem, options.activeTorrentThreshold, options.categoryFilter, options.filterIsWhitelist, options.showCompletedTorrent, options.showCompletedUsenet, options.showCompletedHttp, clientFilter, statusFilter, ]); const sortedData = useMemo(() => { const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem; const dir = sortStatus.direction === "desc" ? -1 : 1; return [...data].toSorted((a, b) => { const aVal = a[accessor] ?? 0; const bVal = b[accessor] ?? 0; if (accessor === "time") { const toKey = (time: number) => (time === 0 ? Infinity : time); return (toKey(aVal as number) - toKey(bVal as number)) * dir; } if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir; if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir; return 0; }); }, [data, sortStatus]); const clients = useMemo(() => { return currentItems .filter(({ integration }) => integrationIds.includes(integration.id)) .map(({ integration, data: d }): ExtendedClientStatus => { const interact = integrationsWithInteractions.includes(integration.id); const isTorrent = getIntegrationKindsByCategory("torrent").some((kind) => kind === integration.kind); const { totalUp, totalDown } = d.items .filter(({ category }) => !options.applyFilterToRatio || !d.status.types.includes("torrent") || options.filterIsWhitelist === options.categoryFilter.some((filter) => (Array.isArray(category) ? category : [category]).includes(filter), ), ) .reduce( ({ totalUp, totalDown }, { sent, size, progress }) => ({ totalUp: isTorrent ? (totalUp ?? 0) + (sent ?? 0) : undefined, totalDown: totalDown + size * progress, }), { totalDown: 0, totalUp: isTorrent ? 0 : undefined }, ); return { integration, interact, status: { totalUp, totalDown, ratio: totalUp === undefined ? undefined : totalUp / totalDown, ...d.status }, }; }); }, [currentItems, integrationIds, integrationsWithInteractions, options.applyFilterToRatio, options.categoryFilter, options.filterIsWhitelist]); const hasTorrents = data.some(({ type }) => type === "torrent"); const hasMultipleClients = clients.length > 1; const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; const size = getSizeConfig(width); const visibleColumns = useMemo(() => { const set = new Set(options.columns); return { name: set.has("name"), progress: set.has("progress"), size: set.has("size"), downSpeed: set.has("downSpeed") && size.showSpeedColumns, upSpeed: set.has("upSpeed") && hasTorrents && size.showSpeedColumns, time: set.has("time") && size.showTimeColumn, state: set.has("state") && size.showStateColumn, added: set.has("added"), ratio: set.has("ratio") && hasTorrents, received: set.has("received"), sent: set.has("sent") && hasTorrents, category: set.has("category"), integration: set.has("integration") && hasMultipleClients, index: set.has("index"), type: set.has("type") && hasMultipleTypes, }; }, [options.columns, size, hasTorrents, hasMultipleClients, hasMultipleTypes]); const columns = useMemo((): DataTableColumn[] => { const cols: (DataTableColumn | false)[] = [ visibleColumns.integration && { accessor: "integration", title: "", width: 36, render: (record) => ( ), }, visibleColumns.name && { accessor: "name", title: t("items.name.columnTitle"), sortable: true, ellipsis: true, render: (record) => ( {record.name} ), }, visibleColumns.progress && { accessor: "progress", title: t("items.progress.columnTitle"), sortable: true, width: width < 400 ? 80 : 120, render: (record) => ( {`${Math.floor(record.progress * 100)}%`} ), }, visibleColumns.size && { accessor: "size", title: t("items.size.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => {formatBytes(record.size)}, }, visibleColumns.downSpeed && { accessor: "downSpeed", title: t("items.downSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => record.downSpeed ? ( {formatByteRate(record.downSpeed)} ) : null, }, visibleColumns.upSpeed && { accessor: "upSpeed", title: t("items.upSpeed.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => record.upSpeed ? ( {formatByteRate(record.upSpeed)} ) : null, }, visibleColumns.time && { accessor: "time", title: t("items.time.columnTitle"), sortable: true, width: 100, noWrap: true, render: (record) => { if (record.time === 0) return ∞; return {dayjs().add(record.time).fromNow(true)}; }, }, visibleColumns.state && { accessor: "state", title: t("items.state.columnTitle"), width: 90, render: (record) => ( {t(`states.${record.state}`)} ), }, visibleColumns.added && { accessor: "added", title: t("items.added.columnTitle"), sortable: true, width: 90, noWrap: true, render: (record) => {record.added ? dayjs(record.added).fromNow() : "—"}, }, visibleColumns.ratio && { accessor: "ratio", title: t("items.ratio.columnTitle"), sortable: true, width: 60, render: (record) => record.ratio !== undefined ? ( {record.ratio.toFixed(record.ratio >= 100 ? 0 : record.ratio >= 10 ? 1 : 2)} ) : null, }, visibleColumns.received && { accessor: "received", title: t("items.received.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => {formatBytes(record.received)}, }, visibleColumns.sent && { accessor: "sent", title: t("items.sent.columnTitle"), sortable: true, width: 80, noWrap: true, render: (record) => record.sent ? {formatBytes(record.sent)} : null, }, visibleColumns.category && { accessor: "category", title: t("items.category.columnTitle"), width: 80, ellipsis: true, render: (record) => record.category ? ( {Array.isArray(record.category) ? record.category.join(", ") : record.category} ) : null, }, visibleColumns.index && { accessor: "index", title: t("items.index.columnTitle"), sortable: true, width: 40, render: (record) => {record.index}, }, visibleColumns.type && { accessor: "type", title: t("items.type.columnTitle"), sortable: true, width: 70, render: (record) => {record.type}, }, ]; return cols.filter(Boolean) as DataTableColumn[]; }, [visibleColumns, t, size, width]); const handleContextMenu = useCallback( ({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => { event.preventDefault(); setContextMenu({ x: event.clientX, y: event.clientY, item: record }); }, [], ); const closeContextMenu = useCallback(() => setContextMenu(null), []); const totalSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.down ?? 0), 0); const totalUpSpeed = clients.reduce((sum, c) => sum + (c.status?.rates.up ?? 0), 0); const globalRatio = useMemo(() => { const traffic = clients .filter(({ integration: { kind } }) => getIntegrationKindsByCategory("torrent").some((k) => k === kind)) .reduce( ({ up, down }, { status }) => ({ up: up + (status?.totalUp ?? 0), down: down + (status?.totalDown ?? 0) }), { up: 0, down: 0 }, ); return traffic.down > 0 ? traffic.up / traffic.down : 0; }, [clients]); const availableStatuses = useMemo(() => { return [...new Set(data.map(({ state }) => state))]; }, [data]); const queueStats = useMemo(() => { const stateCounts: Record = {}; let totalSize = 0; let completedSize = 0; for (const item of data) { stateCounts[item.state] = (stateCounts[item.state] ?? 0) + 1; totalSize += item.size; completedSize += item.received ?? item.size * item.progress; } return { stateCounts, totalSize, completedSize, totalItems: data.length }; }, [data]); if (options.columns.length === 0) { return (
{t("errors.noColumns")}
); } return ( {showStats && ( )} ({ padding: `${size.cellPadding}px 8px` }), }} onRowContextMenu={isEditMode ? undefined : handleContextMenu} rowBackgroundColor={(record) => rowBgAlpha[record.state] as string | undefined} rowExpansion={{ allowMultiple: true, collapseProps: { transitionDuration: 200, animateOpacity: true, transitionTimingFunction: "ease-out", }, content: ({ record, collapse }) => , }} onScroll={() => { if (contextMenu) closeContextMenu(); }} /> {contextMenu && ( )} ); } function buildHoverTooltip( record: ExtendedDownloadClientItem, t: ReturnType>, ): React.ReactNode { const lines: { label: string; value: string }[] = [ { label: t("items.size.detailsTitle"), value: `${formatBytes(record.received)} / ${formatBytes(record.size)}` }, { label: t("items.state.detailsTitle"), value: t(`states.${record.state}`) }, ]; if (record.downSpeed) { lines.push({ label: t("items.downSpeed.detailsTitle"), value: formatByteRate(record.downSpeed) }); } if (record.upSpeed) { lines.push({ label: t("items.upSpeed.detailsTitle"), value: formatByteRate(record.upSpeed) }); } if (record.time !== 0) { lines.push({ label: "ETA", value: dayjs().add(record.time, "milliseconds").fromNow() }); } if (record.ratio !== undefined) { lines.push({ label: t("items.ratio.detailsTitle"), value: record.ratio.toFixed(2) }); } if (record.category) { lines.push({ label: t("items.category.detailsTitle"), value: Array.isArray(record.category) ? record.category.join(", ") : record.category, }); } return ( {record.name} {lines.map(({ label, value }) => ( {label} {value} ))} ); } function ExpandedRow({ item, collapse }: { item: ExtendedDownloadClientItem; collapse: () => void }) { const t = useScopedI18n("widget.downloads"); const progressPercent = Math.floor(item.progress * 100); return ( {`${progressPercent}%`}} /> {item.name} {t(`states.${item.state}`)} {item.downSpeed !== undefined && item.downSpeed > 0 && ( } color="blue" /> )} {item.upSpeed !== undefined && item.upSpeed > 0 && ( } color="green" /> )} {item.ratio !== undefined && } {item.sent !== undefined && } {item.added !== undefined && } {item.category && ( )} ); } function DetailPair({ label, value, icon, color }: { label: string; value: string; icon?: React.ReactNode; color?: string }) { return ( {label}: {icon} {value} ); } function GlobalStatsBar({ queueStats, totalSpeed, totalUpSpeed, globalRatio, hasTorrents, clients, }: { queueStats: { stateCounts: Record; totalSize: number; completedSize: number; totalItems: number }; totalSpeed: number; totalUpSpeed: number; globalRatio: number; hasTorrents: boolean; clients: ExtendedClientStatus[]; }) { const t = useScopedI18n("widget.downloads"); const overallProgress = queueStats.totalSize > 0 ? queueStats.completedSize / queueStats.totalSize : 0; return ( {`${Math.floor(overallProgress * 100)}%`} {`${formatBytes(queueStats.completedSize)} / ${formatBytes(queueStats.totalSize)}`} {formatByteRate(totalSpeed)} {totalUpSpeed > 0 && ( {formatByteRate(totalUpSpeed)} )} {hasTorrents && globalRatio > 0 && ( {`${t("globalRatio")}: ${globalRatio.toFixed(2)}`} )} {Object.entries(queueStats.stateCounts).map(([state, count]) => ( {count} ))} {t("stats.clients", { count: clients.length })} ); } function RowContextMenu({ state, onClose, t }: { state: ContextMenuState; onClose: () => void; t: ReturnType>; }) { const { item } = state; const isPaused = item.state === "paused"; const hasActions = !!item.actions; const [showDeleteConfirm, { open: openDelete }] = useDisclosure(false); return ( { e.preventDefault(); onClose(); }} > {item.name.length > 40 ? `${item.name.slice(0, 40)}…` : item.name} } onClick={() => { navigator.clipboard.writeText(item.name); onClose(); }} > {t("contextMenu.copyName")} {item.id && ( } onClick={() => { navigator.clipboard.writeText(item.id); onClose(); }} > {t("contextMenu.copyId")} )} {hasActions && ( <> : } onClick={() => { (isPaused ? item.actions!.resume : item.actions!.pause)(); onClose(); }} > {t(`actions.item.${isPaused ? "resume" : "pause"}`)} {!showDeleteConfirm ? ( } onClick={(e) => { e.stopPropagation(); openDelete(); }} > {t("actions.item.delete.title")} ) : ( <> } onClick={() => { item.actions!.delete({ fromDisk: false }); onClose(); }} > {t("actions.item.delete.entry")} } onClick={() => { item.actions!.delete({ fromDisk: true }); onClose(); }} > {t("actions.item.delete.entryAndFiles")} )} )} ); } interface WidgetFooterProps { clients: ExtendedClientStatus[]; totalSpeed: number; totalUpSpeed: number; globalRatio: number; hasTorrents: boolean; clientFilter: string[]; setClientFilter: (ids: string[]) => void; statusFilter: string[]; setStatusFilter: (statuses: string[]) => void; availableStatuses: ExtendedDownloadClientItem["state"][]; pauseQueue: (args: { integrationIds: string[] }) => void; resumeQueue: (args: { integrationIds: string[] }) => void; itemCount: number; showStats: boolean; toggleStats: () => void; } function WidgetFooter({ clients, totalSpeed, totalUpSpeed, globalRatio, hasTorrents, clientFilter, setClientFilter, statusFilter, setStatusFilter, availableStatuses, pauseQueue, resumeQueue, itemCount, showStats, toggleStats, }: WidgetFooterProps) { const t = useScopedI18n("widget.downloads"); const [filterOpen, { toggle: toggleFilter }] = useDisclosure(false); const someInteract = clients.some(({ interact }) => interact); const hasActiveFilter = clientFilter.length > 0 || statusFilter.length > 0; const integrationsStatuses = clients.reduce( (acc, { status, integration: { id }, interact }) => status && interact ? (acc[status.paused ? "paused" : "active"].push(id), acc) : acc, { paused: [] as string[], active: [] as string[] }, ); return ( {filterOpen && ( {clients.length > 1 && ( {t("items.integration.columnTitle")}: {clients.map(({ integration }) => { const active = clientFilter.includes(integration.id); return ( setClientFilter(active ? clientFilter.filter((id) => id !== integration.id) : [...clientFilter, integration.id]) } > } > {integration.name} ); })} )} {availableStatuses.length > 1 && ( {t("items.state.columnTitle")}: {availableStatuses.map((status) => { const active = statusFilter.includes(status); return ( setStatusFilter(active ? statusFilter.filter((s) => s !== status) : [...statusFilter, status]) } > {t(`states.${status}`)} ); })} )} )} { if (hasActiveFilter && !filterOpen) { setClientFilter([]); setStatusFilter([]); } else { toggleFilter(); } }} > {hasActiveFilter ? : } {showStats ? : } {clients.map(({ integration }) => ( ))} {hasTorrents && globalRatio > 0 && ( {`${t("globalRatio")}: ${globalRatio.toFixed(2)}`} )} {someInteract && ( resumeQueue({ integrationIds: integrationsStatuses.paused })} > )} {formatByteRate(totalSpeed)} {totalUpSpeed > 0 && ( {formatByteRate(totalUpSpeed)} )} {someInteract && ( pauseQueue({ integrationIds: integrationsStatuses.active })} > )} {itemCount} ); } function ClientIndicator({ integration }: { integration: ExtendedClientStatus["integration"] }) { const isConnected = useIntegrationConnected(integration.updatedAt, { timeout: 30000 }); return ( ); }