import { gql } from "@api/client"; import { GET_DOWNLOAD_STATUS } from "@api/queries"; import { START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD, DEQUEUE_CHAPTERS_DOWNLOAD, ENQUEUE_DOWNLOAD, REORDER_DOWNLOAD, } from "@api/mutations"; import { addToast, setActiveDownloads, store, updateSettings } from "@store/state.svelte"; import { boot } from "@store/boot.svelte"; import type { DownloadStatus, DownloadQueueItem } from "@types/index"; import { toActiveDownloads, optimisticRemove, optimisticRemoveMany, 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); loading = $state(true); togglingPlay = $state(false); clearing = $state(false); dequeueing = $state(new Set()); selected = $state(new Set()); batchWorking = $state(false); 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; } private lastSample: SpeedSample | null = null; private prevQueue: DownloadQueueItem[] = []; private autoRetryHnd: AutoRetryHandle | null = null; get queue() { return this.status?.queue ?? []; } get isRunning() { return isRunning(this.status?.state); } get erroredIds() { return new Set(getErrored(this.queue).map((i) => i.chapter.id)); } get hasErrored() { return this.erroredIds.size > 0; } toggleToasts() { const next = !this.toastsEnabled; updateSettings({ downloadToastsEnabled: next }); addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", body: next ? "You'll be notified when chapters finish downloading" : "Download notifications are silenced", duration: 2500 }); } toggleAutoRetry() { if (this.autoRetryEnabled) { this.autoRetryHnd?.stop(); this.autoRetryHnd = null; updateSettings({ downloadAutoRetry: false }); addToast({ kind: "info", title: "Auto-retry disabled", body: "Failed downloads will no longer retry automatically", duration: 2500 }); } else { updateSettings({ downloadAutoRetry: true }); this.autoRetryHnd = startAutoRetry( () => this.queue, () => this.isRunning, () => this.retryAllErrored(), ); addToast({ kind: "info", title: "Auto-retry enabled", body: "Errored downloads will retry automatically", duration: 3000 }); } } detectTransitions(next: DownloadQueueItem[]) { if (!this.toastsEnabled) return; const nextMap = new Map(next.map(i => [i.chapter.id, i])); for (const item of this.prevQueue) { if (item.state !== "DOWNLOADING") continue; const nextItem = nextMap.get(item.chapter.id); const manga = item.chapter.manga; const label = manga ? `${manga.title} — ${item.chapter.name}` : item.chapter.name; if (!nextItem) { addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 }); } else if (nextItem.state === "ERROR") { addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 }); } } this.prevQueue = next.slice(); } applyStatus(ds: DownloadStatus) { 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) { const active = ds.queue[0]; if (!active || active.state !== "DOWNLOADING") { this.lastSample = null; this.pagesPerSec = null; this.eta = null; return; } const sample: SpeedSample = { ts: Date.now(), progress: active.progress, pages: active.chapter.pageCount ?? 0, }; const speed = calcSpeed(this.lastSample, sample); this.lastSample = sample; if (speed !== null) { this.pagesPerSec = speed; this.eta = estimateEta(speed, ds.queue); } } async poll() { if (boot.sessionExpired) return; gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) .then((d) => this.applyStatus(d.downloadStatus)) .catch(console.error) .finally(() => { this.loading = false; }); } async togglePlay() { if (this.togglingPlay) return; this.togglingPlay = true; const wasRunning = this.isRunning; if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" }; try { if (wasRunning) { const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER); this.applyStatus(d.stopDownloader.downloadStatus); } else { const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER); this.applyStatus(d.startDownloader.downloadStatus); } } catch (e) { console.error(e); this.poll(); } finally { this.togglingPlay = false; addToast({ kind: "info", title: wasRunning ? "Downloads paused" : "Downloads resumed", body: wasRunning ? "The download queue has been paused" : "The download queue is running", duration: 2500 }); } } async clear() { if (this.clearing) return; this.clearing = true; this.selected = new Set(); if (this.status) this.status = { ...this.status, queue: [] }; setActiveDownloads([]); try { const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER); this.applyStatus(d.clearDownloader.downloadStatus); addToast({ kind: "info", title: "Queue cleared", body: "All pending downloads have been removed", duration: 2500 }); } catch (e) { console.error(e); this.poll(); } finally { this.clearing = false; } } async dequeue(chapterId: number) { if (this.dequeueing.has(chapterId)) return; this.dequeueing = new Set(this.dequeueing).add(chapterId); if (this.status) this.status = { ...this.status, queue: optimisticRemove(this.status.queue, chapterId) }; this.selected.delete(chapterId); this.selected = new Set(this.selected); try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); } catch (e) { console.error(e); this.poll(); } finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } } async dequeueSelected() { if (this.batchWorking || this.selected.size === 0) return; this.batchWorking = true; const ids = [...this.selected]; if (this.status) this.status = { ...this.status, queue: optimisticRemoveMany(this.status.queue, this.selected) }; this.selected = new Set(); try { await gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }); this.poll(); addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, body: "Selected items have been removed from the queue", duration: 2500 }); } catch (e) { console.error(e); this.poll(); } 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(); } finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); } } async retryAllErrored() { if (this.batchWorking || !this.hasErrored) return; this.batchWorking = true; 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 }); } catch (e) { console.error(e); this.poll(); } finally { this.batchWorking = false; } } async retrySelected() { if (this.batchWorking || this.selected.size === 0) return; this.batchWorking = true; const ids = [...this.selected].filter((id) => this.erroredIds.has(id)); this.selected = new Set(); 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 }); } this.poll(); } catch (e) { console.error(e); this.poll(); } finally { this.batchWorking = false; } } async reorder(chapterId: number, direction: "up" | "down") { const idx = this.queue.findIndex((i) => i.chapter.id === chapterId); if (idx === -1) return; const to = direction === "up" ? idx - 1 : idx + 1; if (to < 0 || to >= this.queue.length) return; const newQueue = [...this.queue]; [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; if (this.status) this.status = { ...this.status, queue: newQueue }; try { const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( REORDER_DOWNLOAD, { chapterId, to }, ); this.applyStatus(d.reorderChapterDownload.downloadStatus); } catch (e) { console.error(e); this.poll(); } } async reorderSelected(direction: "up" | "down") { if (this.batchWorking || this.selected.size === 0) return; this.batchWorking = true; const queue = [...this.queue]; const selectedIndices = queue .map((item, i) => ({ id: item.chapter.id, i })) .filter(({ id }) => this.selected.has(id)) .map(({ i }) => i) .sort((a, b) => direction === "up" ? a - b : b - a); if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; } if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; } const newQueue = [...queue]; for (const idx of selectedIndices) { const to = direction === "up" ? idx - 1 : idx + 1; if (to < 0 || to >= newQueue.length) break; [newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]]; } if (this.status) this.status = { ...this.status, queue: newQueue }; try { for (const idx of selectedIndices) { const to = direction === "up" ? idx - 1 : idx + 1; if (to < 0 || to >= queue.length) break; const chapterId = queue[idx].chapter.id; await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( REORDER_DOWNLOAD, { chapterId, to }, ); } this.poll(); } catch (e) { console.error(e); this.poll(); } finally { this.batchWorking = false; } } async reorderToEdge(chapterId: number, edge: "top" | "bottom") { const idx = this.queue.findIndex((i) => i.chapter.id === chapterId); if (idx === -1) return; const first = this.isRunning ? 1 : 0; const last = this.queue.length - 1; const to = edge === "top" ? first : last; if (idx === to) return; const newQueue = [...this.queue]; newQueue.splice(idx, 1); newQueue.splice(to, 0, this.queue[idx]); if (this.status) this.status = { ...this.status, queue: newQueue }; try { const d = await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( REORDER_DOWNLOAD, { chapterId, to }, ); this.applyStatus(d.reorderChapterDownload.downloadStatus); } catch (e) { console.error(e); this.poll(); } } async reorderSelectedToEdge(edge: "top" | "bottom") { if (this.batchWorking || this.selected.size === 0) return; this.batchWorking = true; const first = this.isRunning ? 1 : 0; const active = this.queue.slice(0, first); const moveable = this.queue.slice(first); const pinned = moveable.filter((i) => this.selected.has(i.chapter.id)); const rest = moveable.filter((i) => !this.selected.has(i.chapter.id)); const newQueue = edge === "top" ? [...active, ...pinned, ...rest] : [...active, ...rest, ...pinned]; if (this.status) this.status = { ...this.status, queue: newQueue }; const last = this.queue.length - 1; try { if (edge === "top") { for (let i = 0; i < pinned.length; i++) { await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: first + i }, ); } } else { for (let i = 0; i < pinned.length; i++) { await gql<{ reorderChapterDownload: { downloadStatus: DownloadStatus } }>( REORDER_DOWNLOAD, { chapterId: pinned[i].chapter.id, to: last - (pinned.length - 1 - i) }, ); } } this.poll(); } catch (e) { console.error(e); this.poll(); } finally { this.batchWorking = false; } } selectOnly(chapterId: number) { this.selected = new Set([chapterId]); } toggleSelect(chapterId: number) { const next = new Set(this.selected); if (next.has(chapterId)) next.delete(chapterId); else next.add(chapterId); this.selected = next; } selectRange(fromId: number, toId: number) { const ids = this.queue.map((i) => i.chapter.id); const a = ids.indexOf(fromId), b = ids.indexOf(toId); if (a === -1 || b === -1) return; const [lo, hi] = a < b ? [a, b] : [b, a]; const next = new Set(this.selected); for (let i = lo; i <= hi; i++) next.add(ids[i]); this.selected = next; } selectAll() { this.selected = new Set(this.queue.map((i) => i.chapter.id)); } clearSelection() { this.selected = new Set(); } } export const downloadStore = new DownloadStore();