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]
|
#[tauri::command]
|
||||||
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
pub async fn clear_moku_cache(app: tauri::AppHandle) -> Result<(), String> {
|
||||||
let window = app.get_webview_window("main").ok_or("no main window")?;
|
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())?;
|
let cache_dir = app.path().app_cache_dir().map_err(|e| e.to_string())?;
|
||||||
if cache_dir.exists() {
|
if cache_dir.exists() {
|
||||||
|
wait_until_deletable(&cache_dir, 3);
|
||||||
remove_dir_best_effort(&cache_dir);
|
remove_dir_best_effort(&cache_dir);
|
||||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +165,6 @@ pub fn reset_suwayomi_data(app: tauri::AppHandle) -> Result<(), String> {
|
|||||||
let data_dir = suwayomi_data_dir();
|
let data_dir = suwayomi_data_dir();
|
||||||
let targets = ["database.mv.db", "extensions", "settings", "logs", "local"];
|
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 {
|
for entry_name in &targets {
|
||||||
let p = data_dir.join(entry_name);
|
let p = data_dir.join(entry_name);
|
||||||
if p.exists() {
|
if p.exists() {
|
||||||
|
|||||||
Vendored
+7
-2
@@ -5,8 +5,9 @@ import { uiAuth } from "@core/auth";
|
|||||||
const cache = new Map<string, string>();
|
const cache = new Map<string, string>();
|
||||||
const inflight = new Map<string, Promise<string>>();
|
const inflight = new Map<string, Promise<string>>();
|
||||||
const MAX_CONCURRENT = 6;
|
const MAX_CONCURRENT = 6;
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let drainScheduled = false;
|
let drainScheduled = false;
|
||||||
|
let clearing = false;
|
||||||
|
|
||||||
interface QueueEntry {
|
interface QueueEntry {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -34,7 +35,9 @@ function getAuthHeaders(): Record<string, string> {
|
|||||||
async function doFetch(url: string): Promise<string> {
|
async function doFetch(url: string): Promise<string> {
|
||||||
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
const res = await tauriFetch(url, { method: "GET", headers: getAuthHeaders() });
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
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);
|
cache.set(url, blobUrl);
|
||||||
return blobUrl;
|
return blobUrl;
|
||||||
}
|
}
|
||||||
@@ -121,8 +124,10 @@ export function cancelQueuedFetches(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function clearBlobCache(): void {
|
export function clearBlobCache(): void {
|
||||||
|
clearing = true;
|
||||||
cancelQueuedFetches();
|
cancelQueuedFetches();
|
||||||
cache.forEach(blob => URL.revokeObjectURL(blob));
|
cache.forEach(blob => URL.revokeObjectURL(blob));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
inflight.clear();
|
inflight.clear();
|
||||||
|
clearing = false;
|
||||||
}
|
}
|
||||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export * from './memoryCache';
|
export * from './memoryCache';
|
||||||
export * from './pageCache';
|
export * from './pageCache';
|
||||||
export * from './imageCache';
|
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 {
|
export function preloadImage(url: string, useBlob: boolean): void {
|
||||||
if (useBlob) {
|
if (useBlob) { preloadBlobUrls([url], 0); return; }
|
||||||
preloadBlobUrls([url], 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+74
-8
@@ -1,11 +1,14 @@
|
|||||||
interface Entry<T> {
|
interface Entry<T> {
|
||||||
promise: Promise<T>;
|
promise: Promise<T>;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
|
fetcher?: () => Promise<T>;
|
||||||
|
ttl?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = new Map<string, Entry<unknown>>();
|
const store = new Map<string, Entry<unknown>>();
|
||||||
const subs = new Map<string, Set<() => void>>();
|
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;
|
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]) {
|
for (const tag of Array.isArray(group) ? group : [group]) {
|
||||||
if (!groups.has(tag)) groups.set(tag, new Set());
|
if (!groups.has(tag)) groups.set(tag, new Set());
|
||||||
groups.get(tag)!.add(key);
|
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);
|
if (err?.name !== "AbortError") store.delete(key);
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
}) as Promise<T>;
|
}) 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);
|
registerGroups(key, group);
|
||||||
promise.then(() => notify(key)).catch(() => {});
|
promise.then(() => notify(key)).catch(() => {});
|
||||||
return promise;
|
return promise;
|
||||||
},
|
},
|
||||||
|
|
||||||
set<T>(key: string, value: T, group?: string | string[]) {
|
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);
|
registerGroups(key, group);
|
||||||
notify(key);
|
notify(key);
|
||||||
},
|
},
|
||||||
@@ -43,10 +62,38 @@ export const cache = {
|
|||||||
const existing = store.get(key) as Entry<T> | undefined;
|
const existing = store.get(key) as Entry<T> | undefined;
|
||||||
if (!existing) return;
|
if (!existing) return;
|
||||||
const next = existing.promise.then(fn);
|
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(() => {});
|
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); },
|
has(key: string): boolean { return store.has(key); },
|
||||||
|
|
||||||
ageOf(key: string): number | undefined {
|
ageOf(key: string): number | undefined {
|
||||||
@@ -54,18 +101,35 @@ export const cache = {
|
|||||||
return e ? Date.now() - e.fetchedAt : undefined;
|
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) {
|
clearGroup(tag: string) {
|
||||||
const keys = groups.get(tag);
|
const keys = groups.get(tag);
|
||||||
if (!keys) return;
|
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);
|
groups.delete(tag);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearAll() {
|
clearAll() {
|
||||||
const allKeys = [...store.keys()];
|
const allKeys = [...store.keys()];
|
||||||
store.clear(); groups.clear();
|
store.clear();
|
||||||
|
groups.clear();
|
||||||
|
keyToGroups.clear();
|
||||||
allKeys.forEach(notify);
|
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> {
|
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.CHAPTERS(mangaId));
|
||||||
cache.clear(CACHE_KEYS.LIBRARY);
|
cache.clear(CACHE_KEYS.LIBRARY);
|
||||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||||
|
|||||||
@@ -13,17 +13,18 @@
|
|||||||
import type { BackupEntry } from "@core/persistence/persist";
|
import type { BackupEntry } from "@core/persistence/persist";
|
||||||
import { DEFAULT_SETTINGS } from "@types/settings";
|
import { DEFAULT_SETTINGS } from "@types/settings";
|
||||||
import { DEFAULT_READING_STATS } from "@types/history";
|
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";
|
type ResetState = "idle" | "busy" | "done" | "error";
|
||||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean; }
|
||||||
|
|
||||||
let resetItems = $state<ResetItem[]>([
|
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: "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: "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: "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: "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: "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: "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: "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: "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);
|
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) {
|
async function runReset(key: string) {
|
||||||
confirming = null;
|
confirming = null;
|
||||||
patchReset(key, { state: "busy", error: null });
|
patchReset(key, { state: "busy", error: null });
|
||||||
try {
|
try {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "moku-cache":
|
case "all-cache":
|
||||||
await invoke("clear_moku_cache");
|
await clearAllCaches();
|
||||||
break;
|
|
||||||
case "suwayomi-cache":
|
|
||||||
await invoke("clear_suwayomi_cache");
|
|
||||||
break;
|
|
||||||
case "server-cache":
|
|
||||||
await gql(CLEAR_CACHED_IMAGES, { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false });
|
|
||||||
break;
|
break;
|
||||||
case "reading-history":
|
case "reading-history":
|
||||||
store.clearHistory();
|
store.clearHistory();
|
||||||
|
|||||||
Reference in New Issue
Block a user