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[]>
}
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<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> {
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[]>) {
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)
}

View File

@@ -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<string | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
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) => {
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<string, Track> = {}
const previousServerTrackKeys = new Set<string>()
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<string>()
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 (
<Box sx={{ p: { xs: 1, sm: 2, lg: 3 }, minHeight: '100%', bgcolor: 'background.default' }}>
<Box sx={{ width: '100%', maxWidth: 1280, mx: 'auto' }}>
@@ -223,57 +324,62 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</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 } }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Choose the default Library layout so browsing controls can stay compact.
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 1.5, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Interface preferences</Typography>
<Typography variant="body2" color="text.secondary">
Save Library layout and development UI changes together.
</Typography>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: 2 }}>
</Box>
<Button variant="contained" onClick={handleSavePreferences} disabled={!preferencesDirty}>
Save preferences
</Button>
</Box>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240, mb: import.meta.env.DEV ? 2 : 0 }}>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={libraryDisplayMode}
value={draftLibraryDisplayMode}
label="Display"
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
onChange={(event) => setDraftLibraryDisplayMode(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
{import.meta.env.DEV && (
<>
<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'}
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>
{!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 } }}>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library display</Typography>
<Typography variant="subtitle1" sx={{ mb: 0.5 }}>Library cache</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Choose the default Library layout so browsing controls can stay compact.
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>
<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>
<FormControl size="small" sx={{ minWidth: 180, maxWidth: 240 }}>
<InputLabel id="settings-library-display-mode-label">Display</InputLabel>
<Select
labelId="settings-library-display-mode-label"
value={libraryDisplayMode}
label="Display"
onChange={(event) => onLibraryDisplayModeChange(event.target.value as 'grid' | 'table')}
>
<MenuItem value="grid">Grid</MenuItem>
<MenuItem value="table">Table</MenuItem>
</Select>
</FormControl>
</Box>
)}
<Grid container spacing={{ xs: 2, lg: 3 }} alignItems="flex-start">
<Grid item xs={12} lg={5}>
@@ -298,7 +404,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
Test
</Button>
<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>
<IconButton size="large" aria-label="edit" onClick={() => startEdit(s)}>
<EditIcon />
@@ -309,12 +415,18 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
</Box>
<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.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]) => (
<Chip key={`${s.id}-${section}`} label={`${section}: ${count}`} size="small" variant="outlined" />
))}
{s.forceApiKeyInQuery && <Chip label="API key in query" size="small" />}
</Box>
{syncCompletionNotices[s.id] && (
<Alert severity="success" sx={{ width: '100%', mt: 1, py: 0 }}>
{syncCompletionNotices[s.id]}
</Alert>
)}
{s.syncSummary?.updatedAt && (
<Typography variant="caption" color="text.secondary" sx={{ width: '100%', mt: 1 }}>
Last sync: {new Date(s.syncSummary.updatedAt).toLocaleString()}