From 81d42662ee25d8db1c264fb1a63fc316b9eea44b Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 26 Mar 2026 13:10:00 -0700 Subject: [PATCH] fixed caching --- src/libraryCache.ts | 65 +++++++++++ src/pages/SettingsPage.tsx | 222 ++++++++++++++++++++++++++++--------- 2 files changed, 232 insertions(+), 55 deletions(-) diff --git a/src/libraryCache.ts b/src/libraryCache.ts index 6582ada..2405e96 100644 --- a/src/libraryCache.ts +++ b/src/libraryCache.ts @@ -21,11 +21,22 @@ export type LibraryCacheSnapshot = { searchCache: Record } +export type LibraryCacheStats = { + snapshotCount: number + staleSnapshotCount: number + totalBytes: number + activeBytes: number + trackCount: number + searchEntryCount: number + updatedAt: number | null +} + const DATABASE_NAME = 'api-mediaplayer-library-cache' const DATABASE_VERSION = 1 const STORE_NAME = 'snapshots' const MAX_TRACKS = 5000 const MAX_SEARCHES = 250 +const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) { return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|') @@ -66,6 +77,18 @@ function withStore(mode: IDBTransactionMode, handler: (store: IDBObjectStore) }) } +function estimateRecordBytes(record: LibraryCacheRecord) { + const payload = JSON.stringify(record) + if (!textEncoder) return payload.length + return textEncoder.encode(payload).length +} + +async function listLibraryCacheRecords(): Promise { + if (typeof indexedDB === 'undefined') return [] + const records = await withStore('readonly', (store) => store.getAll()) + return Array.isArray(records) ? records : [] +} + export async function loadLibraryCache(cacheKey: string): Promise { if (!cacheKey || typeof indexedDB === 'undefined') return null @@ -78,6 +101,47 @@ export async function loadLibraryCache(cacheKey: string): Promise record.cacheKey) + .filter((cacheKey) => cacheKey !== activeCacheKey) + + if (staleKeys.length === 0) return 0 + + await Promise.all(staleKeys.map((cacheKey) => withStore('readwrite', (store) => store.delete(cacheKey)))) + return staleKeys.length +} + +export async function getLibraryCacheStats(activeCacheKey: string): Promise { + if (!activeCacheKey || typeof indexedDB === 'undefined') { + return { + snapshotCount: 0, + staleSnapshotCount: 0, + totalBytes: 0, + activeBytes: 0, + trackCount: 0, + searchEntryCount: 0, + updatedAt: null, + } + } + + const records = await listLibraryCacheRecords() + const activeRecord = records.find((record) => record.cacheKey === activeCacheKey) + + return { + snapshotCount: records.length, + staleSnapshotCount: records.filter((record) => record.cacheKey !== activeCacheKey).length, + totalBytes: records.reduce((sum, record) => sum + estimateRecordBytes(record), 0), + activeBytes: activeRecord ? estimateRecordBytes(activeRecord) : 0, + trackCount: activeRecord?.tracks.length ?? 0, + searchEntryCount: activeRecord ? Object.keys(activeRecord.searchCache || {}).length : 0, + updatedAt: activeRecord?.updatedAt ?? null, + } +} + export async function saveLibraryCache(cacheKey: string, tracks: Track[], searchCache: Record) { if (!cacheKey || typeof indexedDB === 'undefined') return @@ -96,4 +160,5 @@ export async function saveLibraryCache(cacheKey: string, tracks: Track[], search } await withStore('readwrite', (store) => store.put(record)) + await pruneLibraryCache(cacheKey) } \ No newline at end of file diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 35b1325..6e70b02 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { + Alert, Box, FormControl, InputLabel, @@ -30,7 +31,7 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload' import type { Server } from '../context/ServersContext' import { useServers } from '../context/ServersContext' import { HydrusClient, extractTitleFromTags } from '../api/hydrusClient' -import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache' +import { buildLibraryCacheKey, getLibraryCacheStats, loadLibraryCache, pruneLibraryCache, saveLibraryCache } from '../libraryCache' import type { MediaSection, ServerSyncSummary, Track } from '../types' const SYNC_SECTION_LIMIT = 2000 @@ -59,6 +60,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE const [lastTest, setLastTest] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) const [detailsText, setDetailsText] = useState(null) + const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode) + const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled) + const [syncCompletionNotices, setSyncCompletionNotices] = useState>({}) + const [cacheStorageText, setCacheStorageText] = useState('0 B') + const [cacheTrackCount, setCacheTrackCount] = useState(0) + const [cacheSearchCount, setCacheSearchCount] = useState(0) + const [cacheSnapshotCount, setCacheSnapshotCount] = useState(0) + const [cacheUpdatedAt, setCacheUpdatedAt] = useState(null) + const syncNoticeTimeoutsRef = useRef>({}) + const currentCacheKey = useMemo(() => buildLibraryCacheKey(servers), [servers]) const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => { if (!tags || !Array.isArray(tags)) return null @@ -80,6 +91,55 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE setDetailsOpen(false) }, []) + useEffect(() => { + setDraftLibraryDisplayMode(libraryDisplayMode) + }, [libraryDisplayMode]) + + useEffect(() => { + setDraftDevOverlayEnabled(devOverlayEnabled) + }, [devOverlayEnabled]) + + useEffect(() => { + return () => { + Object.values(syncNoticeTimeoutsRef.current).forEach((timeoutId) => window.clearTimeout(timeoutId)) + } + }, []) + + const preferencesDirty = draftLibraryDisplayMode !== libraryDisplayMode || draftDevOverlayEnabled !== devOverlayEnabled + + const formatBytes = (value: number) => { + if (!value || value <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let size = value + let unitIndex = 0 + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024 + unitIndex += 1 + } + + return `${size >= 10 || unitIndex === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unitIndex]}` + } + + const refreshCacheStats = async (cacheKey: string) => { + if (!cacheKey) { + setCacheStorageText('0 B') + setCacheTrackCount(0) + setCacheSearchCount(0) + setCacheSnapshotCount(0) + setCacheUpdatedAt(null) + return + } + + await pruneLibraryCache(cacheKey) + const stats = await getLibraryCacheStats(cacheKey) + setCacheStorageText(formatBytes(stats.activeBytes)) + setCacheTrackCount(stats.trackCount) + setCacheSearchCount(stats.searchEntryCount) + setCacheSnapshotCount(stats.snapshotCount) + setCacheUpdatedAt(stats.updatedAt) + } + const startAdd = () => { setEditing(null) setForm(DEFAULT_SERVER_FORM) @@ -140,15 +200,24 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE const snapshot = await loadLibraryCache(cacheKey) const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) } const mergedTrackMap: Record = {} + const previousServerTrackKeys = new Set() let localCounter = Date.now() for (const track of snapshot?.tracks ?? []) { const hydratedTrack: Track = { ...track, id: ++localCounter } const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId) - if (key) mergedTrackMap[key] = hydratedTrack + if (!key) continue + + if (hydratedTrack.serverId === server.id) { + previousServerTrackKeys.add(key) + continue + } + + mergedTrackMap[key] = hydratedTrack } const counts: ServerSyncSummary['counts'] = {} + const currentServerTrackKeys = new Set() for (const section of SYNC_SECTIONS) { const searchTags = [section.predicate] @@ -164,6 +233,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE const tags = tagMap[fileId] || [] const key = buildTrackCacheKey(server.id, fileId) if (!key) continue + currentServerTrackKeys.add(key) mergedTrackMap[key] = { id: ++localCounter, @@ -186,15 +256,32 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache) const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0) + const addedCount = Array.from(currentServerTrackKeys).filter((key) => !previousServerTrackKeys.has(key)).length + const removedCount = Array.from(previousServerTrackKeys).filter((key) => !currentServerTrackKeys.has(key)).length const summary: ServerSyncSummary = { updatedAt: Date.now(), total, counts, - message: `Synced ${total} cached items`, } updateServer(server.id, { syncSummary: summary }) - setLastTest(summary.message ?? `Synced ${total} cached items`) + const completionMessage = addedCount === 0 && removedCount === 0 + ? `Sync complete. No file changes. ${total} cached items.` + : `Sync complete. Added ${addedCount} files, removed ${removedCount} files.` + setLastTest(`Sync complete. ${total} cached items.`) + setSyncCompletionNotices((current) => ({ ...current, [server.id]: completionMessage })) + await refreshCacheStats(cacheKey) + if (syncNoticeTimeoutsRef.current[server.id]) { + window.clearTimeout(syncNoticeTimeoutsRef.current[server.id]) + } + syncNoticeTimeoutsRef.current[server.id] = window.setTimeout(() => { + setSyncCompletionNotices((current) => { + const next = { ...current } + delete next[server.id] + return next + }) + delete syncNoticeTimeoutsRef.current[server.id] + }, 8000) } catch (error: any) { const message = error?.message ?? String(error) updateServer(server.id, { @@ -211,6 +298,20 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE } } + useEffect(() => { + void refreshCacheStats(currentCacheKey) + }, [currentCacheKey]) + + const handleSavePreferences = () => { + if (draftLibraryDisplayMode !== libraryDisplayMode) { + onLibraryDisplayModeChange(draftLibraryDisplayMode) + } + + if (draftDevOverlayEnabled !== devOverlayEnabled) { + onDevOverlayEnabledChange(draftDevOverlayEnabled) + } + } + return ( @@ -223,57 +324,62 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE - {import.meta.env.DEV && ( - - Library display - - Choose the default Library layout so browsing controls can stay compact. - - - Display - - - - Developer tools - - Control development-only UI that can get in the way on smaller screens. - - onDevOverlayEnabledChange(event.target.checked)} />} - label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'} - sx={{ alignItems: 'flex-start', m: 0 }} - /> + + + + Interface preferences + + Save Library layout and development UI changes together. + + + - )} - {!import.meta.env.DEV && ( - - Library display - - Choose the default Library layout so browsing controls can stay compact. - - - Display - - + + Display + + + + {import.meta.env.DEV && ( + <> + Developer tools + + Control development-only UI that can get in the way on smaller screens. + + setDraftDevOverlayEnabled(event.target.checked)} />} + label={draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'} + sx={{ alignItems: 'flex-start', m: 0 }} + /> + + )} + + + + Library cache + + The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots. + + + + + + - )} + + {cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'} + + @@ -298,7 +404,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE Test startEdit(s)}> @@ -309,12 +415,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE {s.lastTest && s.lastTest.message && } - {s.syncSummary?.message && } + {typeof s.syncSummary?.total === 'number' && } + {s.syncSummary?.message && } {s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => ( ))} {s.forceApiKeyInQuery && } + {syncCompletionNotices[s.id] && ( + + {syncCompletionNotices[s.id]} + + )} {s.syncSummary?.updatedAt && ( Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()}