fixed caching
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user