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;
};
|