diff --git a/src/App.tsx b/src/App.tsx
index e57282f..e073ab0 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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() {
+
) : null}
+
)
diff --git a/src/appPreferences.ts b/src/appPreferences.ts
index 3bd3554..6993efc 100644
--- a/src/appPreferences.ts
+++ b/src/appPreferences.ts
@@ -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) {
...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}` })
}
}
\ No newline at end of file
diff --git a/src/components/DevErrorPanel.tsx b/src/components/DevErrorPanel.tsx
index 731b54d..e52ac80 100644
--- a/src/components/DevErrorPanel.tsx
+++ b/src/components/DevErrorPanel.tsx
@@ -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(() => {
diff --git a/src/components/DownloadsOverlay.tsx b/src/components/DownloadsOverlay.tsx
index 6ab97bf..3bbe8d8 100644
--- a/src/components/DownloadsOverlay.tsx
+++ b/src/components/DownloadsOverlay.tsx
@@ -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)
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..8c40c09
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
+ Something went wrong
+
+
+ The application encountered an unexpected error. You can try reloading the page to recover.
+
+
+
+
+
+
+ )
+ }
+
+ return this.props.children
+ }
+}
diff --git a/src/downloadStore.ts b/src/downloadStore.ts
index 1d22918..c81e7c4 100644
--- a/src/downloadStore.ts
+++ b/src/downloadStore.ts
@@ -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((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(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest) {
- return new Promise((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 {
if (typeof indexedDB === 'undefined') return []
- const records = await withStore('readonly', (store) => store.getAll())
+ const records = await withStore(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 }
\ No newline at end of file
diff --git a/src/libraryCache.ts b/src/libraryCache.ts
index 2405e96..54e0870 100644
--- a/src/libraryCache.ts
+++ b/src/libraryCache.ts
@@ -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((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(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest) {
- return new Promise((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 {
if (typeof indexedDB === 'undefined') return []
- const records = await withStore('readonly', (store) => store.getAll())
+ const records = await withStore(DB_CONFIG, 'readonly', (store) => store.getAll())
return Array.isArray(records) ? records : []
}
export async function loadLibraryCache(cacheKey: string): Promise {
if (!cacheKey || typeof indexedDB === 'undefined') return null
- const record = await withStore('readonly', (store) => store.get(cacheKey))
+ const record = await withStore(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)
}
\ No newline at end of file
diff --git a/src/librarySync.ts b/src/librarySync.ts
index 1d4a962..00637d4 100644
--- a/src/librarySync.ts
+++ b/src/librarySync.ts
@@ -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}` : ''
}
diff --git a/src/mediaCache.ts b/src/mediaCache.ts
index 261fb3c..211b5c8 100644
--- a/src/mediaCache.ts
+++ b/src/mediaCache.ts
@@ -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())
diff --git a/src/pages/DownloadsPage.tsx b/src/pages/DownloadsPage.tsx
index 5bd8092..2bf44a0 100644
--- a/src/pages/DownloadsPage.tsx
+++ b/src/pages/DownloadsPage.tsx
@@ -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))
diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx
index 03e31a3..66a9cb8 100644
--- a/src/pages/Library.tsx
+++ b/src/pages/Library.tsx
@@ -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 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)
diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx
index 8e6906f..f88bb12 100644
--- a/src/pages/SettingsPage.tsx
+++ b/src/pages/SettingsPage.tsx
@@ -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 & {
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)
diff --git a/src/utils/extractNamespaceValue.ts b/src/utils/extractNamespaceValue.ts
new file mode 100644
index 0000000..ef4550f
--- /dev/null
+++ b/src/utils/extractNamespaceValue.ts
@@ -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
+}
diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts
new file mode 100644
index 0000000..fdce59f
--- /dev/null
+++ b/src/utils/formatBytes.ts
@@ -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]}`
+}
diff --git a/src/utils/indexedDb.ts b/src/utils/indexedDb.ts
new file mode 100644
index 0000000..76ce812
--- /dev/null
+++ b/src/utils/indexedDb.ts
@@ -0,0 +1,45 @@
+type DatabaseConfig = {
+ name: string
+ version: number
+ storeName: string
+ keyPath: string
+}
+
+function openDatabase(config: DatabaseConfig) {
+ return new Promise((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(
+ config: DatabaseConfig,
+ mode: IDBTransactionMode,
+ handler: (store: IDBObjectStore) => IDBRequest,
+) {
+ return new Promise((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)
+ })
+}