diff --git a/src/App.tsx b/src/App.tsx index f4335b9..c9dc519 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { cancelAllDownloads, cancelDownload, deleteAllDownloads, deleteChannel, - deleteDownload, downloadVideo, interruptedDownloads, previewDeleteChannel, + deleteDownload, downloadVideo, interruptedDownloads, listFeed, previewDeleteChannel, setDownloadDefaults, type RemovalPreview, fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource, } from "./api"; @@ -50,6 +50,8 @@ export default function App() { const [downloadedOnly, setDownloadedOnly] = useState(() => remembered("downloadedOnly")); const [hideShorts, setHideShorts] = useState(() => remembered("hideShorts")); const [autoplayNext, setAutoplayNext] = useState(() => remembered("autoplayNext")); + const [autoMode, setAutoMode] = useState(() => remembered("autoMode")); + const [confirmAuto, setConfirmAuto] = useState<{ fetch: number; remove: number } | null>(null); const [sidebarHidden, setSidebarHidden] = useState(() => remembered("sidebarHidden")); const [sidebarPeek, setSidebarPeek] = useState(false); const [view, setView] = useState(() => { @@ -163,12 +165,13 @@ export default function App() { localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0"); localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0"); localStorage.setItem("flighttube.autoplayNext", autoplayNext ? "1" : "0"); + localStorage.setItem("flighttube.autoMode", autoMode ? "1" : "0"); localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0"); } catch { /* storage blocked */ } }, [view, quality, bulkLimit, streamQuality, subLang, subStyle, browser, downloadedOnly, - hideShorts, autoplayNext, sidebarHidden]); + hideShorts, autoplayNext, autoMode, sidebarHidden]); // The language a download embeds. Like the player's fetch, this does not // depend on the on/off preference: that says what is shown, and a file @@ -219,6 +222,62 @@ export default function App() { // machine-translated variant YouTube offers, and asking for all of them // earns an HTTP 429. Off means no subtitle requests at all. + /** + * Auto mode: keep the newest `bulkLimit` videos of the feed on disk, and + * nothing else. + * + * Runs after every check of the feed, so the library follows the feed rather + * than accumulating. Deliberate one-off saves from the menu bar are left + * alone — their channel is not a subscription, so they are not part of what + * this is managing, and deleting something saved by hand a minute ago would + * be a nasty surprise. + */ + const reconciling = useRef(false); + const autoModeRef = useRef(autoMode); + autoModeRef.current = autoMode; + + const reconcileAuto = useCallback(async () => { + if (reconciling.current || bulkLimit <= 0) return; + reconciling.current = true; + try { + const shared = { channel_id: null, search: null, hide_shorts: hideShorts }; + const [wanted, held] = await Promise.all([ + listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }), + // Everything on disk, Shorts included: one downloaded before Hide + // Shorts was switched on still takes up room. + listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }), + ]); + + const keep = new Set(wanted.map((v) => v.id)); + const subscribed = new Set(channels.map((c) => c.id)); + + const stale = held.filter( + (v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id), + ); + for (const v of stale) { + await deleteDownload(v.id).catch(() => {}); + clearLive(v.id); + } + + const missing = wanted.filter( + (v) => v.state !== "done" && v.state !== "queued" && v.state !== "running", + ); + for (const v of missing) { + downloadVideo(v.id, quality, embedLang).catch(() => {}); + } + + if (stale.length > 0 || missing.length > 0) { + reload(); + say( + `Auto: ${missing.length} to fetch` + + (stale.length > 0 ? `, ${stale.length} removed` : ""), + ); + } + } finally { + reconciling.current = false; + } + }, [bulkLimit, hideShorts, channels, quality, embedLang, reload, say, clearLive]); + const doRefresh = useCallback(async () => { setRefreshing(true); try { @@ -230,13 +289,14 @@ export default function App() { ? `Checked ${s.channels} channels · ${failed} failed` : `Checked ${s.channels} channel${s.channels === 1 ? "" : "s"}`, ); + if (autoModeRef.current) void reconcileAuto(); } catch (e) { setFailure(String(e)); } finally { setRefreshing(false); setRefreshProgress(null); } - }, [reload, say]); + }, [reload, say, reconcileAuto]); // Downloaded plays from disk; anything else streams. Only being offline with // no local copy leaves nothing to play. @@ -303,6 +363,39 @@ export default function App() { }; }, [reload]); + /** What engaging auto mode would do right now, so the question is concrete. */ + const askAutoMode = useCallback(async () => { + if (autoMode) { + setAutoMode(false); + say("Auto mode off"); + return; + } + if (bulkLimit <= 0) { + setFailure( + "Auto mode needs a number to keep. Set Download all in Settings to 5, 10, 25, " + + "50 or 100 — with no limit there is nothing to trim to.", + ); + return; + } + try { + const shared = { channel_id: null, search: null, hide_shorts: hideShorts }; + const [wanted, held] = await Promise.all([ + listFeed({ ...shared, downloaded_only: false, limit: bulkLimit }), + listFeed({ ...shared, hide_shorts: false, downloaded_only: true, limit: 1000 }), + ]); + const keep = new Set(wanted.map((v) => v.id)); + const subscribed = new Set(channels.map((c) => c.id)); + setConfirmAuto({ + fetch: wanted.filter((v) => v.state !== "done").length, + remove: held.filter( + (v) => !keep.has(v.id) && v.state === "done" && subscribed.has(v.channel_id), + ).length, + }); + } catch (e) { + setFailure(String(e)); + } + }, [autoMode, bulkLimit, hideShorts, channels, say]); + const askRemoveChannel = useCallback((id: string) => { previewDeleteChannel(id) .then((p) => setRemoving({ ...p, id })) @@ -322,6 +415,17 @@ export default function App() { .catch((e) => setFailure(String(e))); }, [removing, channelId, reload, say]); + // On launch, bring the library in line before the first ten-minute check — + // otherwise auto mode looks asleep for the first ten minutes. Waits for the + // channel list, since knowing which channels are subscriptions is what keeps + // hand-saved videos from being swept. + const reconciledOnce = useRef(false); + useEffect(() => { + if (!autoMode || reconciledOnce.current || channels.length === 0) return; + reconciledOnce.current = true; + void reconcileAuto(); + }, [autoMode, channels.length, reconcileAuto]); + // Anything in flight, whether or not it is currently listed — a download // started on one channel keeps running while you look at another. const activeDownloads = useMemo(() => { @@ -545,6 +649,9 @@ export default function App() { downloadAllTotal={pendingDownloads.length} onStopAll={activeDownloads > 0 ? stopAll : undefined} stopAllCount={activeDownloads} + autoMode={autoMode} + onAutoMode={askAutoMode} + autoModeCount={bulkLimit} sidebarHidden={sidebarHidden} onShowSidebar={() => { setSidebarHidden(false); setSidebarPeek(false); }} titleBarInset={titleBarInset} @@ -714,6 +821,42 @@ export default function App() { )} + {confirmAuto && ( + setConfirmAuto(null)} + onConfirm={() => { + setConfirmAuto(null); + setAutoMode(true); + say("Auto mode on"); + void reconcileAuto(); + }} + confirmLabel="Turn on" + destructive={confirmAuto.remove > 0} + > +

+ FlightTube will keep the newest {bulkLimit} videos of your feed on this + Mac, and check again after every refresh. +

+

+ Right now that means downloading {confirmAuto.fetch} video + {confirmAuto.fetch === 1 ? "" : "s"} + {confirmAuto.remove > 0 ? ( + <> + {" "}and deleting {confirmAuto.remove} already downloaded that fall + outside the newest {bulkLimit} + + ) : null} + . From then on, anything that drops out of the newest {bulkLimit} is deleted to + make room. +

+

+ Videos you saved by hand from the menu bar are left alone — those are not part + of a subscription, so auto mode does not manage them. +

+
+ )} + {removing && ( void; stopAllCount?: number; + autoMode: boolean; + onAutoMode: () => void; + /** How many videos auto mode keeps, for the tooltip. */ + autoModeCount: number; /** How many this press would queue, and how many are listed in all. */ downloadAllCount?: number; downloadAllTotal?: number; @@ -67,7 +71,8 @@ export default function TopBar({ online, reachable, forcedOffline, onToggleForcedOffline, onRefresh, refreshing, refreshProgress, resultCount, view, onView, sidebarHidden, onShowSidebar, onDeleteAll, onDownloadAll, downloadAllCount = 0, - downloadAllTotal = 0, onStopAll, stopAllCount = 0, titleBarInset, + downloadAllTotal = 0, onStopAll, stopAllCount = 0, + autoMode, onAutoMode, autoModeCount, titleBarInset, }: Props) { const pct = refreshProgress && refreshProgress.total > 0 ? (refreshProgress.done / refreshProgress.total) * 100 @@ -225,6 +230,33 @@ export default function TopBar({ {online ? "Online" : forcedOffline ? "Offline (forced)" : "Offline"} + +