From 1af21efebd627e99986182b6b2d6a8e1a65377a0 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 19 May 2026 20:07:21 -0500 Subject: [PATCH] Feat: Download Storage Threshold Warning (#88) --- src/features/downloads/lib/downloadQueue.ts | 12 ++++ .../downloads/store/downloadState.svelte.ts | 70 ++++++++++++++++++- .../series/components/SeriesDetail.svelte | 11 +-- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/src/features/downloads/lib/downloadQueue.ts b/src/features/downloads/lib/downloadQueue.ts index d9d66af..7b28b70 100644 --- a/src/features/downloads/lib/downloadQueue.ts +++ b/src/features/downloads/lib/downloadQueue.ts @@ -65,6 +65,18 @@ export function reorderSelectedToEdge( return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned]; } +const AVG_BYTES_PER_PAGE = 1_500_000; + +export function estimateQueueBytes(queue: DownloadQueueItem[]): number { + let total = 0; + for (const item of queue) { + const pages = item.chapter.pageCount ?? 0; + const remaining = pages - Math.round(item.progress * pages); + total += remaining * AVG_BYTES_PER_PAGE; + } + return total; +} + export function formatEta(seconds: number): string { if (seconds < 60) return `~${Math.ceil(seconds)}s`; if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`; diff --git a/src/features/downloads/store/downloadState.svelte.ts b/src/features/downloads/store/downloadState.svelte.ts index b960ca2..5dd7d87 100644 --- a/src/features/downloads/store/downloadState.svelte.ts +++ b/src/features/downloads/store/downloadState.svelte.ts @@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte"; import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import { toActiveDownloads, optimisticRemove, optimisticRemoveMany, - isRunning, getErrored, calcSpeed, estimateEta, + isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes, type SpeedSample, } from "../lib/downloadQueue"; import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry"; +import { invoke } from "@tauri-apps/api/core"; class DownloadStore { status: DownloadStatus | null = $state(null); @@ -23,8 +24,11 @@ class DownloadStore { dequeueing = $state(new Set()); selected = $state(new Set()); batchWorking = $state(false); - pagesPerSec: number | null = $state(null); - eta: number | null = $state(null); + pagesPerSec: number | null = $state(null); + eta: number | null = $state(null); + storageWarning: boolean = $state(false); + + private freeBytes: number | null = null; get toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; } get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; } @@ -82,6 +86,52 @@ class DownloadStore { this.status = ds; setActiveDownloads(toActiveDownloads(ds.queue)); this.updateSpeed(ds); + this.fetchFreeBytes(ds); + } + + private async fetchFreeBytes(ds: DownloadStatus) { + const path = store.settings.serverDownloadsPath ?? ""; + if (!path) return; + try { + const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath: path }); + this.freeBytes = info.free_bytes; + this.storageWarning = estimateQueueBytes(ds.queue) > info.free_bytes * 0.95; + } catch { } + } + + private confirmStorageOverrun(): Promise { + return new Promise(resolve => { + const backdrop = document.createElement("div"); + backdrop.style.cssText = "position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;animation:s-fade-in 0.15s ease both"; + const panel = document.createElement("div"); + panel.style.cssText = "background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 24px 80px rgba(0,0,0,0.7),0 0 0 1px rgba(255,255,255,0.04) inset;width:min(380px,calc(100vw - 40px));overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both"; + panel.innerHTML = ` +
+

Low disk space

+
+
+

+ The download queue is estimated to exceed 95% of your available storage. Download anyway? +

+
+
+ + +
+ `; + backdrop.appendChild(panel); + document.body.appendChild(backdrop); + function finish(result: boolean) { backdrop.remove(); resolve(result); } + panel.querySelector("#_moku-storage-cancel")!.addEventListener("click", () => finish(false)); + panel.querySelector("#_moku-storage-confirm")!.addEventListener("click", () => finish(true)); + backdrop.addEventListener("click", (e) => { if (e.target === backdrop) finish(false); }); + }); + } + + private async guardStorage(queueAfter: DownloadQueueItem[]): Promise { + if (this.freeBytes === null) return true; + if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true; + return this.confirmStorageOverrun(); } private updateSpeed(ds: DownloadStatus) { @@ -172,11 +222,21 @@ class DownloadStore { finally { this.batchWorking = false; } } + async enqueue(chapterId: number): Promise { + const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as any]; + if (!(await this.guardStorage(projected))) return false; + try { await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); } + catch (e) { console.error(e); } + return true; + } + async retryOne(chapterId: number) { if (this.dequeueing.has(chapterId)) return; this.dequeueing = new Set(this.dequeueing).add(chapterId); try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); + const projected = this.queue.filter(i => i.chapter.id !== chapterId); + if (!(await this.guardStorage(projected))) { this.poll(); return; } await gql(ENQUEUE_DOWNLOAD, { chapterId }); this.poll(); } catch (e) { console.error(e); this.poll(); } @@ -189,6 +249,8 @@ class DownloadStore { const ids = [...this.erroredIds]; try { await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); + const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id)); + if (!(await this.guardStorage(projected))) { this.poll(); return; } for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); this.poll(); addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); @@ -204,6 +266,8 @@ class DownloadStore { try { if (ids.length > 0) { await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); + const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id)); + if (!(await this.guardStorage(projected))) { this.poll(); return; } for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id }); addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 }); } diff --git a/src/features/series/components/SeriesDetail.svelte b/src/features/series/components/SeriesDetail.svelte index 5d01b77..fb6cfb9 100644 --- a/src/features/series/components/SeriesDetail.svelte +++ b/src/features/series/components/SeriesDetail.svelte @@ -10,7 +10,7 @@ import { GET_CHAPTERS } from "@api/queries/chapters"; import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga"; import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters"; - import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads"; + import { downloadStore } from "@features/downloads/store/downloadState.svelte"; import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache"; import { store, addToast, openReader, setActiveManga, @@ -321,7 +321,7 @@ async function enqueue(ch: Chapter, e: MouseEvent) { e.stopPropagation(); enqueueing = new Set(enqueueing).add(ch.id); - await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error); + await downloadStore.enqueue(ch.id); addToast({ kind: "download", title: "Download queued", body: ch.name }); enqueueing.delete(ch.id); enqueueing = new Set(enqueueing); if (store.activeManga) reloadChapters(store.activeManga.id); @@ -329,7 +329,10 @@ async function enqueueMultiple(chapterIds: number[]) { if (!chapterIds.length) return; - await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error); + for (const id of chapterIds) { + const allowed = await downloadStore.enqueue(id); + if (!allowed) return; + } addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` }); if (store.activeManga) reloadChapters(store.activeManga.id); } @@ -461,7 +464,7 @@ { label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 }, { label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 }, { separator: true }, - { label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) }, + { label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : Download, danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : downloadStore.enqueue(ch.id) }, { separator: true }, { label: "Download next 5 from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) }, { label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },