All files / src/hooks useDownloads.tsx

100% Statements 68/68
92.59% Branches 25/27
100% Functions 19/19
100% Lines 54/54

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                    9x       390x 67x 84x 46x 39x 24x               9x 7x                 9x           2707x 2707x 2707x 203x 203x 203x 107x 107x 86x 86x   107x 107x     2707x 2707x 86x   2707x                           9x   9x 51x 51x   51x   51x 2x 1x 1x     51x 19x 19x     19x 2x 2x 2x 2x       19x 1x   19x 19x 19x       51x   51x                 9x 23x 23x 21x    
// SPDX-License-Identifier: GPL-3.0-only
// Copyright (C) 2026 Max Boksem. See NOTICE for additional terms under GPLv3 section 7.
 
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { isActiveDownload } from "@/lib/download-status";
import { getDownloadRetention, listDownloads, setDownloadRetention } from "@/lib/tauri";
import type { DownloadRecord, AggregateDownloadState } from "@/lib/types";
 
/** Pure: compute a series card's aggregate state from its episode downloads. */
export const aggregateForSeries = (
	episodeDownloads: DownloadRecord[],
	totalEpisodes: number
): AggregateDownloadState => {
	if (episodeDownloads.length === 0) return "none";
	if (episodeDownloads.some(isActiveDownload)) return "downloading";
	const completed = episodeDownloads.filter((d) => d.status === "completed").length;
	if (completed === 0) return "none";
	if (totalEpisodes > 0 && completed >= totalEpisodes) return "complete";
	return "partial";
};
 
/**
 * Pure: should `candidate` replace `current` as the representative record for a
 * channel? A completed record always wins; otherwise the most recent attempt
 * does, so a retry supersedes an earlier failed/cancelled record.
 */
export const supersedes = (candidate: DownloadRecord, current: DownloadRecord): boolean =>
	candidate.status === "completed" ||
	(current.status !== "completed" && candidate.createdAt >= current.createdAt);
 
/**
 * Pure: collapse the flat download list into per-channel and per-series views,
 * keeping a single representative record per channel (see {@link supersedes}).
 * `byChannel` therefore counts movies + individual episodes (never the series
 * container), and each `bySeries` array holds one record per episode.
 */
export const groupDownloads = (
	downloads: DownloadRecord[]
): {
	byChannel: Map<string, DownloadRecord>;
	bySeries: Map<string, DownloadRecord[]>;
} => {
	const byChannel = new Map<string, DownloadRecord>();
	const bySeriesByChannel = new Map<string, Map<string, DownloadRecord>>();
	for (const d of downloads) {
		const existing = byChannel.get(d.channelId);
		Eif (!existing || supersedes(d, existing)) byChannel.set(d.channelId, d);
		if (d.seriesChannelId) {
			let inner = bySeriesByChannel.get(d.seriesChannelId);
			if (!inner) {
				inner = new Map<string, DownloadRecord>();
				bySeriesByChannel.set(d.seriesChannelId, inner);
			}
			const prev = inner.get(d.channelId);
			Eif (!prev || supersedes(d, prev)) inner.set(d.channelId, d);
		}
	}
	const bySeries = new Map<string, DownloadRecord[]>();
	for (const [seriesId, inner] of bySeriesByChannel) {
		bySeries.set(seriesId, Array.from(inner.values()));
	}
	return { byChannel, bySeries };
};
 
interface DownloadsContextValue {
	downloads: DownloadRecord[];
	byChannel: Map<string, DownloadRecord>;
	bySeries: Map<string, DownloadRecord[]>;
	refresh: () => Promise<void>;
	/** Days finished downloads are kept, or null to keep them until removed. */
	retentionDays: number | null;
	/** Saves the setting. Resolves to how many downloads it deleted right away. */
	setRetentionDays: (days: number | null) => Promise<number>;
}
 
const DownloadsContext = createContext<DownloadsContextValue | null>(null);
 
export const DownloadsProvider = ({ children }: { children: ReactNode }) => {
	const [downloads, setDownloads] = useState<DownloadRecord[]>([]);
	const [retentionDays, setRetentionState] = useState<number | null>(null);
 
	const refresh = useCallback(() => listDownloads().then(setDownloads), []);
 
	const setRetentionDays = useCallback(async (days: number | null) => {
		const deleted = await setDownloadRetention(days);
		setRetentionState(days);
		return deleted;
	}, []);
 
	useEffect(() => {
		refresh();
		getDownloadRetention()
			.then(setRetentionState)
			.catch(() => {});
		const unlistenProgress = listen<DownloadRecord>("download://progress", (e) => {
			setDownloads((prev) => {
				const next = prev.filter((d) => d.id !== e.payload.id);
				next.push(e.payload);
				return next;
			});
		});
		// Sent when a download is removed, by the user or the retention setting.
		const unlistenRemoved = listen<string>("download://removed", (e) => {
			setDownloads((prev) => prev.filter((d) => d.id !== e.payload));
		});
		return () => {
			unlistenProgress.then((f) => f());
			unlistenRemoved.then((f) => f());
		};
	}, [refresh]);
 
	const { byChannel, bySeries } = groupDownloads(downloads);
 
	return (
		<DownloadsContext.Provider
			value={{ downloads, byChannel, bySeries, refresh, retentionDays, setRetentionDays }}
		>
			{children}
		</DownloadsContext.Provider>
	);
};
 
export const useDownloads = (): DownloadsContextValue => {
	const ctx = useContext(DownloadsContext);
	if (!ctx) throw new Error("useDownloads must be used within DownloadsProvider");
	return ctx;
};