mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Fix: Cache Adjustments (WIP)
This commit is contained in:
@@ -96,13 +96,43 @@ fn wait_until_deletable(path: &std::path::Path, timeout_secs: u64) -> bool {
|
||||
#[tauri::command]
|
||||
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let window = app.get_webview_window("main").ok_or("no main window")?;
|
||||
window.clear_all_browsing_data().map_err(|e| e.to_string())?;
|
||||
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<Result<(), String>>();
|
||||
|
||||
window
|
||||
.with_webview(move |wv| {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2_2;
|
||||
use windows::core::Interface;
|
||||
let core = wv.controller().CoreWebView2().map_err(|e| e.to_string());
|
||||
let result = core.and_then(|c| {
|
||||
c.cast::<ICoreWebView2_2>()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.and_then(|c2| {
|
||||
unsafe {
|
||||
c2.ClearBrowsingDataAll(None).map_err(|e| e.to_string())
|
||||
}
|
||||
});
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
rx.await.map_err(|e| e.to_string())??;
|
||||
|
||||
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||
if cache_dir.exists() {
|
||||
wait_until_deletable(&cache_dir, 3);
|
||||
remove_dir_best_effort(&cache_dir);
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -135,7 +165,6 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let data_dir = suwayomi_data_dir();
|
||||
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
|
||||
|
||||
// Wait up to 10s for the JVM to release file locks
|
||||
for entry_name in &targets {
|
||||
let p = data_dir.join(entry_name);
|
||||
if p.exists() {
|
||||
|
||||
Vendored
+7
-2
@@ -5,8 +5,9 @@ import { uiAuth } from "@core/auth";
|
||||
const cache = new Map<string, string>();
|
||||
const inflight = new Map<string, Promise<string>>();
|
||||
const MAX_CONCURRENT = 6;
|
||||
let active = 0;
|
||||
let active = 0;
|
||||
let drainScheduled = false;
|
||||
let clearing = false;
|
||||
|
||||
interface QueueEntry {
|
||||
url: string;
|
||||
@@ -34,7 +35,9 @@ function getAuthHeaders(): Record<string, string> {
|
||||
async function doFetch(url: string): Promise<string> {
|
||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||
if (!res.ok) throw new Error(`${res.status}`);
|
||||
const blobUrl = URL.createObjectURL(await res.blob());
|
||||
const blob = await res.blob();
|
||||
if (clearing) throw new DOMException("Cancelled", "AbortError");
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
cache.set(url, blobUrl);
|
||||
return blobUrl;
|
||||
}
|
||||
@@ -121,8 +124,10 @@ export function cancelQueuedFetches(): void {
|
||||
}
|
||||
|
||||
export function clearBlobCache(): void {
|
||||
clearing = true;
|
||||
cancelQueuedFetches();
|
||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
clearing = false;
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
export * from './memoryCache';
|
||||
export * from './pageCache';
|
||||
export * from './imageCache';
|
||||
export * from './queryCache';
|
||||
export * from './queryCache';
|
||||
Vendored
+44
@@ -0,0 +1,44 @@
|
||||
interface MemEntry<T> {
|
||||
value: T;
|
||||
expiresAt: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class MemoryCache<T> {
|
||||
readonly #cap: number;
|
||||
readonly #ttl: number;
|
||||
readonly #map = new Map<string, MemEntry<T>>();
|
||||
|
||||
constructor(capacity: number, ttlMs: number) {
|
||||
this.#cap = capacity;
|
||||
this.#ttl = ttlMs;
|
||||
}
|
||||
|
||||
get(key: string): T | undefined {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return undefined; }
|
||||
this.#map.delete(key);
|
||||
this.#map.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
set(key: string, value: T): void {
|
||||
if (this.#map.has(key)) this.#map.delete(key);
|
||||
else if (this.#map.size >= this.#cap) this.#map.delete(this.#map.keys().next().value!);
|
||||
this.#map.set(key, { value, expiresAt: Date.now() + this.#ttl, key });
|
||||
}
|
||||
|
||||
has(key: string): boolean {
|
||||
const entry = this.#map.get(key);
|
||||
if (!entry) return false;
|
||||
if (Date.now() > entry.expiresAt) { this.#map.delete(key); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
delete(key: string): void { this.#map.delete(key); }
|
||||
|
||||
clear(): void { this.#map.clear(); }
|
||||
|
||||
get size(): number { return this.#map.size; }
|
||||
}
|
||||
Vendored
+1
-4
@@ -62,10 +62,7 @@ export function measureAspect(url: string, useBlob: boolean): Promise<number> {
|
||||
}
|
||||
|
||||
export function preloadImage(url: string, useBlob: boolean): void {
|
||||
if (useBlob) {
|
||||
preloadBlobUrls([url], 0);
|
||||
return;
|
||||
}
|
||||
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
Vendored
+74
-8
@@ -1,11 +1,14 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
@@ -16,6 +19,16 @@ function registerGroups(key: string, group?: string | string[]) {
|
||||
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||
groups.get(tag)!.add(key);
|
||||
if (!keyToGroups.has(key)) keyToGroups.set(key, new Set());
|
||||
keyToGroups.get(key)!.add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
function unregisterKey(key: string) {
|
||||
const tags = keyToGroups.get(key);
|
||||
if (tags) {
|
||||
for (const tag of tags) groups.get(tag)?.delete(key);
|
||||
keyToGroups.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,14 +40,20 @@ export const cache = {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
}) as Promise<T>;
|
||||
store.set(key, { promise, fetchedAt: Date.now() });
|
||||
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl });
|
||||
registerGroups(key, group);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
set<T>(key: string, value: T, group?: string | string[]) {
|
||||
store.set(key, { promise: Promise.resolve(value), fetchedAt: Date.now() });
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
store.set(key, {
|
||||
promise: Promise.resolve(value),
|
||||
fetchedAt: Date.now(),
|
||||
fetcher: existing?.fetcher,
|
||||
ttl: existing?.ttl,
|
||||
});
|
||||
registerGroups(key, group);
|
||||
notify(key);
|
||||
},
|
||||
@@ -43,10 +62,38 @@ export const cache = {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing) return;
|
||||
const next = existing.promise.then(fn);
|
||||
store.set(key, { promise: next, fetchedAt: Date.now() });
|
||||
store.set(key, { ...existing, promise: next, fetchedAt: Date.now() });
|
||||
next.then(() => notify(key)).catch(() => {});
|
||||
},
|
||||
|
||||
refresh<T>(key: string): Promise<T> | undefined {
|
||||
const existing = store.get(key) as Entry<T> | undefined;
|
||||
if (!existing?.fetcher) return undefined;
|
||||
const promise = (existing.fetcher as () => Promise<T>)().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
},
|
||||
|
||||
refreshGroup(tag: string): void {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of [...keys]) {
|
||||
const existing = store.get(key);
|
||||
if (existing?.fetcher) {
|
||||
const promise = existing.fetcher().catch(err => {
|
||||
if (err?.name !== "AbortError") store.delete(key);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
store.set(key, { ...existing, promise, fetchedAt: Date.now() });
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
has(key: string): boolean { return store.has(key); },
|
||||
|
||||
ageOf(key: string): number | undefined {
|
||||
@@ -54,18 +101,35 @@ export const cache = {
|
||||
return e ? Date.now() - e.fetchedAt : undefined;
|
||||
},
|
||||
|
||||
clear(key: string) { store.delete(key); notify(key); },
|
||||
isStale(key: string): boolean {
|
||||
const e = store.get(key);
|
||||
if (!e) return true;
|
||||
return Date.now() - e.fetchedAt >= (e.ttl ?? DEFAULT_TTL_MS);
|
||||
},
|
||||
|
||||
clear(key: string) {
|
||||
unregisterKey(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
},
|
||||
|
||||
clearGroup(tag: string) {
|
||||
const keys = groups.get(tag);
|
||||
if (!keys) return;
|
||||
for (const key of keys) { store.delete(key); notify(key); }
|
||||
for (const key of [...keys]) {
|
||||
keyToGroups.get(key)?.delete(tag);
|
||||
if (keyToGroups.get(key)?.size === 0) keyToGroups.delete(key);
|
||||
store.delete(key);
|
||||
notify(key);
|
||||
}
|
||||
groups.delete(tag);
|
||||
},
|
||||
|
||||
clearAll() {
|
||||
const allKeys = [...store.keys()];
|
||||
store.clear(); groups.clear();
|
||||
store.clear();
|
||||
groups.clear();
|
||||
keyToGroups.clear();
|
||||
allKeys.forEach(notify);
|
||||
},
|
||||
|
||||
@@ -161,7 +225,9 @@ export function getTopSources<T extends { id: string }>(sources: T[]): T[] {
|
||||
}
|
||||
|
||||
export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): Promise<void> {
|
||||
cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
const didRefresh = cache.refresh(CACHE_KEYS.MANGA(mangaId));
|
||||
if (!didRefresh) cache.clear(CACHE_KEYS.MANGA(mangaId));
|
||||
|
||||
cache.clear(CACHE_KEYS.CHAPTERS(mangaId));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
|
||||
@@ -13,17 +13,18 @@
|
||||
import type { BackupEntry } from "@core/persistence/persist";
|
||||
import { DEFAULT_SETTINGS } from "@types/settings";
|
||||
import { DEFAULT_READING_STATS } from "@types/history";
|
||||
import { clearBlobCache } from "@core/cache/imageCache";
|
||||
import { clearPageCache } from "@core/cache/pageCache";
|
||||
import { cache as queryCache } from "@core/cache/queryCache";
|
||||
|
||||
type ResetState = "idle" | "busy" | "done" | "error";
|
||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
||||
|
||||
let resetItems = $state<ResetItem[]>([
|
||||
{ key: "moku-cache", label: "Clear Moku cache", desc: "Removes image cache and temporary files stored by Moku.", state: "idle", error: null, confirm: false },
|
||||
{ key: "suwayomi-cache", label: "Clear Suwayomi cache", desc: "Deletes the Suwayomi cache and KCEF directories inside the data folder.", state: "idle", error: null, confirm: false },
|
||||
{ key: "server-cache", label: "Clear server image cache", desc: "Removes cached chapter pages and thumbnails stored on the Suwayomi server.", state: "idle", error: null, confirm: false },
|
||||
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
|
||||
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
|
||||
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
|
||||
{ key: "all-cache", label: "Clear all caches", desc: "Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.", state: "idle", error: null, confirm: false },
|
||||
{ key: "reading-history", label: "Clear reading history", desc: "Erases chapter history, read log, reading stats, and daily read counts.", state: "idle", error: null, confirm: true },
|
||||
{ key: "moku-settings", label: "Reset Moku settings", desc: "Restores all app settings to their defaults. Does not affect library data.", state: "idle", error: null, confirm: true },
|
||||
{ key: "suwayomi-data", label: "Reset Suwayomi data", desc: "Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.", state: "idle", error: null, confirm: true },
|
||||
]);
|
||||
|
||||
let confirming = $state<string | null>(null);
|
||||
@@ -73,19 +74,24 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function clearAllCaches(): Promise<void> {
|
||||
clearBlobCache();
|
||||
clearPageCache();
|
||||
queryCache.clearAll();
|
||||
await Promise.all([
|
||||
invoke("clear_moku_cache"),
|
||||
invoke("clear_suwayomi_cache"),
|
||||
gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }),
|
||||
]);
|
||||
}
|
||||
|
||||
async function runReset(key: string) {
|
||||
confirming = null;
|
||||
patchReset(key, { state: "busy", error: null });
|
||||
try {
|
||||
switch (key) {
|
||||
case "moku-cache":
|
||||
await invoke("clear_moku_cache");
|
||||
break;
|
||||
case "suwayomi-cache":
|
||||
await invoke("clear_suwayomi_cache");
|
||||
break;
|
||||
case "server-cache":
|
||||
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
|
||||
case "all-cache":
|
||||
await clearAllCaches();
|
||||
break;
|
||||
case "reading-history":
|
||||
store.clearHistory();
|
||||
|
||||
Reference in New Issue
Block a user