update error handling and add error boundary component

This commit is contained in:
2026-07-15 18:25:40 -07:00
parent f2ce3e82fa
commit bd9a6ccffe
15 changed files with 208 additions and 221 deletions
+28 -31
View File
@@ -7,6 +7,7 @@ import Header from './components/Header'
import Sidebar from './components/Sidebar'
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
import DownloadsPage from './pages/DownloadsPage'
import ErrorBoundary from './components/ErrorBoundary'
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
import { ThemeProvider } from '@mui/material/styles'
import { ACTIVE_KEY, STORAGE_KEY, ServersProvider, useServers } from './context/ServersContext'
@@ -15,6 +16,7 @@ import { addDevLog } from './debugLog'
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
import { syncLibraryCache } from './librarySync'
import { createAppTheme } from './themes'
import { extractNamespaceValue } from './utils/extractNamespaceValue'
import type { MediaSection, Track } from './types'
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
@@ -82,27 +84,11 @@ function isTrackTitleUsable(title?: string) {
}
}
function extractNamespaceValue(tags: string[] | null | undefined, namespace: string) {
if (!tags || !Array.isArray(tags)) return undefined
const prefix = `${namespace.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
}
return bestValue || undefined
}
function resolveTrackMetadata(track: Track, details?: HydrusFileDetails | null) {
const tags = details?.tags || track.tags || []
const derivedTitle = extractTitleFromTags(tags) || undefined
const derivedArtist = extractNamespaceValue(tags, 'artist')
const derivedAlbum = extractNamespaceValue(tags, 'album')
const derivedArtist = extractNamespaceValue(tags, 'artist') ?? undefined
const derivedAlbum = extractNamespaceValue(tags, 'album') ?? undefined
const title = isTrackTitleUsable(track.title) ? track.title.trim() : derivedTitle
const artist = (track.artist || '').trim() || derivedArtist
@@ -116,9 +102,9 @@ function buildHydrusDownloadMetadata(track: Track, details?: HydrusFileDetails |
return {
title: extractTitleFromTags(tags) || undefined,
artist: extractNamespaceValue(tags, 'artist'),
album: extractNamespaceValue(tags, 'album'),
trackNumber: extractNamespaceValue(tags, 'track'),
artist: extractNamespaceValue(tags, 'artist') ?? undefined,
album: extractNamespaceValue(tags, 'album') ?? undefined,
trackNumber: extractNamespaceValue(tags, 'track') ?? undefined,
}
}
@@ -160,8 +146,8 @@ function buildExternalPlayerMetadata(track: Track, details?: HydrusFileDetails |
return {
title: displayTitle,
artist,
album,
artist: artist ?? undefined,
album: album ?? undefined,
}
}
@@ -375,8 +361,9 @@ function StartupLibraryCacheSync() {
updateServer(serverId, { syncSummary: summary })
}
})
.catch(() => {
// background warm-up failures are surfaced through per-server sync summaries/events
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'library-cache', message: `Background cache sync failed: ${message}` })
})
return () => {
@@ -469,8 +456,9 @@ function App() {
return [...prev, ...restoredDownloads.filter((download) => !existingIds.has(download.id))]
})
})
.catch(() => {
// If IndexedDB is unavailable or blocked, downloads still work for the current session.
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to list stored downloads: ${message}` })
})
return () => {
@@ -811,8 +799,9 @@ function App() {
blob,
savedAt: Date.now(),
})
} catch {
// Keep the in-memory download available even if persistence fails.
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to persist download: ${message}` })
}
updateDownload(id, {
@@ -857,7 +846,10 @@ function App() {
const dismissDownload = useCallback((id: string) => {
revokeDownloadUrl(id)
setDownloads((prev) => prev.filter((download) => download.id !== id))
void deleteStoredDownload(id).catch(() => {})
void deleteStoredDownload(id).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to delete stored download: ${message}` })
})
}, [revokeDownloadUrl])
const clearFinishedDownloads = useCallback(() => {
@@ -868,7 +860,10 @@ function App() {
return prev.filter((download) => download.status === 'downloading')
})
void clearStoredDownloads().catch(() => {})
void clearStoredDownloads().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to clear stored downloads: ${message}` })
})
}, [revokeDownloadUrl])
return (
@@ -876,6 +871,7 @@ function App() {
<StartupLibraryCacheSync />
<ThemeProvider theme={theme}>
<CssBaseline />
<ErrorBoundary>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<Header
onOpenSettings={openSettings}
@@ -952,6 +948,7 @@ function App() {
/>
) : null}
</Box>
</ErrorBoundary>
</ThemeProvider>
</ServersProvider>
)
+4 -2
View File
@@ -1,5 +1,6 @@
import type { MediaSection } from './types'
import type { AppThemeId } from './themes'
import { addDevLog } from './debugLog'
export type LibraryPrimaryAction = 'details' | 'stream'
@@ -48,7 +49,8 @@ export function saveUiPreferences(preferences: Partial<UiPreferences>) {
...preferences,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences))
} catch {
// ignore persistence failures
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'error', category: 'preferences', message: `Failed to save UI preferences: ${message}` })
}
}
+2 -20
View File
@@ -25,29 +25,11 @@ export default function DevErrorPanel() {
const copyLog = async (log: DevLogItem) => {
const payload = formatLogItem(log)
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(payload)
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
return
}
await navigator.clipboard.writeText(payload)
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
} catch (error: any) {
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
}
try {
const textarea = document.createElement('textarea')
textarea.value = payload
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.left = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
textarea.remove()
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id, fallback: true } })
} catch (error: any) {
addDevLog({ kind: 'error', category: 'dev-log-panel', message: 'Clipboard fallback copy failed', details: { copiedId: log.id, name: error?.name, message: error?.message ?? String(error) } })
}
}
useEffect(() => {
+1 -14
View File
@@ -6,6 +6,7 @@ import CloseIcon from '@mui/icons-material/Close'
import DownloadIcon from '@mui/icons-material/Download'
import ClearAllIcon from '@mui/icons-material/ClearAll'
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'
import { formatBytes } from '../utils/formatBytes'
export type DownloadOverlayItem = {
id: string
@@ -28,20 +29,6 @@ type DownloadsOverlayProps = {
onClearFinished: () => void
}
function formatBytes(value?: number | null) {
if (!value || value <= 0) return null
const units = ['B', 'KB', 'MB', 'GB', 'TB']
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]}`
}
function buildProgressText(download: DownloadOverlayItem) {
const receivedText = formatBytes(download.receivedBytes)
const totalText = formatBytes(download.totalBytes)
+65
View File
@@ -0,0 +1,65 @@
import React from 'react'
import { Box, Button, Typography } from '@mui/material'
import { addDevLog } from '../debugLog'
type Props = {
children: React.ReactNode
}
type State = {
hasError: boolean
}
export default class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError() {
return { hasError: true }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
addDevLog({
kind: 'error',
category: 'error-boundary',
message: error.message || 'Unknown render error',
stack: info.componentStack || error.stack,
})
}
handleReload = () => {
window.location.reload()
}
handleReset = () => {
this.setState({ hasError: false })
window.location.reload()
}
render() {
if (this.state.hasError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', gap: 2, p: 3, textAlign: 'center' }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
Something went wrong
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ maxWidth: 400 }}>
The application encountered an unexpected error. You can try reloading the page to recover.
</Typography>
<Box sx={{ display: 'flex', gap: 1, mt: 1 }}>
<Button variant="contained" onClick={this.handleReset}>
Reload
</Button>
<Button variant="outlined" onClick={() => window.location.reload()}>
Hard Reload
</Button>
</Box>
</Box>
)
}
return this.props.children
}
}
+11 -41
View File
@@ -1,3 +1,5 @@
import { withStore } from './utils/indexedDb'
type PersistedDownloadRecord = {
id: string
trackKey: string
@@ -10,64 +12,32 @@ type PersistedDownloadRecord = {
savedAt: number
}
const DATABASE_NAME = 'api-mediaplayer-downloads'
const DATABASE_VERSION = 1
const STORE_NAME = 'downloads'
function openDownloadDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open download database'))
})
}
function withStore<T>(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
openDownloadDatabase()
.then((database) => {
const transaction = database.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
const request = handler(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
transaction.oncomplete = () => database.close()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
.catch(reject)
})
const DB_CONFIG = {
name: 'api-mediaplayer-downloads',
version: 1,
storeName: 'downloads',
keyPath: 'id',
}
export async function listStoredDownloads(): Promise<PersistedDownloadRecord[]> {
if (typeof indexedDB === 'undefined') return []
const records = await withStore<PersistedDownloadRecord[]>('readonly', (store) => store.getAll())
const records = await withStore<PersistedDownloadRecord[]>(DB_CONFIG, 'readonly', (store) => store.getAll())
return Array.isArray(records) ? [...records].sort((left, right) => right.savedAt - left.savedAt) : []
}
export async function saveStoredDownload(record: PersistedDownloadRecord) {
if (typeof indexedDB === 'undefined') return
await withStore('readwrite', (store) => store.put(record))
await withStore(DB_CONFIG, 'readwrite', (store) => store.put(record))
}
export async function deleteStoredDownload(id: string) {
if (!id || typeof indexedDB === 'undefined') return
await withStore('readwrite', (store) => store.delete(id))
await withStore(DB_CONFIG, 'readwrite', (store) => store.delete(id))
}
export async function clearStoredDownloads() {
if (typeof indexedDB === 'undefined') return
await withStore('readwrite', (store) => store.clear())
await withStore(DB_CONFIG, 'readwrite', (store) => store.clear())
}
export type { PersistedDownloadRecord }
+11 -42
View File
@@ -1,4 +1,5 @@
import type { Track } from './types'
import { withStore } from './utils/indexedDb'
type CacheServerDescriptor = {
id: string
@@ -31,9 +32,12 @@ export type LibraryCacheStats = {
updatedAt: number | null
}
const DATABASE_NAME = 'api-mediaplayer-library-cache'
const DATABASE_VERSION = 1
const STORE_NAME = 'snapshots'
const DB_CONFIG = {
name: 'api-mediaplayer-library-cache',
version: 1,
storeName: 'snapshots',
keyPath: 'cacheKey',
}
const MAX_TRACKS = 5000
const MAX_SEARCHES = 250
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
@@ -42,41 +46,6 @@ export function buildLibraryCacheKey(servers: CacheServerDescriptor[]) {
return servers.map((server) => `${server.id}:${server.host}:${server.port ?? ''}:${server.ssl ? 'https' : 'http'}`).join('|')
}
function openLibraryCacheDatabase() {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(STORE_NAME)) {
database.createObjectStore(STORE_NAME, { keyPath: 'cacheKey' })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('Failed to open library cache database'))
})
}
function withStore<T>(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
openLibraryCacheDatabase()
.then((database) => {
const transaction = database.transaction(STORE_NAME, mode)
const store = transaction.objectStore(STORE_NAME)
const request = handler(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
transaction.oncomplete = () => database.close()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
.catch(reject)
})
}
function estimateRecordBytes(record: LibraryCacheRecord) {
const payload = JSON.stringify(record)
if (!textEncoder) return payload.length
@@ -85,14 +54,14 @@ function estimateRecordBytes(record: LibraryCacheRecord) {
async function listLibraryCacheRecords(): Promise<LibraryCacheRecord[]> {
if (typeof indexedDB === 'undefined') return []
const records = await withStore<LibraryCacheRecord[]>('readonly', (store) => store.getAll())
const records = await withStore<LibraryCacheRecord[]>(DB_CONFIG, '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
const record = await withStore<LibraryCacheRecord | undefined>('readonly', (store) => store.get(cacheKey))
const record = await withStore<LibraryCacheRecord | undefined>(DB_CONFIG, 'readonly', (store) => store.get(cacheKey))
if (!record) return null
return {
@@ -111,7 +80,7 @@ export async function pruneLibraryCache(activeCacheKey: string) {
if (staleKeys.length === 0) return 0
await Promise.all(staleKeys.map((cacheKey) => withStore('readwrite', (store) => store.delete(cacheKey))))
await Promise.all(staleKeys.map((cacheKey) => withStore(DB_CONFIG, 'readwrite', (store) => store.delete(cacheKey))))
return staleKeys.length
}
@@ -159,6 +128,6 @@ export async function saveLibraryCache(cacheKey: string, tracks: Track[], search
searchCache: trimmedSearchCache,
}
await withStore('readwrite', (store) => store.put(record))
await withStore(DB_CONFIG, 'readwrite', (store) => store.put(record))
await pruneLibraryCache(cacheKey)
}
+1 -11
View File
@@ -1,5 +1,6 @@
import { HydrusClient, extractTitleFromTags, type ServerConfig } from './api/hydrusClient'
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from './libraryCache'
import { extractNamespaceValue } from './utils/extractNamespaceValue'
import type { MediaSection, ServerSyncSummary, Track } from './types'
const SYNC_SECTION_LIMIT = 2000
@@ -33,17 +34,6 @@ function dispatchSyncEvent(detail: SyncEventDetail) {
window.dispatchEvent(new CustomEvent(LIBRARY_CACHE_SYNC_EVENT, { detail }))
}
function extractNamespaceValue(tags: string[] | null | undefined, ns: string) {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${ns.toLowerCase()}:`
const values = tags
.filter((tag) => typeof tag === 'string' && tag.toLowerCase().startsWith(prefix))
.map((tag) => tag.slice(prefix.length).replace(/_/g, ' ').trim())
.filter(Boolean)
return values.sort((left, right) => right.length - left.length)[0] || null
}
function buildTrackCacheKey(serverId?: string, fileId?: number) {
return serverId && fileId != null ? `${serverId}:${fileId}` : ''
}
+7 -1
View File
@@ -1,3 +1,5 @@
import { addDevLog } from './debugLog'
const MEDIA_CACHE_NAME = 'api-mediaplayer-media-v1'
const MAX_MEDIA_CACHE_ITEMS = 12
@@ -18,7 +20,11 @@ export async function cacheMediaFile(url: string) {
const existing = await cache.match(url)
if (existing) return true
const response = await fetch(url, { method: 'GET', mode: 'cors' }).catch(() => null)
const response = await fetch(url, { method: 'GET', mode: 'cors' }).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
addDevLog({ kind: 'debug', category: 'media-cache', message: `Fetch failed for cache: ${message}` })
return null
})
if (!response || !response.ok || response.status !== 200) return false
await cache.put(url, response.clone())
+1 -14
View File
@@ -1,6 +1,7 @@
import React, { useMemo } from 'react'
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import type { DownloadOverlayItem } from '../components/DownloadsOverlay'
import { formatBytes } from '../utils/formatBytes'
type DownloadsPageProps = {
downloads: DownloadOverlayItem[]
@@ -10,20 +11,6 @@ type DownloadsPageProps = {
onClearFinished: () => void
}
function formatBytes(value?: number | null) {
if (!value || value <= 0) return null
const units = ['B', 'KB', 'MB', 'GB', 'TB']
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]}`
}
function getProgressPercent(download: DownloadOverlayItem) {
if (!download.totalBytes || download.totalBytes <= 0) return null
return Math.max(0, Math.min(100, (download.receivedBytes / download.totalBytes) * 100))
+2 -30
View File
@@ -7,6 +7,8 @@ import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../a
import { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from '../appPreferences'
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
import { extractNamespaceValue } from '../utils/extractNamespaceValue'
import { formatBytes } from '../utils/formatBytes'
import type { MediaSection, Track } from '../types'
const RESULTS_PAGE_SIZE = 36
@@ -181,22 +183,6 @@ function getTrackNamespacePresentation(section: MediaSection): TrackNamespacePre
}
}
function extractNamespaceValue(tags: string[] | null | undefined, namespace: string): string | null {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${namespace.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
}
return bestValue || null
}
function deriveTrackPrimaryValue(track: Track, section: MediaSection) {
if (section === 'video') return extractNamespaceValue(track.tags, 'series') || track.artist || null
return track.artist || null
@@ -297,20 +283,6 @@ function getStoredSectionView(section: MediaSection, views: Partial<Record<Media
return SECTION_CONFIG[section].views.some((item) => item.id === candidate) ? candidate as LibraryView : 'tracks'
}
function formatBytes(value?: number) {
if (!value || value <= 0) return null
const units = ['B', 'KB', 'MB', 'GB', 'TB']
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]}`
}
function formatDuration(value?: number) {
if (!value || value <= 0) return null
const totalSeconds = Math.round(value >= 1000 ? value / 1000 : value)
+2 -15
View File
@@ -34,6 +34,7 @@ import { useServers } from '../context/ServersContext'
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync'
import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
import { formatBytes } from '../utils/formatBytes'
type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
endpoint: string
}
@@ -181,20 +182,6 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const preferencesDirty = draftAppTheme !== appTheme || draftLibraryDisplayMode !== libraryDisplayMode || draftLibraryPrimaryAction !== libraryPrimaryAction || 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')
@@ -207,7 +194,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
await pruneLibraryCache(cacheKey)
const stats = await getLibraryCacheStats(cacheKey)
setCacheStorageText(formatBytes(stats.activeBytes))
setCacheStorageText(formatBytes(stats.activeBytes) || '0 B')
setCacheTrackCount(stats.trackCount)
setCacheSearchCount(stats.searchEntryCount)
setCacheSnapshotCount(stats.snapshotCount)
+15
View File
@@ -0,0 +1,15 @@
export function extractNamespaceValue(tags: string[] | null | undefined, namespace: string): string | null {
if (!tags || !Array.isArray(tags)) return null
const prefix = `${namespace.toLowerCase()}:`
let bestValue = ''
for (const tag of tags) {
if (typeof tag !== 'string') continue
if (!tag.toLowerCase().startsWith(prefix)) continue
const value = tag.slice(prefix.length).replace(/_/g, ' ').trim()
if (value.length > bestValue.length) bestValue = value
}
return bestValue || null
}
+13
View File
@@ -0,0 +1,13 @@
export function formatBytes(value?: number | null): string | null {
if (!value || value <= 0) return null
const units = ['B', 'KB', 'MB', 'GB', 'TB']
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]}`
}
+45
View File
@@ -0,0 +1,45 @@
type DatabaseConfig = {
name: string
version: number
storeName: string
keyPath: string
}
function openDatabase(config: DatabaseConfig) {
return new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(config.name, config.version)
request.onupgradeneeded = () => {
const database = request.result
if (!database.objectStoreNames.contains(config.storeName)) {
database.createObjectStore(config.storeName, { keyPath: config.keyPath })
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error(`Failed to open database: ${config.name}`))
})
}
export function withStore<T>(
config: DatabaseConfig,
mode: IDBTransactionMode,
handler: (store: IDBObjectStore) => IDBRequest<T>,
) {
return new Promise<T>((resolve, reject) => {
openDatabase(config)
.then((database) => {
const transaction = database.transaction(config.storeName, mode)
const store = transaction.objectStore(config.storeName)
const request = handler(store)
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed'))
transaction.oncomplete = () => database.close()
transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed'))
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted'))
})
.catch(reject)
})
}