mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Download Storage Threshold Warning (#88)
This commit is contained in:
@@ -65,6 +65,18 @@ export function reorderSelectedToEdge(
|
|||||||
return edge === "top" ? [...pinned, ...rest] : [...rest, ...pinned];
|
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 {
|
export function formatEta(seconds: number): string {
|
||||||
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
|
||||||
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import { boot } from "@store/boot.svelte";
|
|||||||
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
|
||||||
import {
|
import {
|
||||||
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
toActiveDownloads, optimisticRemove, optimisticRemoveMany,
|
||||||
isRunning, getErrored, calcSpeed, estimateEta,
|
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||||
type SpeedSample,
|
type SpeedSample,
|
||||||
} from "../lib/downloadQueue";
|
} from "../lib/downloadQueue";
|
||||||
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
import { startAutoRetry, type AutoRetryHandle } from "../lib/autoRetry";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
class DownloadStore {
|
class DownloadStore {
|
||||||
status: DownloadStatus | null = $state(null);
|
status: DownloadStatus | null = $state(null);
|
||||||
@@ -25,6 +26,9 @@ class DownloadStore {
|
|||||||
batchWorking = $state(false);
|
batchWorking = $state(false);
|
||||||
pagesPerSec: number | null = $state(null);
|
pagesPerSec: number | null = $state(null);
|
||||||
eta: 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 toastsEnabled() { return store.settings.downloadToastsEnabled ?? true; }
|
||||||
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
get autoRetryEnabled() { return store.settings.downloadAutoRetry ?? false; }
|
||||||
@@ -82,6 +86,52 @@ class DownloadStore {
|
|||||||
this.status = ds;
|
this.status = ds;
|
||||||
setActiveDownloads(toActiveDownloads(ds.queue));
|
setActiveDownloads(toActiveDownloads(ds.queue));
|
||||||
this.updateSpeed(ds);
|
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<boolean> {
|
||||||
|
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 = `
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)">
|
||||||
|
<p style="margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em">Low disk space</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)">
|
||||||
|
<p style="margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-muted);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)">
|
||||||
|
The download queue is estimated to exceed 95% of your available storage. Download anyway?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end;gap:var(--sp-2)">
|
||||||
|
<button id="_moku-storage-cancel" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid var(--border-dim);background:none;color:var(--text-muted);cursor:pointer">Cancel</button>
|
||||||
|
<button id="_moku-storage-confirm" style="font-family:var(--font-ui);font-size:var(--text-xs);letter-spacing:var(--tracking-wide);padding:5px var(--sp-3);border-radius:var(--radius-sm);border:1px solid color-mix(in srgb,var(--color-error) 40%,transparent);background:color-mix(in srgb,var(--color-error) 10%,transparent);color:var(--color-error);cursor:pointer">Download anyway</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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<boolean> {
|
||||||
|
if (this.freeBytes === null) return true;
|
||||||
|
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
|
||||||
|
return this.confirmStorageOverrun();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSpeed(ds: DownloadStatus) {
|
private updateSpeed(ds: DownloadStatus) {
|
||||||
@@ -172,11 +222,21 @@ class DownloadStore {
|
|||||||
finally { this.batchWorking = false; }
|
finally { this.batchWorking = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async enqueue(chapterId: number): Promise<boolean> {
|
||||||
|
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) {
|
async retryOne(chapterId: number) {
|
||||||
if (this.dequeueing.has(chapterId)) return;
|
if (this.dequeueing.has(chapterId)) return;
|
||||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_DOWNLOAD, { chapterId });
|
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 });
|
await gql(ENQUEUE_DOWNLOAD, { chapterId });
|
||||||
this.poll();
|
this.poll();
|
||||||
} catch (e) { console.error(e); this.poll(); }
|
} catch (e) { console.error(e); this.poll(); }
|
||||||
@@ -189,6 +249,8 @@ class DownloadStore {
|
|||||||
const ids = [...this.erroredIds];
|
const ids = [...this.erroredIds];
|
||||||
try {
|
try {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
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 });
|
for (const id of ids) await gql(ENQUEUE_DOWNLOAD, { chapterId: id });
|
||||||
this.poll();
|
this.poll();
|
||||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
@@ -204,6 +266,8 @@ class DownloadStore {
|
|||||||
try {
|
try {
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids });
|
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 });
|
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 });
|
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||||
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
|
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 { 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 { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
|
||||||
import {
|
import {
|
||||||
store, addToast, openReader, setActiveManga,
|
store, addToast, openReader, setActiveManga,
|
||||||
@@ -321,7 +321,7 @@
|
|||||||
async function enqueue(ch: Chapter, e: MouseEvent) {
|
async function enqueue(ch: Chapter, e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
enqueueing = new Set(enqueueing).add(ch.id);
|
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 });
|
addToast({ kind: "download", title: "Download queued", body: ch.name });
|
||||||
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
if (store.activeManga) reloadChapters(store.activeManga.id);
|
||||||
@@ -329,7 +329,10 @@
|
|||||||
|
|
||||||
async function enqueueMultiple(chapterIds: number[]) {
|
async function enqueueMultiple(chapterIds: number[]) {
|
||||||
if (!chapterIds.length) return;
|
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` });
|
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
|
||||||
if (store.activeManga) reloadChapters(store.activeManga.id);
|
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 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 },
|
{ label: "Mark below as unread", icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
|
||||||
{ separator: true },
|
{ 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 },
|
{ 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 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)) },
|
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
|
||||||
|
|||||||
Reference in New Issue
Block a user