mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Chore: Port over Extensions & Search
This commit is contained in:
@@ -1,18 +1,342 @@
|
||||
import type { DownloadItem } from "$lib/server-adapters/types";
|
||||
import type { DownloadStatus, DownloadQueueItem } from "$lib/types/api";
|
||||
import {
|
||||
loadDownloadStatus, dequeueDownload, dequeueDownloads,
|
||||
reorderDownload, clearDownloads, startDownloader, stopDownloader, enqueueDownload,
|
||||
getStorageInfo,
|
||||
} from "$lib/request-manager/downloads";
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { addToast } from "$lib/state/notifications.svelte";
|
||||
import {
|
||||
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
|
||||
type SpeedSample,
|
||||
} from "$lib/components/downloads/lib/downloadQueue";
|
||||
import { startAutoRetry, type AutoRetryHandle } from "$lib/components/downloads/lib/autoRetry";
|
||||
import { mount, unmount } from "svelte";
|
||||
import StorageWarningDialog from "$lib/components/downloads/StorageWarningDialog.svelte";
|
||||
|
||||
export const downloadsState = $state({
|
||||
items: [] as DownloadItem[],
|
||||
error: null as string | null,
|
||||
});
|
||||
class DownloadStore {
|
||||
status: DownloadStatus | null = $state(null);
|
||||
loading = $state(true);
|
||||
togglingPlay = $state(false);
|
||||
clearing = $state(false);
|
||||
dequeueing = $state(new Set<number>());
|
||||
selected = $state(new Set<number>());
|
||||
batchWorking = $state(false);
|
||||
pagesPerSec: number | null = $state(null);
|
||||
eta: number | null = $state(null);
|
||||
storageWarning = $state(false);
|
||||
|
||||
export function activeDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === "downloading");
|
||||
private freeBytes: number | null = null;
|
||||
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; }
|
||||
get toastsEnabled() { return settingsState.settings.downloadToastsEnabled ?? true; }
|
||||
get autoRetryEnabled() { return settingsState.settings.downloadAutoRetry ?? false; }
|
||||
|
||||
private applyStatus(ds: DownloadStatus) {
|
||||
this.detectTransitions(ds.queue);
|
||||
this.status = ds;
|
||||
this.updateSpeed(ds);
|
||||
this.syncFreeBytes(ds);
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
|
||||
private async syncFreeBytes(ds: DownloadStatus) {
|
||||
const path = settingsState.settings.serverDownloadsPath ?? "";
|
||||
if (!path) return;
|
||||
try {
|
||||
const info = await getStorageInfo(path);
|
||||
this.freeBytes = info.freeBytes;
|
||||
this.storageWarning = estimateQueueBytes(ds.queue) > info.freeBytes * 0.95;
|
||||
} catch { }
|
||||
}
|
||||
|
||||
private confirmStorageOverrun(): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const target = document.createElement("div");
|
||||
document.body.appendChild(target);
|
||||
const instance = mount(StorageWarningDialog, {
|
||||
target,
|
||||
props: {
|
||||
onConfirm: () => { unmount(instance); target.remove(); resolve(true); },
|
||||
onCancel: () => { unmount(instance); target.remove(); resolve(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();
|
||||
}
|
||||
|
||||
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 label = item.chapter.manga
|
||||
? `${item.chapter.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();
|
||||
}
|
||||
|
||||
async poll() {
|
||||
try {
|
||||
const ds = await loadDownloadStatus();
|
||||
if (ds) this.applyStatus(ds);
|
||||
} catch { } finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async enqueue(chapterId: number): Promise<boolean> {
|
||||
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as DownloadQueueItem];
|
||||
if (!(await this.guardStorage(projected))) return false;
|
||||
try { await enqueueDownload(String(chapterId)); await this.poll(); } catch { }
|
||||
return true;
|
||||
}
|
||||
|
||||
toggleToasts() {
|
||||
const next = !this.toastsEnabled;
|
||||
updateSettings({ downloadToastsEnabled: next });
|
||||
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", duration: 2500 });
|
||||
}
|
||||
|
||||
toggleAutoRetry() {
|
||||
if (this.autoRetryEnabled) {
|
||||
this.autoRetryHnd?.stop();
|
||||
this.autoRetryHnd = null;
|
||||
updateSettings({ downloadAutoRetry: false });
|
||||
addToast({ kind: "info", title: "Auto-retry disabled", duration: 2500 });
|
||||
} else {
|
||||
updateSettings({ downloadAutoRetry: true });
|
||||
this.autoRetryHnd = startAutoRetry(
|
||||
() => this.queue,
|
||||
() => this.isRunning,
|
||||
() => this.retryAllErrored(),
|
||||
);
|
||||
addToast({ kind: "info", title: "Auto-retry enabled", duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const ds = wasRunning ? await stopDownloader() : await startDownloader();
|
||||
if (ds) this.applyStatus(ds); else await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
finally { this.togglingPlay = false; }
|
||||
}
|
||||
|
||||
async clear() {
|
||||
if (this.clearing) return;
|
||||
this.clearing = true;
|
||||
this.selected = new Set();
|
||||
if (this.status) this.status = { ...this.status, queue: [] };
|
||||
try {
|
||||
await clearDownloads();
|
||||
addToast({ kind: "info", title: "Queue cleared", duration: 2500 });
|
||||
} catch { await 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: this.queue.filter(i => i.chapter.id !== chapterId) };
|
||||
const next = new Set(this.selected); next.delete(chapterId); this.selected = next;
|
||||
try { await dequeueDownload(String(chapterId)); await this.poll(); }
|
||||
catch { await this.poll(); }
|
||||
finally { const s = new Set(this.dequeueing); s.delete(chapterId); this.dequeueing = s; }
|
||||
}
|
||||
|
||||
async dequeueSelected() {
|
||||
if (this.batchWorking || !this.selected.size) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.selected];
|
||||
const idSet = new Set(ids);
|
||||
this.selected = new Set();
|
||||
if (this.status) this.status = { ...this.status, queue: this.queue.filter(i => !idSet.has(i.chapter.id)) };
|
||||
try {
|
||||
await dequeueDownloads(ids.map(String));
|
||||
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, duration: 2500 });
|
||||
await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async retryOne(chapterId: number) {
|
||||
if (this.dequeueing.has(chapterId)) return;
|
||||
this.dequeueing = new Set(this.dequeueing).add(chapterId);
|
||||
try {
|
||||
await dequeueDownload(String(chapterId));
|
||||
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
|
||||
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
|
||||
await enqueueDownload(String(chapterId));
|
||||
await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
finally { const s = new Set(this.dequeueing); s.delete(chapterId); this.dequeueing = s; }
|
||||
}
|
||||
|
||||
async retryAllErrored() {
|
||||
if (this.batchWorking || !this.hasErrored) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.erroredIds];
|
||||
try {
|
||||
await dequeueDownloads(ids.map(String));
|
||||
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
|
||||
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
|
||||
for (const id of ids) await enqueueDownload(String(id));
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
async retrySelected() {
|
||||
if (this.batchWorking || !this.selected.size) return;
|
||||
this.batchWorking = true;
|
||||
const ids = [...this.selected].filter(id => this.erroredIds.has(id));
|
||||
this.selected = new Set();
|
||||
try {
|
||||
if (ids.length) {
|
||||
await dequeueDownloads(ids.map(String));
|
||||
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
|
||||
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
|
||||
for (const id of ids) await enqueueDownload(String(id));
|
||||
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
|
||||
}
|
||||
await this.poll();
|
||||
} catch { await 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 ds = await reorderDownload(chapterId, to);
|
||||
if (ds) this.applyStatus(ds); else await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
}
|
||||
|
||||
async reorderSelected(direction: "up" | "down", step: number = 1) {
|
||||
if (this.batchWorking || !this.selected.size) 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" ? Math.max(0, idx - step) : Math.min(newQueue.length - 1, idx + step);
|
||||
[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" ? Math.max(0, idx - step) : Math.min(queue.length - 1, idx + step);
|
||||
await reorderDownload(queue[idx].chapter.id, to);
|
||||
}
|
||||
await this.poll();
|
||||
} catch { await 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 ds = await reorderDownload(chapterId, to);
|
||||
if (ds) this.applyStatus(ds); else await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
}
|
||||
|
||||
async reorderSelectedToEdge(edge: "top" | "bottom") {
|
||||
if (this.batchWorking || !this.selected.size) 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 reorderDownload(pinned[i].chapter.id, first + i);
|
||||
} else {
|
||||
for (let i = 0; i < pinned.length; i++)
|
||||
await reorderDownload(pinned[i].chapter.id, last - (pinned.length - 1 - i));
|
||||
}
|
||||
await this.poll();
|
||||
} catch { await this.poll(); }
|
||||
finally { this.batchWorking = false; }
|
||||
}
|
||||
|
||||
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
|
||||
toggleSelect(chapterId: number) {
|
||||
const next = new Set(this.selected);
|
||||
next.has(chapterId) ? next.delete(chapterId) : 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 function queuedDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === "queued");
|
||||
}
|
||||
|
||||
export function downloadCount() {
|
||||
return downloadsState.items.length;
|
||||
}
|
||||
export const downloadStore = new DownloadStore();
|
||||
Reference in New Issue
Block a user