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 Sidebar from './components/Sidebar'
|
||||||
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
|
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
|
||||||
import DownloadsPage from './pages/DownloadsPage'
|
import DownloadsPage from './pages/DownloadsPage'
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary'
|
||||||
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
|
||||||
import { ThemeProvider } from '@mui/material/styles'
|
import { ThemeProvider } from '@mui/material/styles'
|
||||||
import { ACTIVE_KEY, STORAGE_KEY, ServersProvider, useServers } from './context/ServersContext'
|
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 { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
|
||||||
import { syncLibraryCache } from './librarySync'
|
import { syncLibraryCache } from './librarySync'
|
||||||
import { createAppTheme } from './themes'
|
import { createAppTheme } from './themes'
|
||||||
|
import { extractNamespaceValue } from './utils/extractNamespaceValue'
|
||||||
import type { MediaSection, Track } from './types'
|
import type { MediaSection, Track } from './types'
|
||||||
|
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
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) {
|
function resolveTrackMetadata(track: Track, details?: HydrusFileDetails | null) {
|
||||||
const tags = details?.tags || track.tags || []
|
const tags = details?.tags || track.tags || []
|
||||||
const derivedTitle = extractTitleFromTags(tags) || undefined
|
const derivedTitle = extractTitleFromTags(tags) || undefined
|
||||||
const derivedArtist = extractNamespaceValue(tags, 'artist')
|
const derivedArtist = extractNamespaceValue(tags, 'artist') ?? undefined
|
||||||
const derivedAlbum = extractNamespaceValue(tags, 'album')
|
const derivedAlbum = extractNamespaceValue(tags, 'album') ?? undefined
|
||||||
|
|
||||||
const title = isTrackTitleUsable(track.title) ? track.title.trim() : derivedTitle
|
const title = isTrackTitleUsable(track.title) ? track.title.trim() : derivedTitle
|
||||||
const artist = (track.artist || '').trim() || derivedArtist
|
const artist = (track.artist || '').trim() || derivedArtist
|
||||||
@@ -116,9 +102,9 @@ function buildHydrusDownloadMetadata(track: Track, details?: HydrusFileDetails |
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: extractTitleFromTags(tags) || undefined,
|
title: extractTitleFromTags(tags) || undefined,
|
||||||
artist: extractNamespaceValue(tags, 'artist'),
|
artist: extractNamespaceValue(tags, 'artist') ?? undefined,
|
||||||
album: extractNamespaceValue(tags, 'album'),
|
album: extractNamespaceValue(tags, 'album') ?? undefined,
|
||||||
trackNumber: extractNamespaceValue(tags, 'track'),
|
trackNumber: extractNamespaceValue(tags, 'track') ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,8 +146,8 @@ function buildExternalPlayerMetadata(track: Track, details?: HydrusFileDetails |
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
title: displayTitle,
|
title: displayTitle,
|
||||||
artist,
|
artist: artist ?? undefined,
|
||||||
album,
|
album: album ?? undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,8 +361,9 @@ function StartupLibraryCacheSync() {
|
|||||||
updateServer(serverId, { syncSummary: summary })
|
updateServer(serverId, { syncSummary: summary })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error: unknown) => {
|
||||||
// background warm-up failures are surfaced through per-server sync summaries/events
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
addDevLog({ kind: 'error', category: 'library-cache', message: `Background cache sync failed: ${message}` })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -469,8 +456,9 @@ function App() {
|
|||||||
return [...prev, ...restoredDownloads.filter((download) => !existingIds.has(download.id))]
|
return [...prev, ...restoredDownloads.filter((download) => !existingIds.has(download.id))]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error: unknown) => {
|
||||||
// If IndexedDB is unavailable or blocked, downloads still work for the current session.
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to list stored downloads: ${message}` })
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -811,8 +799,9 @@ function App() {
|
|||||||
blob,
|
blob,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
// Keep the in-memory download available even if persistence fails.
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
addDevLog({ kind: 'error', category: 'downloads', message: `Failed to persist download: ${message}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDownload(id, {
|
updateDownload(id, {
|
||||||
@@ -857,7 +846,10 @@ function App() {
|
|||||||
const dismissDownload = useCallback((id: string) => {
|
const dismissDownload = useCallback((id: string) => {
|
||||||
revokeDownloadUrl(id)
|
revokeDownloadUrl(id)
|
||||||
setDownloads((prev) => prev.filter((download) => download.id !== 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])
|
}, [revokeDownloadUrl])
|
||||||
|
|
||||||
const clearFinishedDownloads = useCallback(() => {
|
const clearFinishedDownloads = useCallback(() => {
|
||||||
@@ -868,7 +860,10 @@ function App() {
|
|||||||
|
|
||||||
return prev.filter((download) => download.status === 'downloading')
|
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])
|
}, [revokeDownloadUrl])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -876,6 +871,7 @@ function App() {
|
|||||||
<StartupLibraryCacheSync />
|
<StartupLibraryCacheSync />
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
|
<ErrorBoundary>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
||||||
<Header
|
<Header
|
||||||
onOpenSettings={openSettings}
|
onOpenSettings={openSettings}
|
||||||
@@ -952,6 +948,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
|
</ErrorBoundary>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ServersProvider>
|
</ServersProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { MediaSection } from './types'
|
import type { MediaSection } from './types'
|
||||||
import type { AppThemeId } from './themes'
|
import type { AppThemeId } from './themes'
|
||||||
|
import { addDevLog } from './debugLog'
|
||||||
|
|
||||||
export type LibraryPrimaryAction = 'details' | 'stream'
|
export type LibraryPrimaryAction = 'details' | 'stream'
|
||||||
|
|
||||||
@@ -48,7 +49,8 @@ export function saveUiPreferences(preferences: Partial<UiPreferences>) {
|
|||||||
...preferences,
|
...preferences,
|
||||||
}
|
}
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(nextPreferences))
|
||||||
} catch {
|
} catch (error: unknown) {
|
||||||
// ignore persistence failures
|
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 copyLog = async (log: DevLogItem) => {
|
||||||
const payload = formatLogItem(log)
|
const payload = formatLogItem(log)
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(payload)
|
await navigator.clipboard.writeText(payload)
|
||||||
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
|
addDevLog({ kind: 'debug', category: 'dev-log-panel', message: 'Copied log entry', details: { copiedId: log.id } })
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} 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) } })
|
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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import CloseIcon from '@mui/icons-material/Close'
|
|||||||
import DownloadIcon from '@mui/icons-material/Download'
|
import DownloadIcon from '@mui/icons-material/Download'
|
||||||
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
import ClearAllIcon from '@mui/icons-material/ClearAll'
|
||||||
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'
|
import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'
|
||||||
|
import { formatBytes } from '../utils/formatBytes'
|
||||||
|
|
||||||
export type DownloadOverlayItem = {
|
export type DownloadOverlayItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -28,20 +29,6 @@ type DownloadsOverlayProps = {
|
|||||||
onClearFinished: () => void
|
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) {
|
function buildProgressText(download: DownloadOverlayItem) {
|
||||||
const receivedText = formatBytes(download.receivedBytes)
|
const receivedText = formatBytes(download.receivedBytes)
|
||||||
const totalText = formatBytes(download.totalBytes)
|
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 = {
|
type PersistedDownloadRecord = {
|
||||||
id: string
|
id: string
|
||||||
trackKey: string
|
trackKey: string
|
||||||
@@ -10,64 +12,32 @@ type PersistedDownloadRecord = {
|
|||||||
savedAt: number
|
savedAt: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATABASE_NAME = 'api-mediaplayer-downloads'
|
const DB_CONFIG = {
|
||||||
const DATABASE_VERSION = 1
|
name: 'api-mediaplayer-downloads',
|
||||||
const STORE_NAME = 'downloads'
|
version: 1,
|
||||||
|
storeName: 'downloads',
|
||||||
function openDownloadDatabase() {
|
keyPath: 'id',
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listStoredDownloads(): Promise<PersistedDownloadRecord[]> {
|
export async function listStoredDownloads(): Promise<PersistedDownloadRecord[]> {
|
||||||
if (typeof indexedDB === 'undefined') return []
|
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) : []
|
return Array.isArray(records) ? [...records].sort((left, right) => right.savedAt - left.savedAt) : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveStoredDownload(record: PersistedDownloadRecord) {
|
export async function saveStoredDownload(record: PersistedDownloadRecord) {
|
||||||
if (typeof indexedDB === 'undefined') return
|
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) {
|
export async function deleteStoredDownload(id: string) {
|
||||||
if (!id || typeof indexedDB === 'undefined') return
|
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() {
|
export async function clearStoredDownloads() {
|
||||||
if (typeof indexedDB === 'undefined') return
|
if (typeof indexedDB === 'undefined') return
|
||||||
await withStore('readwrite', (store) => store.clear())
|
await withStore(DB_CONFIG, 'readwrite', (store) => store.clear())
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { PersistedDownloadRecord }
|
export type { PersistedDownloadRecord }
|
||||||
+11
-42
@@ -1,4 +1,5 @@
|
|||||||
import type { Track } from './types'
|
import type { Track } from './types'
|
||||||
|
import { withStore } from './utils/indexedDb'
|
||||||
|
|
||||||
type CacheServerDescriptor = {
|
type CacheServerDescriptor = {
|
||||||
id: string
|
id: string
|
||||||
@@ -31,9 +32,12 @@ export type LibraryCacheStats = {
|
|||||||
updatedAt: number | null
|
updatedAt: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATABASE_NAME = 'api-mediaplayer-library-cache'
|
const DB_CONFIG = {
|
||||||
const DATABASE_VERSION = 1
|
name: 'api-mediaplayer-library-cache',
|
||||||
const STORE_NAME = 'snapshots'
|
version: 1,
|
||||||
|
storeName: 'snapshots',
|
||||||
|
keyPath: 'cacheKey',
|
||||||
|
}
|
||||||
const MAX_TRACKS = 5000
|
const MAX_TRACKS = 5000
|
||||||
const MAX_SEARCHES = 250
|
const MAX_SEARCHES = 250
|
||||||
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
|
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('|')
|
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) {
|
function estimateRecordBytes(record: LibraryCacheRecord) {
|
||||||
const payload = JSON.stringify(record)
|
const payload = JSON.stringify(record)
|
||||||
if (!textEncoder) return payload.length
|
if (!textEncoder) return payload.length
|
||||||
@@ -85,14 +54,14 @@ function estimateRecordBytes(record: LibraryCacheRecord) {
|
|||||||
|
|
||||||
async function listLibraryCacheRecords(): Promise<LibraryCacheRecord[]> {
|
async function listLibraryCacheRecords(): Promise<LibraryCacheRecord[]> {
|
||||||
if (typeof indexedDB === 'undefined') return []
|
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 : []
|
return Array.isArray(records) ? records : []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> {
|
export async function loadLibraryCache(cacheKey: string): Promise<LibraryCacheSnapshot | null> {
|
||||||
if (!cacheKey || typeof indexedDB === 'undefined') return 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
|
if (!record) return null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -111,7 +80,7 @@ export async function pruneLibraryCache(activeCacheKey: string) {
|
|||||||
|
|
||||||
if (staleKeys.length === 0) return 0
|
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
|
return staleKeys.length
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +128,6 @@ export async function saveLibraryCache(cacheKey: string, tracks: Track[], search
|
|||||||
searchCache: trimmedSearchCache,
|
searchCache: trimmedSearchCache,
|
||||||
}
|
}
|
||||||
|
|
||||||
await withStore('readwrite', (store) => store.put(record))
|
await withStore(DB_CONFIG, 'readwrite', (store) => store.put(record))
|
||||||
await pruneLibraryCache(cacheKey)
|
await pruneLibraryCache(cacheKey)
|
||||||
}
|
}
|
||||||
+1
-11
@@ -1,5 +1,6 @@
|
|||||||
import { HydrusClient, extractTitleFromTags, type ServerConfig } from './api/hydrusClient'
|
import { HydrusClient, extractTitleFromTags, type ServerConfig } from './api/hydrusClient'
|
||||||
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from './libraryCache'
|
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from './libraryCache'
|
||||||
|
import { extractNamespaceValue } from './utils/extractNamespaceValue'
|
||||||
import type { MediaSection, ServerSyncSummary, Track } from './types'
|
import type { MediaSection, ServerSyncSummary, Track } from './types'
|
||||||
|
|
||||||
const SYNC_SECTION_LIMIT = 2000
|
const SYNC_SECTION_LIMIT = 2000
|
||||||
@@ -33,17 +34,6 @@ function dispatchSyncEvent(detail: SyncEventDetail) {
|
|||||||
window.dispatchEvent(new CustomEvent(LIBRARY_CACHE_SYNC_EVENT, { detail }))
|
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) {
|
function buildTrackCacheKey(serverId?: string, fileId?: number) {
|
||||||
return serverId && fileId != null ? `${serverId}:${fileId}` : ''
|
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 MEDIA_CACHE_NAME = 'api-mediaplayer-media-v1'
|
||||||
const MAX_MEDIA_CACHE_ITEMS = 12
|
const MAX_MEDIA_CACHE_ITEMS = 12
|
||||||
|
|
||||||
@@ -18,7 +20,11 @@ export async function cacheMediaFile(url: string) {
|
|||||||
const existing = await cache.match(url)
|
const existing = await cache.match(url)
|
||||||
if (existing) return true
|
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
|
if (!response || !response.ok || response.status !== 200) return false
|
||||||
|
|
||||||
await cache.put(url, response.clone())
|
await cache.put(url, response.clone())
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react'
|
import React, { useMemo } from 'react'
|
||||||
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
|
||||||
import type { DownloadOverlayItem } from '../components/DownloadsOverlay'
|
import type { DownloadOverlayItem } from '../components/DownloadsOverlay'
|
||||||
|
import { formatBytes } from '../utils/formatBytes'
|
||||||
|
|
||||||
type DownloadsPageProps = {
|
type DownloadsPageProps = {
|
||||||
downloads: DownloadOverlayItem[]
|
downloads: DownloadOverlayItem[]
|
||||||
@@ -10,20 +11,6 @@ type DownloadsPageProps = {
|
|||||||
onClearFinished: () => void
|
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) {
|
function getProgressPercent(download: DownloadOverlayItem) {
|
||||||
if (!download.totalBytes || download.totalBytes <= 0) return null
|
if (!download.totalBytes || download.totalBytes <= 0) return null
|
||||||
return Math.max(0, Math.min(100, (download.receivedBytes / download.totalBytes) * 100))
|
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 { type LibraryPrimaryAction, loadUiPreferences, saveUiPreferences } from '../appPreferences'
|
||||||
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
|
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
|
||||||
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
|
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
|
||||||
|
import { extractNamespaceValue } from '../utils/extractNamespaceValue'
|
||||||
|
import { formatBytes } from '../utils/formatBytes'
|
||||||
import type { MediaSection, Track } from '../types'
|
import type { MediaSection, Track } from '../types'
|
||||||
|
|
||||||
const RESULTS_PAGE_SIZE = 36
|
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) {
|
function deriveTrackPrimaryValue(track: Track, section: MediaSection) {
|
||||||
if (section === 'video') return extractNamespaceValue(track.tags, 'series') || track.artist || null
|
if (section === 'video') return extractNamespaceValue(track.tags, 'series') || track.artist || null
|
||||||
return 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'
|
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) {
|
function formatDuration(value?: number) {
|
||||||
if (!value || value <= 0) return null
|
if (!value || value <= 0) return null
|
||||||
const totalSeconds = Math.round(value >= 1000 ? value / 1000 : value)
|
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 { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
|
||||||
import { syncLibraryCache } from '../librarySync'
|
import { syncLibraryCache } from '../librarySync'
|
||||||
import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
|
import { APP_THEME_PRESETS, type AppThemeId } from '../themes'
|
||||||
|
import { formatBytes } from '../utils/formatBytes'
|
||||||
type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
|
type ServerForm = Omit<Server, 'id' | 'lastTest' | 'host' | 'port'> & {
|
||||||
endpoint: string
|
endpoint: string
|
||||||
}
|
}
|
||||||
@@ -181,20 +182,6 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
|
|
||||||
const preferencesDirty = draftAppTheme !== appTheme || draftLibraryDisplayMode !== libraryDisplayMode || draftLibraryPrimaryAction !== libraryPrimaryAction || draftDevOverlayEnabled !== devOverlayEnabled
|
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) => {
|
const refreshCacheStats = async (cacheKey: string) => {
|
||||||
if (!cacheKey) {
|
if (!cacheKey) {
|
||||||
setCacheStorageText('0 B')
|
setCacheStorageText('0 B')
|
||||||
@@ -207,7 +194,7 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
|
|||||||
|
|
||||||
await pruneLibraryCache(cacheKey)
|
await pruneLibraryCache(cacheKey)
|
||||||
const stats = await getLibraryCacheStats(cacheKey)
|
const stats = await getLibraryCacheStats(cacheKey)
|
||||||
setCacheStorageText(formatBytes(stats.activeBytes))
|
setCacheStorageText(formatBytes(stats.activeBytes) || '0 B')
|
||||||
setCacheTrackCount(stats.trackCount)
|
setCacheTrackCount(stats.trackCount)
|
||||||
setCacheSearchCount(stats.searchEntryCount)
|
setCacheSearchCount(stats.searchEntryCount)
|
||||||
setCacheSnapshotCount(stats.snapshotCount)
|
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