fixed caching

This commit is contained in:
2026-03-26 13:10:00 -07:00
parent 38d50a814f
commit 81d42662ee
2 changed files with 232 additions and 55 deletions

View File

@@ -21,11 +21,22 @@ export type LibraryCacheSnapshot = {
searchCache: Record<string, number[]> searchCache: Record<string, number[]>
} }
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_NAME = 'api-mediaplayer-library-cache'
const DATABASE_VERSION = 1 const DATABASE_VERSION = 1
const STORE_NAME = 'snapshots' const STORE_NAME = 'snapshots'
const MAX_TRACKS = 5000 const MAX_TRACKS = 5000
const MAX_SEARCHES = 250 const MAX_SEARCHES = 250
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) { export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) {
return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|') return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|')
@@ -66,6 +77,18 @@ function withStore<T>(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<LibraryCacheRecord[]> {
if (typeof indexedDB === 'undefined') return []
const records = await withStore<LibraryCacheRecord[]>('readonly', (store) => store.getAll())
return Array.isArray(records) ? records : []
}
export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> { export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> {
if (!cacheKey || typeof indexedDB === 'undefined') return null if (!cacheKey || typeof indexedDB === 'undefined') return null
@@ -78,6 +101,47 @@ export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSn
} }
} }
export async function pruneLibraryCache(activeCacheKey: string) {
if (!activeCacheKey || typeof indexedDB === 'undefined') return 0
const records = await listLibraryCacheRecords()
const staleKeys = records
.map((record) => 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<LibraryCacheStats> {
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<string, number[]>) { export async function saveLibraryCache(cacheKey: string, tracks: Track[], searchCache: Record<string, number[]>) {
if (!cacheKey || typeof indexedDB === 'undefined') return 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 withStore('readwrite', (store) => store.put(record))
await pruneLibraryCache(cacheKey)
} }

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { import {
Alert,
Box, Box,
FormControl, FormControl,
InputLabel, InputLabel,
@@ -30,7 +31,7 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import type { Server } from '../context/ServersContext' import type { Server } from '../context/ServersContext'
import { useServers } from '../context/ServersContext' import { useServers } from '../context/ServersContext'
import { HydrusClient, extractTitleFromTags } from '../api/hydrusClient' 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' import type { MediaSection, ServerSyncSummary, Track } from '../types'
const SYNC_SECTION_LIMIT = 2000 const SYNC_SECTION_LIMIT = 2000
@@ -59,6 +60,16 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const [lastTest, setLastTest] = useState<string | null>(null) const [lastTest, setLastTest] = useState<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false) const [detailsOpen, setDetailsOpen] = useState(false)
const [detailsText, setDetailsText] = useState<string | null>(null) const [detailsText, setDetailsText] = useState<string | null>(null)
const [draftLibraryDisplayMode, setDraftLibraryDisplayMode] = useState<'grid' | 'table'>(libraryDisplayMode)
const [draftDevOverlayEnabled, setDraftDevOverlayEnabled] = useState(devOverlayEnabled)
const [syncCompletionNotices, setSyncCompletionNotices] = useState<Record<string, string>>({})
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<number | null>(null)
const syncNoticeTimeoutsRef = useRef<Record<string, number>>({})
const currentCacheKey = useMemo(() => buildLibraryCacheKey(servers), [servers])
const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => { const extractNamespaceValue = (tags: string[] | null | undefined, ns: string) => {
if (!tags || !Array.isArray(tags)) return null if (!tags || !Array.isArray(tags)) return null
@@ -80,6 +91,55 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
setDetailsOpen(false) 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 = () => { const startAdd = () => {
setEditing(null) setEditing(null)
setForm(DEFAULT_SERVER_FORM) setForm(DEFAULT_SERVER_FORM)
@@ -140,15 +200,24 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const snapshot = await loadLibraryCache(cacheKey) const snapshot = await loadLibraryCache(cacheKey)
const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) } const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) }
const mergedTrackMap: Record<string, Track> = {} const mergedTrackMap: Record<string, Track> = {}
const previousServerTrackKeys = new Set<string>()
let localCounter = Date.now() let localCounter = Date.now()
for (const track of snapshot?.tracks ?? []) { for (const track of snapshot?.tracks ?? []) {
const hydratedTrack: Track = { ...track, id: ++localCounter } const hydratedTrack: Track = { ...track, id: ++localCounter }
const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId) 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 counts: ServerSyncSummary['counts'] = {}
const currentServerTrackKeys = new Set<string>()
for (const section of SYNC_SECTIONS) { for (const section of SYNC_SECTIONS) {
const searchTags = [section.predicate] const searchTags = [section.predicate]
@@ -164,6 +233,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const tags = tagMap[fileId] || [] const tags = tagMap[fileId] || []
const key = buildTrackCacheKey(server.id, fileId) const key = buildTrackCacheKey(server.id, fileId)
if (!key) continue if (!key) continue
currentServerTrackKeys.add(key)
mergedTrackMap[key] = { mergedTrackMap[key] = {
id: ++localCounter, id: ++localCounter,
@@ -186,15 +256,32 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache) await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache)
const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0) 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 = { const summary: ServerSyncSummary = {
updatedAt: Date.now(), updatedAt: Date.now(),
total, total,
counts, counts,
message: `Synced ${total} cached items`,
} }
updateServer(server.id, { syncSummary: summary }) 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) { } catch (error: any) {
const message = error?.message ?? String(error) const message = error?.message ?? String(error)
updateServer(server.id, { 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 ( return (
<Box sx={{ p: { xs: 1, sm: 2, lg: 3 }, minHeight: '100%', bgcolor: 'background.default' }}> <Box sx={{ p: { xs: 1, sm: 2, lg: 3 }, minHeight: '100%', bgcolor: 'background.default' }}>
<Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}> <Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}>
@@ -223,57 +324,62 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</Box> </Box>
</Box> </Box>
{import.meta.env.DEV && ( <Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography> <Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> <Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
Choose the default Library layout so browsing controls can stay compact. <Typography variant="body2" color="text.secondary">
</Typography> Save Library layout and development UI changes together.
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: 2 }}> </Typography>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel> </Box>
<Select <Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
labelId="settings-library-display-mode-label" Save preferences
value={libraryDisplayMode} </Button>
label="Display"
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Developer tools</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Control development-only UI that can get in the way on smaller screens.
</Typography>
<FormControlLabel
control={<Switch checked={devOverlayEnabled} onChange={(event) => onDevOverlayEnabledChange(event.target.checked)} />}
label={devOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</Box> </Box>
)}
{!import.meta.env.DEV && ( <FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: import.meta.env.DEV ? 2 : 0 }}>
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}> <InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography> <Select
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}> labelId="settings-library-display-mode-label"
Choose the default Library layout so browsing controls can stay compact. value={draftLibraryDisplayMode}
</Typography> label="Display"
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240 }}> onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
<InputLabel id="settings-library-display-mode-label">Display</InputLabel> >
<Select <MenuItem value="grid">Grid</MenuItem>
labelId="settings-library-display-mode-label" <MenuItem value="table">Table</MenuItem>
value={libraryDisplayMode} </Select>
label="Display" </FormControl>
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
> {import.meta.env.DEV && (
<MenuItem value="grid">Grid</MenuItem> <>
<MenuItem value="table">Table</MenuItem> <Typography variant="subtitle1" sx={{ mb: 0.5 }}>Developer tools</Typography>
</Select> <Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
</FormControl> Control development-only UI that can get in the way on smaller screens.
</Typography>
<FormControlLabel
control={<Switch checked={draftDevOverlayEnabled} onChange={(event) => setDraftDevOverlayEnabled(event.target.checked)} />}
label={draftDevOverlayEnabled ? 'Floating dev overlay enabled' : 'Floating dev overlay disabled'}
sx={{ alignItems: 'flex-start', m: 0 }}
/>
</>
)}
</Box>
<Box sx={{ border: '1px solid rgba(255,255,255,0.08)', borderRadius: 2, bgcolor: 'background.paper', p: { xs: 1.5, sm: 2 }, mb: { xs: 2, lg: 3 } }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library cache</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
The app keeps one active IndexedDB snapshot and automatically removes older sync cache snapshots.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip label={`Storage: ${cacheStorageText}`} size="small" color="primary" variant="outlined" />
<Chip label={`Tracks: ${cacheTrackCount}`} size="small" variant="outlined" />
<Chip label={`Searches: ${cacheSearchCount}`} size="small" variant="outlined" />
<Chip label={`Snapshots kept: ${cacheSnapshotCount}`} size="small" variant="outlined" />
</Box> </Box>
)} <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
{cacheUpdatedAt ? `Cache updated: ${new Date(cacheUpdatedAt).toLocaleString()}` : 'Cache has not been written yet.'}
</Typography>
</Box>
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start"> <Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
<Grid item xs={12} lg={5}> <Grid item xs={12} lg={5}>
@@ -298,7 +404,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
Test Test
</Button> </Button>
<Button variant="outlined" size="large" onClick={() => handleSyncServer(s)} startIcon={<CloudDownloadIcon />} disabled={syncingServerId === s.id} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}> <Button variant="outlined" size="large" onClick={() => handleSyncServer(s)} startIcon={<CloudDownloadIcon />} disabled={syncingServerId === s.id} className="touch-target" sx={{ flexGrow: { xs: 1, sm: 0 } }}>
{syncingServerId === s.id ? 'Syncing...' : 'Sync cache'} {syncingServerId === s.id ? 'Syncing...' : typeof s.syncSummary?.total === 'number' ? `Sync cache (${s.syncSummary.total})` : 'Sync cache'}
</Button> </Button>
<IconButton size="large" aria-label="edit" onClick={() => startEdit(s)}> <IconButton size="large" aria-label="edit" onClick={() => startEdit(s)}>
<EditIcon /> <EditIcon />
@@ -309,12 +415,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', width: '100%', mt: 1 }}> <Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', width: '100%', mt: 1 }}>
{s.lastTest && s.lastTest.message && <Chip label={s.lastTest.message} size="small" />} {s.lastTest && s.lastTest.message && <Chip label={s.lastTest.message} size="small" />}
{s.syncSummary?.message && <Chip label={s.syncSummary.message} size="small" color="primary" variant="outlined" />} {typeof s.syncSummary?.total === 'number' && <Chip label={`Cached: ${s.syncSummary.total} items`} size="small" color="primary" variant="outlined" />}
{s.syncSummary?.message && <Chip label={s.syncSummary.message} size="small" color="error" variant="outlined" />}
{s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => ( {s.syncSummary?.counts && Object.entries(s.syncSummary.counts).map(([section, count]) => (
<Chip key={`${s.id}-${section}`} label={`${section}: ${count}`} size="small" variant="outlined" /> <Chip key={`${s.id}-${section}`} label={`${section}: ${count}`} size="small" variant="outlined" />
))} ))}
{s.forceApiKeyInQuery && <Chip label="API key in query" size="small" />} {s.forceApiKeyInQuery && <Chip label="API key in query" size="small" />}
</Box> </Box>
{syncCompletionNotices[s.id] && (
<Alert severity="success" sx={{ width: '100%', mt: 1, py: 0 }}>
{syncCompletionNotices[s.id]}
</Alert>
)}
{s.syncSummary?.updatedAt && ( {s.syncSummary?.updatedAt && (
<Typography variant="caption" color="text.secondary" sx={{ width: '100%', mt: 1 }}> <Typography variant="caption" color="text.secondary" sx={{ width: '100%', mt: 1 }}>
Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()} Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()}