All files / src/components/settings DownloadHistory.tsx

84.21% Statements 32/38
67.39% Branches 31/46
88.88% Functions 8/9
87.09% Lines 27/31

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147                              11x 8x 16x             2x 8x       2x 8x   2x             6x 5x 1x 4x 3x 3x   1x       2x             2x 13x 13x   13x 5x     8x                                 8x                                                           2x             317x 317x 3x                                                  
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { useId } from "react";
import { X } from "lucide-react";
import { Dialog } from "@/components/ui/dialog";
import { useDownloads } from "@/hooks/useDownloads";
import { useNow } from "@/hooks/useNow";
import { downloadPercent } from "@/lib/download-progress";
import { isActiveDownload } from "@/lib/download-status";
import { formatBytes } from "@/lib/format";
import { errorSummary, recordingStatus, recordingStatusColor } from "@/lib/recordings";
import { deleteAt, deleteLabel } from "@/lib/retention";
import type { DownloadRecord } from "@/lib/types";
 
const fmtBytes = (b: number | null) => (b == null ? "—" : formatBytes(b));
const fmtRate = (b: number | null) => (b == null ? "—" : `${(b / 1_000_000).toFixed(1)} MB/s`);
const fmtTime = (t: number | null) => (t == null ? "—" : new Date(t * 1000).toLocaleString());
 
/**
 * How much has been saved, and out of how much while the download runs or
 * waits. Otherwise it's only the file's size, which stays right for a record
 * whose size an older version took from an HLS playlist.
 */
const sizeCell = (d: DownloadRecord) =>
	d.totalBytes && isActiveDownload(d)
		? `${fmtBytes(d.downloadedBytes)} / ${fmtBytes(d.totalBytes)}`
		: fmtBytes(d.downloadedBytes);
 
const statusCell = (d: DownloadRecord, now: number) => {
	if (d.kind === "recording") {
		// The first line of an error is in plain words, and what ffmpeg said follows it.
		return (
			<span className={recordingStatusColor(d)} title={d.error ?? undefined}>
				{recordingStatus(d, now)}
				{d.error && `. ${errorSummary(d.error)}`}
			</span>
		);
	}
	if (d.status === "completed") return <span className="text-emerald-500">Done</span>;
	if (d.status === "failed")
		return <span className="text-red-400">Failed{d.error ? `. ${d.error}` : ""}</span>;
	if (d.status === "downloading") {
		const pct = downloadPercent(d);
		return <span className="text-blue-400">{pct == null ? "…" : `${pct}%`}</span>;
	}
	return <span className="text-muted-foreground">{d.status}</span>;
};
 
/** When the retention setting deletes a finished download. Blank when it never will. */
const deletesCell = (d: DownloadRecord, days: number | null) => {
	if (d.status !== "completed" || !days) return "";
	if (d.keep) return "Kept";
	const at = deleteAt(d, days);
	return at === null ? "" : deleteLabel(at, Date.now() / 1000);
};
 
export const DownloadHistory = () => {
	const { downloads, retentionDays } = useDownloads();
	const now = useNow();
 
	if (downloads.length === 0) {
		return <p className="text-xs text-muted-foreground">No downloads yet.</p>;
	}
 
	return (
		<div className="overflow-x-auto">
			<table className="w-full text-xs">
				<thead className="text-muted-foreground">
					<tr>
						<th className="text-left py-2">Title</th>
						<th className="text-left">Type</th>
						<th className="text-left">Size</th>
						<th className="text-left">Avg rate</th>
						<th className="text-left">Started</th>
						<th className="text-left">Finished</th>
						<th className="text-left">Status</th>
						{retentionDays !== null && <th className="text-left pl-2">Deletes</th>}
					</tr>
				</thead>
				<tbody>
					{downloads.map((d) => (
						<tr key={d.id} className="border-t border-border/40">
							<td className="py-2 pr-2 max-w-[180px] truncate">{d.title}</td>
							<td className="pr-2">{d.kind}</td>
							<td className="pr-2 whitespace-nowrap">{sizeCell(d)}</td>
							<td className="pr-2 whitespace-nowrap">{fmtRate(d.avgRateBps)}</td>
							<td className="pr-2 whitespace-nowrap">
								{/* A recording starts at its time, not when it was set up. */}
								{fmtTime(
									d.kind === "recording"
										? (d.startAt ?? d.createdAt)
										: d.createdAt
								)}
							</td>
							<td className="pr-2 whitespace-nowrap">{fmtTime(d.finishedAt)}</td>
							<td>{statusCell(d, now)}</td>
							{retentionDays !== null && (
								<td className="pl-2 whitespace-nowrap text-muted-foreground">
									{deletesCell(d, retentionDays)}
								</td>
							)}
						</tr>
					))}
				</tbody>
			</table>
		</div>
	);
};
 
/** Full-screen overlay wrapping the history table — the Settings pane is too
 *  narrow to show every column, so the table lives in a wide popup. */
export const DownloadHistoryDialog = ({
	open,
	onClose,
}: {
	open: boolean;
	onClose: () => void;
}) => {
	const titleId = useId();
	if (!open) return null;
	return (
		<Dialog
			onClose={onClose}
			labelledBy={titleId}
			overlayClassName="p-4"
			className="bg-card border border-border rounded-lg shadow-xl w-full max-w-4xl max-h-[80vh] flex flex-col"
		>
			<div className="flex items-center justify-between px-5 py-3 border-b border-border">
				<h3 id={titleId} className="text-sm font-semibold">
					Download history
				</h3>
				<button
					aria-label="Close"
					className="p-1 rounded-md hover:bg-accent"
					onClick={onClose}
				>
					<X className="h-4 w-4" />
				</button>
			</div>
			<div className="overflow-auto p-5">
				<DownloadHistory />
			</div>
		</Dialog>
	);
};