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) + }) +}