feat: auto mode, keeping the newest N videos on disk
A toggle beside Refresh. On, it keeps the newest videos of the feed downloaded and deletes the rest, so the library follows the feed instead of growing without bound. How many is the Download all number in Settings, so there is one place that says how much disk this app uses. It runs after every check of the feed, and once at launch — otherwise it would look asleep for the first ten minutes. Turning it on asks first, and the question is concrete: it counts what would be fetched and what would be deleted right now, before anything happens. Turning it off does not ask, because stopping is harmless. Two things it will not do. With Download all set to no limit it refuses rather than fetching five hundred videos, and says which values work. And a video saved by hand from the menu bar is left alone: its channel is not a subscription, so it is not part of what auto mode manages, and sweeping away something saved a minute ago would be a nasty surprise.
This commit is contained in:
+146
-3
@@ -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<ViewMode>(() => {
|
||||
@@ -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() {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{confirmAuto && (
|
||||
<Dialog
|
||||
title="Turn on auto mode?"
|
||||
onCancel={() => setConfirmAuto(null)}
|
||||
onConfirm={() => {
|
||||
setConfirmAuto(null);
|
||||
setAutoMode(true);
|
||||
say("Auto mode on");
|
||||
void reconcileAuto();
|
||||
}}
|
||||
confirmLabel="Turn on"
|
||||
destructive={confirmAuto.remove > 0}
|
||||
>
|
||||
<p>
|
||||
FlightTube will keep the newest <b>{bulkLimit}</b> videos of your feed on this
|
||||
Mac, and check again after every refresh.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Right now that means downloading <b>{confirmAuto.fetch}</b> video
|
||||
{confirmAuto.fetch === 1 ? "" : "s"}
|
||||
{confirmAuto.remove > 0 ? (
|
||||
<>
|
||||
{" "}and <b>deleting {confirmAuto.remove}</b> 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.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
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.
|
||||
</p>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{removing && (
|
||||
<Dialog
|
||||
title={`Remove ${removing.title}?`}
|
||||
|
||||
@@ -26,6 +26,10 @@ interface Props {
|
||||
/** Present only while something is downloading or waiting to. */
|
||||
onStopAll?: () => 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"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onAutoMode}
|
||||
title={
|
||||
autoMode
|
||||
? `Auto mode is on — keeping the newest ${autoModeCount} videos on this Mac. Click to stop.`
|
||||
: "Auto mode: keep the newest videos downloaded automatically"
|
||||
}
|
||||
aria-label="Auto mode"
|
||||
aria-pressed={autoMode}
|
||||
className={
|
||||
`${ICON_BTN} border ` +
|
||||
(autoMode
|
||||
? "border-sky-500 bg-sky-500 text-white hover:bg-sky-400"
|
||||
: "border-slate-300 text-slate-500 hover:border-sky-500 hover:text-sky-600 " +
|
||||
"dark:border-slate-700 dark:text-slate-400 dark:hover:border-sky-500 " +
|
||||
"dark:hover:text-sky-400")
|
||||
}
|
||||
>
|
||||
{/* A download inside a cycle: it fetches, and it keeps doing it. */}
|
||||
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor"
|
||||
strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20.5 12a8.5 8.5 0 01-14.6 5.9M3.5 12a8.5 8.5 0 0114.6-5.9" />
|
||||
<path d="M18.1 2.5v3.6h-3.6M5.9 21.5v-3.6h3.6" />
|
||||
<path d="M12 8.5v5m0 0l-2-2m2 2l2-2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onClick={onRefresh} disabled={refreshing || !online}
|
||||
title={
|
||||
online
|
||||
|
||||
Reference in New Issue
Block a user