update error handling and add error boundary component
This commit is contained in:
+28
-31
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}` })
|
||||
}
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user