update dependencies and add downloads page, various fixes

This commit is contained in:
2026-04-14 23:14:41 -07:00
parent 81d42662ee
commit e83648b82b
14 changed files with 1590 additions and 517 deletions
+49
View File
@@ -10,8 +10,12 @@
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"browser-id3-writer": "^6.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -848,6 +852,45 @@
"node": ">=12"
}
},
"node_modules/@ffmpeg/core": {
"version": "0.12.10",
"resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.10.tgz",
"integrity": "sha512-dzNplnn2Nxle2c2i2rrDhqcB19q9cglCkWnoMTDN9Q9l3PvdjZWd1HfSPjCNWc/p8Q3CT+Es9fWOR0UhAeYQZA==",
"license": "GPL-2.0-or-later",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/ffmpeg": {
"version": "0.12.15",
"resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz",
"integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==",
"license": "MIT",
"dependencies": {
"@ffmpeg/types": "^0.12.4"
},
"engines": {
"node": ">=18.x"
}
},
"node_modules/@ffmpeg/types": {
"version": "0.12.4",
"resolved": "https://registry.npmjs.org/@ffmpeg/types/-/types-0.12.4.tgz",
"integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==",
"license": "MIT",
"engines": {
"node": ">=16.x"
}
},
"node_modules/@ffmpeg/util": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/@ffmpeg/util/-/util-0.12.2.tgz",
"integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==",
"license": "MIT",
"engines": {
"node": ">=18.x"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1625,6 +1668,12 @@
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/browser-id3-writer": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/browser-id3-writer/-/browser-id3-writer-6.3.1.tgz",
"integrity": "sha512-sRA4Uq9Q3NsmXiVpLvIDxzomtgCdbw6SY85A6fw7dUQGRVoOBg1/buFv6spPhYiSo6FlVtN5OJQTvvhbmfx9rQ==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+4
View File
@@ -12,8 +12,12 @@
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@ffmpeg/core": "^0.12.10",
"@ffmpeg/ffmpeg": "^0.12.15",
"@ffmpeg/util": "^0.12.2",
"@mui/icons-material": "^5.14.0",
"@mui/material": "^5.14.0",
"browser-id3-writer": "^6.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -69,31 +69,72 @@
}
}
function buildDesktopMpvUrl(url, title) {
function buildIntentStringExtra(key, value) {
const trimmed = (value || '').trim()
if (!trimmed) return ''
return `;S.${key}=${encodeURIComponent(trimmed)}`
}
function isUsableTitle(title, url) {
const trimmed = (title || '').trim()
if (!trimmed) return false
if (trimmed === url) return false
if (/^https?:\/\//i.test(trimmed)) return false
return true
}
function extractFallbackTitle(url) {
try {
const parsed = new URL(url, location.href)
const hydrusFileId = parsed.searchParams.get('file_id')
if (hydrusFileId) return `Hydrus File ${hydrusFileId}`
const segments = parsed.pathname.split('/').filter(Boolean)
const lastSegment = segments[segments.length - 1]
return lastSegment ? decodeURIComponent(lastSegment) : 'Media'
} catch {
return 'Media'
}
}
function buildMediaMetadata(url, element) {
const candidates = [
element?.getAttribute?.('data-title') || '',
element?.getAttribute?.('title') || '',
element?.getAttribute?.('aria-label') || '',
element?.textContent || '',
document.title || '',
]
const title = candidates.find((candidate) => isUsableTitle(candidate, url)) || extractFallbackTitle(url)
return { title }
}
function buildDesktopMpvUrl(url, metadata) {
const encodedUrl = encodeUrlSafeBase64(url)
const encodedTitle = title ? encodeUrlSafeBase64(title) : ''
const encodedTitle = metadata.title ? encodeUrlSafeBase64(metadata.title) : ''
const query = encodedTitle ? `?v_title=${encodedTitle}` : ''
return `mpv-handler://play/${encodedUrl}/${query}`
}
function buildAndroidMpvUrl(url, element) {
function buildAndroidMpvUrl(url, element, metadata) {
try {
const parsed = new URL(url, location.href)
const scheme = parsed.protocol.replace(':', '') || 'https'
const intentPath = `${parsed.host}${parsed.pathname}${parsed.search}`
const mimeType = looksLikeVideo(url, element) ? 'video/*' : 'audio/*'
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end`
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType}${buildIntentStringExtra('title', metadata.title)};end`
} catch {
return url
}
}
function buildExternalPlayerUrl(url, title, element) {
function buildExternalPlayerUrl(url, metadata, element) {
if (/Android/i.test(navigator.userAgent || '')) {
return buildAndroidMpvUrl(url, element)
return buildAndroidMpvUrl(url, element, metadata)
}
return buildDesktopMpvUrl(url, title)
return buildDesktopMpvUrl(url, metadata)
}
function openInExternalPlayer(url, element) {
@@ -105,8 +146,8 @@
lastOpenedUrl = url
lastOpenedAt = now
const title = document.title || ''
const targetUrl = buildExternalPlayerUrl(url, title, element)
const metadata = buildMediaMetadata(url, element)
const targetUrl = buildExternalPlayerUrl(url, metadata, element)
location.assign(targetUrl)
}
+537 -9
View File
@@ -1,12 +1,19 @@
import React, { Suspense, lazy, useState, useMemo, useCallback, useEffect, useRef } from 'react'
import { ID3Writer } from 'browser-id3-writer'
import { embedContainerAudioMetadata, supportsContainerMetadataEmbedding } from './audioMetadata'
import Library from './pages/Library'
import Header from './components/Header'
import Sidebar from './components/Sidebar'
import DownloadsOverlay, { type DownloadOverlayItem } from './components/DownloadsOverlay'
import DownloadsPage from './pages/DownloadsPage'
import { Box, CssBaseline, useMediaQuery } from '@mui/material'
import { ThemeProvider, createTheme } from '@mui/material/styles'
import { ServersProvider } from './context/ServersContext'
import { ServersProvider, useServers } from './context/ServersContext'
import { extractTitleFromTags, type HydrusFileDetails } from './api/hydrusClient'
import { addDevLog } from './debugLog'
import { clearStoredDownloads, deleteStoredDownload, listStoredDownloads, saveStoredDownload } from './downloadStore'
import { loadUiPreferences, saveUiPreferences } from './appPreferences'
import { syncLibraryCache } from './librarySync'
import type { MediaSection, Track } from './types'
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
@@ -25,6 +32,19 @@ type ExternalPlayerTarget = {
appName: string
}
type ExternalPlayerMetadata = {
title: string
artist?: string
album?: string
}
type DownloadMetadata = {
title?: string
artist?: string
album?: string
trackNumber?: string
}
function isPlayableMediaTrack(track: Track) {
const mimeType = normalizeMimeForPlaybackCheck(track.mimeType)
if (track.isVideo) return true
@@ -48,15 +68,119 @@ function encodeUrlSafeBase64(value: string) {
return btoa(binary).replace(/\//g, '_').replace(/\+/g, '-').replace(/=+$/g, '')
}
function isTrackTitleUsable(title?: string) {
const trimmed = (title || '').trim()
if (!trimmed) return false
if (/^(?:file|hydrus(?:[\s-]+file)?)\s*[-:#]*\s*\d+$/i.test(trimmed)) return false
try {
const parsed = new URL(trimmed)
return !/^https?:$/i.test(parsed.protocol)
} catch {
return true
}
}
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 title = isTrackTitleUsable(track.title) ? track.title.trim() : derivedTitle
const artist = (track.artist || '').trim() || derivedArtist
const album = (track.album || '').trim() || derivedAlbum
return { title, artist, album }
}
function buildHydrusDownloadMetadata(track: Track, details?: HydrusFileDetails | null): DownloadMetadata {
const tags = details?.tags || track.tags || []
return {
title: extractTitleFromTags(tags) || undefined,
artist: extractNamespaceValue(tags, 'artist'),
album: extractNamespaceValue(tags, 'album'),
trackNumber: extractNamespaceValue(tags, 'track'),
}
}
function buildHydrusDownloadDisplayTitle(track: Track, details?: HydrusFileDetails | null) {
const metadata = buildHydrusDownloadMetadata(track, details)
if (metadata.artist && metadata.title) return `${metadata.artist} - ${metadata.title}`
return metadata.title || metadata.artist || metadata.album || extractFallbackMediaLabel(track.url, track.fileId)
}
function extractFallbackMediaLabel(url: string, fileId?: number) {
if (fileId != null) return `Hydrus File ${fileId}`
try {
const parsed = new URL(url)
const hydrusFileId = parsed.searchParams.get('file_id')
if (hydrusFileId) return `Hydrus File ${hydrusFileId}`
const segments = parsed.pathname.split('/').filter(Boolean)
const lastSegment = segments[segments.length - 1]
if (!lastSegment) return 'Media'
return decodeURIComponent(lastSegment)
} catch {
return 'Media'
}
}
function buildExternalPlayerMetadata(track: Track, details?: HydrusFileDetails | null): ExternalPlayerMetadata {
const resolvedMetadata = resolveTrackMetadata(track, details)
const artist = resolvedMetadata.artist
const album = resolvedMetadata.album
const title = resolvedMetadata.title || ''
const displayTitle = artist && title
? `${artist} - ${title}`
: title
? title
: artist && album
? `${artist} - ${album}`
: artist || album || extractFallbackMediaLabel(track.url, track.fileId)
return {
title: displayTitle,
artist,
album,
}
}
function buildIntentStringExtra(key: string, value?: string) {
const trimmed = (value || '').trim()
if (!trimmed) return ''
return `;S.${key}=${encodeURIComponent(trimmed)}`
}
function buildVlcStreamUrl(track: Track) {
const metadata = buildExternalPlayerMetadata(track)
const url = `vlc-x-callback://x-callback-url/stream?url=${encodeURIComponent(track.url)}`
const fileName = (track.title || '').trim()
const fileName = metadata.title
if (!fileName) return url
return `${url}&filename=${encodeURIComponent(fileName)}`
}
function buildAndroidMpvIntentUrl(track: Track) {
try {
const metadata = buildExternalPlayerMetadata(track)
const url = new URL(track.url)
const scheme = url.protocol.replace(':', '') || 'https'
const intentPath = `${url.host}${url.pathname}${url.search}`
@@ -66,22 +190,181 @@ function buildAndroidMpvIntentUrl(track: Track) {
? 'audio/*'
: 'video/any'
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType};end`
return `intent://${intentPath}#Intent;scheme=${scheme};package=is.xyz.mpv;action=android.intent.action.VIEW;type=${mimeType}${buildIntentStringExtra('title', metadata.title)}${buildIntentStringExtra('artist', metadata.artist)}${buildIntentStringExtra('album', metadata.album)};end`
} catch {
return track.url
}
}
function buildDesktopMpvHandlerUrl(track: Track) {
const metadata = buildExternalPlayerMetadata(track)
const encodedUrl = encodeUrlSafeBase64(track.url)
const title = (track.title || '').trim()
const query = title ? `?v_title=${encodeUrlSafeBase64(title)}` : ''
const query = metadata.title ? `?v_title=${encodeUrlSafeBase64(metadata.title)}` : ''
return `mpv-handler://play/${encodedUrl}/${query}`
}
function createDownloadId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
}
function buildTrackDownloadKey(track: Track) {
if (track.serverId && track.fileId != null) return `${track.serverId}:${track.fileId}`
if (track.fileId != null) return `file:${track.fileId}`
return `url:${track.url}`
}
function sanitizeFileNameSegment(value: string) {
return value
.replace(/[<>:"/\\|?*\x00-\x1f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function parseContentDispositionFilename(headerValue: string | null | undefined) {
if (!headerValue) return null
const patterns = [
/filename\*=UTF-8''([^;]+)/i,
/filename="([^"]+)"/i,
/filename=([^;\s]+)/i,
]
for (const pattern of patterns) {
const match = headerValue.match(pattern)
if (!match?.[1]) continue
try {
return decodeURIComponent(match[1]).trim()
} catch {
return match[1].trim()
}
}
return null
}
function getTrackExtension(track: Track, details?: HydrusFileDetails | null) {
if (details?.extension) return details.extension
const match = track.url.match(/\.([a-z0-9]{1,10})(?:[?#]|$)/i)
return match ? match[1].toLowerCase() : undefined
}
function buildTrackDownloadName(track: Track, details?: HydrusFileDetails | null, contentDisposition?: string | null) {
const suggestedName = sanitizeFileNameSegment(parseContentDispositionFilename(contentDisposition) || '')
if (suggestedName) return suggestedName
const resolvedMetadata = resolveTrackMetadata(track, details)
const extension = sanitizeFileNameSegment(getTrackExtension(track, details) || '')
const title = sanitizeFileNameSegment(resolvedMetadata.title || '')
const artist = sanitizeFileNameSegment(resolvedMetadata.artist || '')
const album = sanitizeFileNameSegment(resolvedMetadata.album || '')
const nameBase = artist && title
? `${artist} - ${title}`
: title || album || (track.fileId != null ? `hydrus-file-${track.fileId}` : 'media')
return extension ? `${nameBase}.${extension}` : nameBase
}
function isMp3Download(track: Track, details?: HydrusFileDetails | null, contentType?: string | null) {
const normalizedType = (contentType || track.mimeType || '').toLowerCase()
if (normalizedType.includes('audio/mpeg') || normalizedType.includes('audio/mp3')) return true
return getTrackExtension(track, details) === 'mp3'
}
function isNonMp3AudioDownload(track: Track, details?: HydrusFileDetails | null, contentType?: string | null) {
return supportsContainerMetadataEmbedding(getTrackExtension(track, details), contentType || track.mimeType || null)
}
function triggerBrowserDownload(href: string, fileName: string) {
const link = document.createElement('a')
link.href = href
link.download = fileName
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
link.remove()
}
async function embedDownloadMetadata(blob: Blob, track: Track, details?: HydrusFileDetails | null, contentType?: string | null) {
const metadata = buildHydrusDownloadMetadata(track, details)
if (isMp3Download(track, details, contentType)) {
if (!metadata.title && !metadata.artist && !metadata.album && !metadata.trackNumber) return { blob, note: undefined as string | undefined }
try {
const writer = new ID3Writer(await blob.arrayBuffer())
if (metadata.title) writer.setFrame('TIT2', metadata.title)
if (metadata.artist) writer.setFrame('TPE1', [metadata.artist])
if (metadata.album) writer.setFrame('TALB', metadata.album)
if (metadata.trackNumber) writer.setFrame('TRCK', metadata.trackNumber)
writer.addTag()
return {
blob: writer.getBlob(),
note: 'MP3 tags embedded from Hydrus tags',
}
} catch {
return { blob, note: undefined as string | undefined }
}
}
if (!isNonMp3AudioDownload(track, details, contentType)) return { blob, note: undefined as string | undefined }
if (!metadata.title && !metadata.artist && !metadata.album && !metadata.trackNumber) return { blob, note: undefined as string | undefined }
try {
const taggedBlob = await embedContainerAudioMetadata(blob, getTrackExtension(track, details), metadata)
return {
blob: taggedBlob,
note: 'Audio tags embedded from Hydrus tags',
}
} catch {
return { blob, note: undefined as string | undefined }
}
}
function StartupLibraryCacheSync() {
const { servers, updateServer } = useServers()
const lastSyncSignatureRef = useRef<string | null>(null)
const syncSignature = useMemo(() => servers
.map((server) => [
server.id,
server.host,
server.port,
server.apiKey,
server.ssl,
server.forceApiKeyInQuery,
].join('|'))
.join(','), [servers])
useEffect(() => {
if (!servers.length || !syncSignature || lastSyncSignatureRef.current === syncSignature) return
lastSyncSignatureRef.current = syncSignature
let cancelled = false
void syncLibraryCache(servers)
.then((result) => {
if (cancelled) return
for (const [serverId, summary] of Object.entries(result.summaries)) {
updateServer(serverId, { syncSummary: summary })
}
})
.catch(() => {
// background warm-up failures are surfaced through per-server sync summaries/events
})
return () => {
cancelled = true
}
}, [servers, syncSignature, updateServer])
return null
}
function App() {
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
const [activePage, setActivePage] = useState<MediaSection | 'settings'>('audio')
const [activePage, setActivePage] = useState<MediaSection | 'settings' | 'downloads'>('audio')
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true)
const [libraryQuery, setLibraryQuery] = useState(initialUiPreferences.libraryQuery)
@@ -99,7 +382,10 @@ function App() {
const mimeCacheRef = useRef<Record<string, MediaInfo>>({})
const mimeRequestCacheRef = useRef<Record<string, Promise<MediaInfo>>>({})
const playRequestAbortRef = useRef<AbortController | null>(null)
const lastBrowsePageRef = useRef<MediaSection>(activePage === 'settings' ? 'audio' : activePage)
const downloadAbortControllersRef = useRef<Record<string, AbortController>>({})
const downloadUrlsRef = useRef<Record<string, string>>({})
const lastBrowsePageRef = useRef<MediaSection>(activePage === 'settings' || activePage === 'downloads' ? 'audio' : activePage)
const [downloads, setDownloads] = useState<DownloadOverlayItem[]>([])
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent || '' : ''
const platform = typeof navigator !== 'undefined' ? navigator.platform || '' : ''
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints || 0 : 0
@@ -112,11 +398,55 @@ function App() {
useEffect(() => {
return () => {
try { playRequestAbortRef.current?.abort() } catch {}
for (const controller of Object.values(downloadAbortControllersRef.current)) {
try { controller.abort() } catch {}
}
for (const url of Object.values(downloadUrlsRef.current)) {
try { window.URL.revokeObjectURL(url) } catch {}
}
}
}, [])
useEffect(() => {
if (activePage !== 'settings') {
let cancelled = false
void listStoredDownloads()
.then((records) => {
if (cancelled || records.length === 0) return
const restoredDownloads: DownloadOverlayItem[] = records.map((record) => {
const objectUrl = window.URL.createObjectURL(record.blob)
downloadUrlsRef.current[record.id] = objectUrl
return {
id: record.id,
trackKey: record.trackKey,
title: record.title,
fileName: record.fileName,
status: 'completed',
receivedBytes: record.receivedBytes,
totalBytes: record.totalBytes,
saveHref: objectUrl,
note: record.note,
}
})
setDownloads((prev) => {
const existingIds = new Set(prev.map((download) => download.id))
return [...prev, ...restoredDownloads.filter((download) => !existingIds.has(download.id))]
})
})
.catch(() => {
// If IndexedDB is unavailable or blocked, downloads still work for the current session.
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
if (activePage !== 'settings' && activePage !== 'downloads') {
lastBrowsePageRef.current = activePage
}
}, [activePage])
@@ -289,6 +619,7 @@ function App() {
}, [])
const handleSidebarNavigate = useCallback((id: string) => {
if (id === 'settings') setActivePage('settings')
else if (id === 'downloads') setActivePage('downloads')
else setActivePage(id as MediaSection)
if (!isDesktopLayout) {
@@ -296,8 +627,183 @@ function App() {
}
}, [isDesktopLayout])
const updateDownload = useCallback((id: string, patch: Partial<DownloadOverlayItem>) => {
setDownloads((prev) => prev.map((download) => (download.id === id ? { ...download, ...patch } : download)))
}, [])
const activeDownloads = useMemo(() => downloads.filter((download) => download.status === 'downloading'), [downloads])
const isTrackDownloading = useCallback((track: Track) => {
const trackKey = buildTrackDownloadKey(track)
return downloads.some((download) => download.trackKey === trackKey && download.status === 'downloading')
}, [downloads])
const revokeDownloadUrl = useCallback((id: string) => {
const objectUrl = downloadUrlsRef.current[id]
if (!objectUrl) return
try { window.URL.revokeObjectURL(objectUrl) } catch {}
delete downloadUrlsRef.current[id]
}, [])
const queueDownload = useCallback((track: Track, details?: HydrusFileDetails | null) => {
const id = createDownloadId()
const trackKey = buildTrackDownloadKey(track)
if (downloads.some((download) => download.trackKey === trackKey && download.status === 'downloading')) {
return
}
const initialItem: DownloadOverlayItem = {
id,
trackKey,
title: buildHydrusDownloadDisplayTitle(track, details),
status: 'downloading',
receivedBytes: 0,
totalBytes: details?.sizeBytes || null,
}
setDownloads((prev) => [initialItem, ...prev])
const controller = new AbortController()
downloadAbortControllersRef.current[id] = controller
void (async () => {
try {
const response = await fetch(track.url, { method: 'GET', mode: 'cors', signal: controller.signal })
if (!response.ok) {
throw new Error(`Download failed (${response.status})`)
}
const contentLength = Number(response.headers.get('content-length') || '')
const resolvedTotalBytes = Number.isFinite(contentLength) && contentLength > 0
? contentLength
: details?.sizeBytes || null
updateDownload(id, { totalBytes: resolvedTotalBytes })
let blob: Blob
if (response.body) {
const reader = response.body.getReader()
const chunks: ArrayBuffer[] = []
let receivedBytes = 0
let lastProgressUpdate = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
if (!value) continue
const chunk = new Uint8Array(value.byteLength)
chunk.set(value)
chunks.push(chunk.buffer)
receivedBytes += value.byteLength
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
if (receivedBytes === resolvedTotalBytes || now - lastProgressUpdate > 120) {
updateDownload(id, { receivedBytes })
lastProgressUpdate = now
}
}
blob = new Blob(chunks, { type: response.headers.get('content-type') || 'application/octet-stream' })
updateDownload(id, {
receivedBytes,
totalBytes: resolvedTotalBytes || blob.size || null,
})
} else {
blob = await response.blob()
updateDownload(id, {
receivedBytes: blob.size,
totalBytes: resolvedTotalBytes || blob.size || null,
})
}
updateDownload(id, {
note: 'Embedding metadata...',
})
const taggedDownload = await embedDownloadMetadata(blob, track, details, response.headers.get('content-type'))
blob = taggedDownload.blob
const downloadName = buildTrackDownloadName(track, details, response.headers.get('content-disposition'))
const objectUrl = window.URL.createObjectURL(blob)
downloadUrlsRef.current[id] = objectUrl
triggerBrowserDownload(objectUrl, downloadName)
try {
await saveStoredDownload({
id,
trackKey,
title: buildHydrusDownloadDisplayTitle(track, details),
fileName: downloadName,
receivedBytes: blob.size,
totalBytes: blob.size || resolvedTotalBytes || null,
note: taggedDownload.note,
blob,
savedAt: Date.now(),
})
} catch {
// Keep the in-memory download available even if persistence fails.
}
updateDownload(id, {
status: 'completed',
fileName: downloadName,
saveHref: objectUrl,
receivedBytes: blob.size,
totalBytes: blob.size || resolvedTotalBytes || null,
note: taggedDownload.note,
})
} catch (error: any) {
if (error?.name === 'AbortError') {
updateDownload(id, { status: 'cancelled', error: undefined, note: undefined })
return
}
updateDownload(id, {
status: 'error',
error: error?.message ?? 'Download failed.',
note: undefined,
})
} finally {
delete downloadAbortControllersRef.current[id]
}
})()
}, [downloads, updateDownload])
const cancelDownload = useCallback((id: string) => {
const controller = downloadAbortControllersRef.current[id]
if (!controller) return
try { controller.abort() } catch {}
updateDownload(id, { status: 'cancelled' })
}, [updateDownload])
const saveDownloadAgain = useCallback((id: string) => {
const download = downloads.find((entry) => entry.id === id)
if (!download?.saveHref || !download.fileName) return
triggerBrowserDownload(download.saveHref, download.fileName)
}, [downloads])
const dismissDownload = useCallback((id: string) => {
revokeDownloadUrl(id)
setDownloads((prev) => prev.filter((download) => download.id !== id))
void deleteStoredDownload(id).catch(() => {})
}, [revokeDownloadUrl])
const clearFinishedDownloads = useCallback(() => {
setDownloads((prev) => {
for (const download of prev) {
if (download.status !== 'downloading') revokeDownloadUrl(download.id)
}
return prev.filter((download) => download.status === 'downloading')
})
void clearStoredDownloads().catch(() => {})
}, [revokeDownloadUrl])
return (
<ServersProvider>
<StartupLibraryCacheSync />
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
@@ -306,7 +812,7 @@ function App() {
onToggleSidebar={toggleSidebar}
searchQuery={libraryQuery}
onSearchQueryChange={setLibraryQuery}
searchDisabled={activePage === 'settings'}
searchDisabled={activePage === 'settings' || activePage === 'downloads'}
/>
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
@@ -331,10 +837,22 @@ function App() {
/>
</Suspense>
)
: activePage === 'downloads'
? (
<DownloadsPage
downloads={downloads}
onCancel={cancelDownload}
onSaveAgain={saveDownloadAgain}
onDismiss={dismissDownload}
onClearFinished={clearFinishedDownloads}
/>
)
: (
<Library
mediaSection={activePage}
onPlayNow={playNow}
onDownloadTrack={queueDownload}
isTrackDownloading={isTrackDownloading}
query={libraryQuery}
onQueryChange={setLibraryQuery}
displayModePreference={libraryDisplayMode}
@@ -348,6 +866,16 @@ function App() {
<DevErrorPanel />
</Suspense>
) : null}
{activePage !== 'downloads' ? (
<DownloadsOverlay
downloads={activeDownloads}
onCancel={cancelDownload}
onSaveAgain={saveDownloadAgain}
onDismiss={dismissDownload}
onClearFinished={clearFinishedDownloads}
/>
) : null}
</Box>
</ThemeProvider>
</ServersProvider>
+110
View File
@@ -0,0 +1,110 @@
import type { FFmpeg } from '@ffmpeg/ffmpeg'
import ffmpegCoreUrl from '@ffmpeg/core?url'
import ffmpegWasmUrl from '@ffmpeg/core/wasm?url'
import ffmpegWorkerUrl from '@ffmpeg/ffmpeg/worker?url'
type AudioTagMetadata = {
title?: string
artist?: string
album?: string
trackNumber?: string
}
const AUDIO_METADATA_EXTENSIONS = new Set([
'flac',
'm4a',
'aac',
'ogg',
'opus',
'oga',
'wav',
'aiff',
'aif',
'ape',
'wv',
])
let ffmpegInstance: FFmpeg | null = null
let ffmpegLoadPromise: Promise<FFmpeg> | null = null
function sanitizeExtension(extension?: string | null) {
return (extension || '').trim().replace(/^\./, '').toLowerCase()
}
async function getFFmpeg() {
if (ffmpegInstance?.loaded) return ffmpegInstance
if (ffmpegLoadPromise) return ffmpegLoadPromise
ffmpegLoadPromise = (async () => {
const { FFmpeg } = await import('@ffmpeg/ffmpeg')
const ffmpeg = new FFmpeg()
await ffmpeg.load({
classWorkerURL: ffmpegWorkerUrl,
coreURL: ffmpegCoreUrl,
wasmURL: ffmpegWasmUrl,
})
ffmpegInstance = ffmpeg
return ffmpeg
})()
try {
return await ffmpegLoadPromise
} finally {
ffmpegLoadPromise = null
}
}
function buildMetadataArgs(metadata: AudioTagMetadata) {
const args: string[] = []
if (metadata.title) args.push('-metadata', `title=${metadata.title}`)
if (metadata.artist) args.push('-metadata', `artist=${metadata.artist}`)
if (metadata.album) args.push('-metadata', `album=${metadata.album}`)
if (metadata.trackNumber) args.push('-metadata', `track=${metadata.trackNumber}`)
return args
}
export function supportsContainerMetadataEmbedding(extension?: string | null, mimeType?: string | null) {
const normalizedExtension = sanitizeExtension(extension)
if (normalizedExtension && AUDIO_METADATA_EXTENSIONS.has(normalizedExtension)) return true
const normalizedMimeType = (mimeType || '').trim().toLowerCase()
return normalizedMimeType.startsWith('audio/') && !normalizedMimeType.includes('mpeg') && !normalizedMimeType.includes('mp3')
}
export async function embedContainerAudioMetadata(blob: Blob, extension: string | undefined, metadata: AudioTagMetadata) {
if (!metadata.title && !metadata.artist && !metadata.album && !metadata.trackNumber) return blob
const normalizedExtension = sanitizeExtension(extension) || 'audio'
const ffmpeg = await getFFmpeg()
const { fetchFile } = await import('@ffmpeg/util')
const inputPath = `input.${normalizedExtension}`
const outputPath = `output.${normalizedExtension}`
await ffmpeg.writeFile(inputPath, await fetchFile(blob))
try {
const exitCode = await ffmpeg.exec([
'-i', inputPath,
'-map', '0',
'-map_metadata', '-1',
'-c', 'copy',
...buildMetadataArgs(metadata),
outputPath,
])
if (exitCode !== 0) {
throw new Error(`FFmpeg metadata update failed (${exitCode})`)
}
const outputData = await ffmpeg.readFile(outputPath)
const outputBytes = outputData instanceof Uint8Array ? outputData : new Uint8Array(new TextEncoder().encode(String(outputData)))
const outputCopy = new Uint8Array(outputBytes.byteLength)
outputCopy.set(outputBytes)
return new Blob([outputCopy], { type: blob.type || 'application/octet-stream' })
} finally {
try { await ffmpeg.deleteFile(inputPath) } catch {}
try { await ffmpeg.deleteFile(outputPath) } catch {}
}
}
export type { AudioTagMetadata }
+29 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Box, Button, Paper, Typography, List, ListItem, ListItemText, IconButton, Chip } from '@mui/material'
import CloseIcon from '@mui/icons-material/Close'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
@@ -18,7 +18,9 @@ function formatLogItem(log: DevLogItem) {
export default function DevErrorPanel() {
const [logs, setLogs] = useState<DevLogItem[]>(() => getDevLogs())
const [open, setOpen] = useState(false)
const [dismissed, setDismissed] = useState(false)
const errorCount = useMemo(() => logs.filter((item) => item.kind !== 'debug').length, [logs])
const previousErrorCountRef = useRef(errorCount)
const copyLog = async (log: DevLogItem) => {
const payload = formatLogItem(log)
@@ -51,7 +53,14 @@ export default function DevErrorPanel() {
useEffect(() => {
return subscribeDevLogs((items) => {
setLogs(items)
if (items.length > 0) setOpen(true)
const nextErrorCount = items.filter((item) => item.kind !== 'debug').length
const hasNewError = nextErrorCount > previousErrorCountRef.current
previousErrorCountRef.current = nextErrorCount
if (hasNewError) {
setDismissed(false)
setOpen(true)
}
})
}, [])
@@ -87,8 +96,24 @@ export default function DevErrorPanel() {
if (!import.meta.env.DEV) return null
if (dismissed) {
return (
<Box sx={{ position: 'fixed', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 12, zIndex: 9999 }}>
<Box sx={{ position: 'fixed', top: 12, right: 12, zIndex: 9999 }}>
<Chip
label={`Dev Logs${errorCount ? ` (${errorCount})` : ''}`}
color={errorCount ? 'error' : 'default'}
clickable
onClick={() => {
setDismissed(false)
setOpen(true)
}}
/>
</Box>
)
}
return (
<Box sx={{ position: 'fixed', top: 12, right: 12, zIndex: 9999 }}>
<Paper elevation={6} sx={{ minWidth: { xs: 0, sm: 320 }, width: { xs: 'calc(100vw - 24px)', sm: 'auto' }, maxWidth: { xs: 'calc(100vw - 24px)', sm: 640 }, p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', pb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -98,7 +123,7 @@ export default function DevErrorPanel() {
</Box>
<Box>
<Button size="small" onClick={() => { clearDevLogs() }} sx={{ mr: 1 }}>Clear</Button>
<IconButton size="small" onClick={() => setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}>
<IconButton size="small" onClick={() => { setDismissed(true); setOpen(false) }} aria-label="dismiss dev log panel" sx={{ width: 32, height: 32 }}>
<CloseIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
+173
View File
@@ -0,0 +1,173 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Box, Chip, IconButton, LinearProgress, Paper, Typography } from '@mui/material'
import ExpandLessIcon from '@mui/icons-material/ExpandLess'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
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'
export type DownloadOverlayItem = {
id: string
trackKey: string
title: string
fileName?: string
status: 'downloading' | 'completed' | 'cancelled' | 'error'
receivedBytes: number
totalBytes: number | null
saveHref?: string
error?: string
note?: string
}
type DownloadsOverlayProps = {
downloads: DownloadOverlayItem[]
onCancel: (id: string) => void
onSaveAgain: (id: string) => void
onDismiss: (id: string) => 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) {
const receivedText = formatBytes(download.receivedBytes)
const totalText = formatBytes(download.totalBytes)
if (download.status === 'downloading') {
if (download.note) return download.note
if (receivedText && totalText) return `${receivedText} of ${totalText}`
if (receivedText) return `${receivedText} received`
return 'Preparing download...'
}
if (download.status === 'completed') {
const parts = ['Ready to save again']
if (totalText || receivedText) parts.push(totalText || receivedText || '')
if (download.note) parts.push(download.note)
return parts.join(' • ')
}
if (download.status === 'cancelled') return 'Cancelled'
return download.error || 'Download failed'
}
function getProgressPercent(download: DownloadOverlayItem) {
if (!download.totalBytes || download.totalBytes <= 0) return null
return Math.max(0, Math.min(100, (download.receivedBytes / download.totalBytes) * 100))
}
export default function DownloadsOverlay({ downloads, onCancel, onSaveAgain, onDismiss, onClearFinished }: DownloadsOverlayProps) {
const [expanded, setExpanded] = useState(true)
const activeCount = useMemo(() => downloads.filter((download) => download.status === 'downloading').length, [downloads])
const finishedCount = useMemo(() => downloads.filter((download) => download.status !== 'downloading').length, [downloads])
useEffect(() => {
if (activeCount > 0) setExpanded(true)
}, [activeCount])
if (downloads.length === 0) return null
return (
<Paper
elevation={10}
sx={{
position: 'fixed',
right: 16,
bottom: 16,
width: { xs: 'calc(100vw - 24px)', sm: 360 },
maxWidth: 'calc(100vw - 24px)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.08)',
bgcolor: 'background.paper',
zIndex: (theme) => theme.zIndex.modal - 1,
overflow: 'hidden',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.25, py: 1, borderBottom: expanded ? '1px solid rgba(255,255,255,0.08)' : 'none' }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, flex: 1, minWidth: 0 }} noWrap>
Downloads
</Typography>
{activeCount > 0 && <Chip size="small" color="primary" label={activeCount === 1 ? '1 active' : `${activeCount} active`} />}
{finishedCount > 0 && <Chip size="small" variant="outlined" label={finishedCount === 1 ? '1 done' : `${finishedCount} done`} />}
{finishedCount > 0 && (
<IconButton size="small" onClick={onClearFinished} aria-label="clear finished downloads">
<ClearAllIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" onClick={() => setExpanded((current) => !current)} aria-label={expanded ? 'collapse downloads' : 'expand downloads'}>
{expanded ? <ExpandMoreIcon fontSize="small" /> : <ExpandLessIcon fontSize="small" />}
</IconButton>
</Box>
{expanded && (
<Box sx={{ maxHeight: 280, overflowY: 'auto' }}>
{downloads.map((download) => {
const progressPercent = getProgressPercent(download)
const progressText = buildProgressText(download)
return (
<Box key={download.id} sx={{ px: 1.25, py: 1, borderTop: '1px solid rgba(255,255,255,0.06)' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{download.title}
</Typography>
{download.fileName && download.status !== 'downloading' && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>
{download.fileName}
</Typography>
)}
</Box>
{download.status === 'downloading'
? (
<IconButton size="small" onClick={() => onCancel(download.id)} aria-label="cancel download">
<StopCircleOutlinedIcon fontSize="small" />
</IconButton>
)
: (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{download.status === 'completed' && download.saveHref && (
<IconButton size="small" onClick={() => onSaveAgain(download.id)} aria-label="save download again">
<DownloadIcon fontSize="small" />
</IconButton>
)}
<IconButton size="small" onClick={() => onDismiss(download.id)} aria-label="dismiss download">
<CloseIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
{download.status === 'downloading' && (
<LinearProgress
sx={{ mt: 0.75 }}
variant={progressPercent != null ? 'determinate' : 'indeterminate'}
value={progressPercent ?? undefined}
/>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{download.status === 'downloading' && progressPercent != null
? `Downloading ${progressPercent.toFixed(0)}% • ${progressText}`
: progressText}
</Typography>
</Box>
)
})}
</Box>
)}
</Paper>
)
}
+3 -1
View File
@@ -4,6 +4,7 @@ import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import MovieIcon from '@mui/icons-material/Movie'
import ImageIcon from '@mui/icons-material/Image'
import AppsIcon from '@mui/icons-material/Apps'
import DownloadIcon from '@mui/icons-material/Download'
import LibraryMusicIcon from '@mui/icons-material/LibraryMusic'
import SettingsIcon from '@mui/icons-material/Settings'
import type { MediaSection } from '../types'
@@ -11,7 +12,7 @@ import type { MediaSection } from '../types'
export const drawerWidth = 240
type NavItem = {
id: MediaSection
id: MediaSection | 'downloads'
label: string
icon: React.ReactNode
}
@@ -22,6 +23,7 @@ const ITEMS: NavItem[] = [
{ id: 'video', label: 'Video', icon: <MovieIcon /> },
{ id: 'image', label: 'Image', icon: <ImageIcon /> },
{ id: 'application', label: 'Applications', icon: <AppsIcon /> },
{ id: 'downloads', label: 'Downloads', icon: <DownloadIcon /> },
]
export default function Sidebar({
+73
View File
@@ -0,0 +1,73 @@
type PersistedDownloadRecord = {
id: string
trackKey: string
title: string
fileName?: string
receivedBytes: number
totalBytes: number | null
note?: string
blob: Blob
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)
})
}
export async function listStoredDownloads(): Promise<PersistedDownloadRecord[]> {
if (typeof indexedDB === 'undefined') return []
const records = await withStore<PersistedDownloadRecord[]>('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))
}
export async function deleteStoredDownload(id: string) {
if (!id || typeof indexedDB === 'undefined') return
await withStore('readwrite', (store) => store.delete(id))
}
export async function clearStoredDownloads() {
if (typeof indexedDB === 'undefined') return
await withStore('readwrite', (store) => store.clear())
}
export type { PersistedDownloadRecord }
+188
View File
@@ -0,0 +1,188 @@
import { HydrusClient, extractTitleFromTags, type ServerConfig } from './api/hydrusClient'
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from './libraryCache'
import type { MediaSection, ServerSyncSummary, Track } from './types'
const SYNC_SECTION_LIMIT = 2000
const SYNC_SECTIONS: Array<{ id: Exclude<MediaSection, 'all'>; predicate: string }> = [
{ id: 'audio', predicate: 'system:filetype = audio' },
{ id: 'video', predicate: 'system:filetype = video' },
{ id: 'image', predicate: 'system:filetype = image' },
{ id: 'application', predicate: 'system:filetype = application' },
]
export const LIBRARY_CACHE_SYNC_EVENT = 'api-media-player:library-cache-sync'
type SyncEventDetail = {
cacheKey: string
phase: 'started' | 'completed' | 'failed'
serverIds: string[]
error?: string
}
export type LibrarySyncServer = ServerConfig
export type LibraryCacheSyncResult = {
cacheKey: string
summaries: Record<string, ServerSyncSummary>
addedCounts: Record<string, number>
removedCounts: Record<string, number>
}
function dispatchSyncEvent(detail: SyncEventDetail) {
if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return
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}` : ''
}
export async function syncLibraryCache(servers: LibrarySyncServer[], options: { targetServerIds?: string[] } = {}): Promise<LibraryCacheSyncResult> {
const cacheKey = buildLibraryCacheKey(servers)
const targetServerIds = new Set(options.targetServerIds && options.targetServerIds.length > 0
? options.targetServerIds
: servers.map((server) => server.id))
const targetServers = servers.filter((server) => targetServerIds.has(server.id))
if (!cacheKey || targetServers.length === 0) {
return {
cacheKey,
summaries: {},
addedCounts: {},
removedCounts: {},
}
}
dispatchSyncEvent({ cacheKey, phase: 'started', serverIds: targetServers.map((server) => server.id) })
try {
const snapshot = await loadLibraryCache(cacheKey)
const snapshotTracks = snapshot?.tracks ?? []
const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) }
const mergedTrackMap: Record<string, Track> = {}
const snapshotTracksByServer: Record<string, Track[]> = {}
let localCounter = Date.now()
for (const track of snapshotTracks) {
const hydratedTrack: Track = { ...track, id: ++localCounter }
const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId)
if (!key || !hydratedTrack.serverId) continue
if (!snapshotTracksByServer[hydratedTrack.serverId]) {
snapshotTracksByServer[hydratedTrack.serverId] = []
}
snapshotTracksByServer[hydratedTrack.serverId].push(hydratedTrack)
if (!targetServerIds.has(hydratedTrack.serverId)) {
mergedTrackMap[key] = hydratedTrack
}
}
for (const searchKey of Object.keys(mergedSearchCache)) {
if (Array.from(targetServerIds).some((serverId) => searchKey.startsWith(`${serverId}|`))) {
delete mergedSearchCache[searchKey]
}
}
const summaries: Record<string, ServerSyncSummary> = {}
const addedCounts: Record<string, number> = {}
const removedCounts: Record<string, number> = {}
for (const server of targetServers) {
const previousTracks = snapshotTracksByServer[server.id] ?? []
const previousServerTrackKeys = new Set(previousTracks.map((track) => buildTrackCacheKey(track.serverId, track.fileId)).filter(Boolean))
const currentServerTrackKeys = new Set<string>()
const counts: ServerSyncSummary['counts'] = {}
try {
const client = new HydrusClient(server)
for (const section of SYNC_SECTIONS) {
const searchTags = [section.predicate]
const ids = await client.searchFiles(searchTags, SYNC_SECTION_LIMIT)
counts[section.id] = ids.length
mergedSearchCache[`${server.id}|${section.id}|tracks|${JSON.stringify(searchTags)}`] = ids
if (ids.length === 0) continue
const [tagMap, mediaInfoMap] = await Promise.all([
client.getFilesTags(ids, 8),
section.id === 'application' ? client.getFilesMediaInfo(ids, 6) : Promise.resolve({} as Record<number, { mimeType?: string; isVideo?: boolean }>),
])
for (const fileId of ids) {
const tags = tagMap[fileId] || []
const key = buildTrackCacheKey(server.id, fileId)
if (!key) continue
currentServerTrackKeys.add(key)
mergedTrackMap[key] = {
id: ++localCounter,
fileId,
serverId: server.id,
serverName: server.name || server.host,
title: extractTitleFromTags(tags) || '',
artist: extractNamespaceValue(tags, 'artist') || undefined,
album: extractNamespaceValue(tags, 'album') || undefined,
tags: tags.length ? tags : undefined,
url: client.getFileUrl(fileId),
thumbnail: client.getThumbnailUrl(fileId),
mimeType: mediaInfoMap[fileId]?.mimeType,
isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined),
mediaKind: section.id,
}
}
}
const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0)
addedCounts[server.id] = Array.from(currentServerTrackKeys).filter((key) => !previousServerTrackKeys.has(key)).length
removedCounts[server.id] = Array.from(previousServerTrackKeys).filter((key) => !currentServerTrackKeys.has(key)).length
summaries[server.id] = {
updatedAt: Date.now(),
total,
counts,
}
} catch (error: any) {
for (const track of previousTracks) {
const key = buildTrackCacheKey(track.serverId, track.fileId)
if (!key) continue
mergedTrackMap[key] = track
}
addedCounts[server.id] = 0
removedCounts[server.id] = 0
summaries[server.id] = {
updatedAt: Date.now(),
total: previousTracks.length,
counts: {},
message: `Sync failed: ${error?.message ?? String(error)}`,
}
}
}
await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache)
dispatchSyncEvent({ cacheKey, phase: 'completed', serverIds: targetServers.map((server) => server.id) })
return {
cacheKey,
summaries,
addedCounts,
removedCounts,
}
} catch (error: any) {
const message = error?.message ?? String(error)
dispatchSyncEvent({ cacheKey, phase: 'failed', serverIds: targetServers.map((server) => server.id), error: message })
throw error
}
}
+197
View File
@@ -0,0 +1,197 @@
import React, { useMemo } from 'react'
import { Box, Button, Chip, Divider, LinearProgress, Paper, Typography } from '@mui/material'
import type { DownloadOverlayItem } from '../components/DownloadsOverlay'
type DownloadsPageProps = {
downloads: DownloadOverlayItem[]
onCancel: (id: string) => void
onSaveAgain: (id: string) => void
onDismiss: (id: string) => 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) {
if (!download.totalBytes || download.totalBytes <= 0) return null
return Math.max(0, Math.min(100, (download.receivedBytes / download.totalBytes) * 100))
}
function getStatusTone(download: DownloadOverlayItem): 'primary' | 'success' | 'warning' | 'error' | 'default' {
if (download.status === 'completed') return 'success'
if (download.status === 'cancelled') return 'warning'
if (download.status === 'error') return 'error'
if (download.status === 'downloading') return 'primary'
return 'default'
}
function renderProgressText(download: DownloadOverlayItem) {
const received = formatBytes(download.receivedBytes)
const total = formatBytes(download.totalBytes)
if (download.status === 'downloading') {
if (download.note) return download.note
if (received && total) return `${received} of ${total}`
if (received) return `${received} received`
return 'Preparing download...'
}
if (download.status === 'completed') {
const parts = ['Saved in browser storage']
if (total || received) parts.push(total || received || '')
if (download.note) parts.push(download.note)
return parts.join(' • ')
}
if (download.status === 'cancelled') return 'Cancelled before completion'
return download.error || 'Download failed'
}
function DownloadRow({ download, onCancel, onSaveAgain, onDismiss }: {
download: DownloadOverlayItem
onCancel: (id: string) => void
onSaveAgain: (id: string) => void
onDismiss: (id: string) => void
}) {
const progressPercent = getProgressPercent(download)
return (
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }} noWrap>
{download.title}
</Typography>
{download.fileName && (
<Typography variant="body2" color="text.secondary" noWrap>
{download.fileName}
</Typography>
)}
</Box>
<Chip size="small" color={getStatusTone(download)} label={download.status === 'downloading' ? 'Downloading' : download.status === 'completed' ? 'Ready' : download.status === 'cancelled' ? 'Cancelled' : 'Error'} />
</Box>
{download.status === 'downloading' && (
<LinearProgress
sx={{ mt: 1.5 }}
variant={progressPercent != null ? 'determinate' : 'indeterminate'}
value={progressPercent ?? undefined}
/>
)}
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{download.status === 'downloading' && progressPercent != null
? `Downloading ${progressPercent.toFixed(0)}% • ${renderProgressText(download)}`
: renderProgressText(download)}
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
{download.status === 'downloading' ? (
<Button variant="outlined" color="warning" onClick={() => onCancel(download.id)}>
Cancel
</Button>
) : null}
{download.status === 'completed' && download.saveHref ? (
<Button variant="contained" onClick={() => onSaveAgain(download.id)}>
Download Again
</Button>
) : null}
<Button variant="text" color="inherit" onClick={() => onDismiss(download.id)}>
Remove
</Button>
</Box>
</Paper>
)
}
export default function DownloadsPage({ downloads, onCancel, onSaveAgain, onDismiss, onClearFinished }: DownloadsPageProps) {
const activeDownloads = useMemo(() => downloads.filter((download) => download.status === 'downloading'), [downloads])
const finishedDownloads = useMemo(() => downloads.filter((download) => download.status !== 'downloading'), [downloads])
return (
<Box sx={{ p: { xs: 2, md: 3 }, maxWidth: 1100, mx: 'auto', width: '100%' }}>
<Typography variant="h4" sx={{ fontSize: { xs: '1.6rem', md: '2rem' }, fontWeight: 700, mb: 1 }}>
Downloads
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
Finished downloads stay available across refreshes until you remove them from this page.
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2.5 }}>
<Chip color="primary" variant="outlined" label={activeDownloads.length === 1 ? '1 active download' : `${activeDownloads.length} active downloads`} />
<Chip color="success" variant="outlined" label={finishedDownloads.length === 1 ? '1 stored item' : `${finishedDownloads.length} stored items`} />
{finishedDownloads.length > 0 && (
<Button variant="text" onClick={onClearFinished}>
Clear Finished
</Button>
)}
</Box>
<Box sx={{ display: 'grid', gap: 3 }}>
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>
Active
</Typography>
{activeDownloads.length > 0 ? (
<Box sx={{ display: 'grid', gap: 1.5 }}>
{activeDownloads.map((download) => (
<DownloadRow
key={download.id}
download={download}
onCancel={onCancel}
onSaveAgain={onSaveAgain}
onDismiss={onDismiss}
/>
))}
</Box>
) : (
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="body2" color="text.secondary">
No active downloads.
</Typography>
</Paper>
)}
</Box>
<Divider flexItem />
<Box>
<Typography variant="h6" sx={{ mb: 1.5 }}>
Stored
</Typography>
{finishedDownloads.length > 0 ? (
<Box sx={{ display: 'grid', gap: 1.5 }}>
{finishedDownloads.map((download) => (
<DownloadRow
key={download.id}
download={download}
onCancel={onCancel}
onSaveAgain={onSaveAgain}
onDismiss={onDismiss}
/>
))}
</Box>
) : (
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
<Typography variant="body2" color="text.secondary">
Completed and failed downloads will appear here for later download or cleanup.
</Typography>
</Paper>
)}
</Box>
</Box>
</Box>
)
}
+134 -377
View File
@@ -1,23 +1,24 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Alert, Box, Button, Card, CardActionArea, CardContent, CardMedia, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Paper, Select, Tab, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tabs, Typography, useMediaQuery } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import DownloadIcon from '@mui/icons-material/Download'
import { useServers } from '../context/ServersContext'
import { HydrusClient, extractTitleFromTags, type HydrusFileDetails } from '../api/hydrusClient'
import { loadUiPreferences, saveUiPreferences } from '../appPreferences'
import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../libraryCache'
import type { HydrusSearchTags } from '../api/hydrusClient'
import { LIBRARY_CACHE_SYNC_EVENT } from '../librarySync'
import type { MediaSection, Track } from '../types'
const NO_IMAGE_DATA_URL = "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='140'%3E%3Crect fill='%23eee' width='100%25' height='100%25'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%23666' font-family='Arial, sans-serif' font-size='20'%3ENo Image%3C/text%3E%3C/svg%3E"
const RESULTS_PAGE_SIZE = 36
const SEARCH_FETCH_LIMIT = RESULTS_PAGE_SIZE * 5
const INITIAL_SYSTEM_FETCH_LIMIT = RESULTS_PAGE_SIZE * 10
const SEARCH_TEXT_SCAN_LIMIT = 2000
const LONG_PRESS_DELAY_MS = 420
const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g
type Props = {
mediaSection: MediaSection
onPlayNow: (track: Track) => void | Promise<void>
onDownloadTrack: (track: Track, details?: HydrusFileDetails | null) => void
isTrackDownloading: (track: Track) => boolean
query: string
onQueryChange: (value: string) => void
displayModePreference: 'grid' | 'table'
@@ -170,15 +171,6 @@ function getStoredSectionView(section: MediaSection, views: Partial<Record<Media
return SECTION_CONFIG[section].views.some((item) => item.id === candidate) ? candidate as LibraryView : 'tracks'
}
function tokenizeSearchWords(value: string) {
return value
.toLocaleLowerCase()
.replace(/_/g, ' ')
.split(/[^a-z0-9]+/i)
.map((part) => part.trim())
.filter(Boolean)
}
function formatBytes(value?: number) {
if (!value || value <= 0) return null
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -221,12 +213,10 @@ function getTrackKindLabel(track: Track, fallbackLabel: string) {
return fallbackLabel
}
export default function Library({ mediaSection, onPlayNow, query, onQueryChange, displayModePreference }: Props) {
export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, query, onQueryChange, displayModePreference }: Props) {
const initialUiPreferences = useMemo(() => loadUiPreferences(), [])
const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial<Record<MediaSection, string>>, [initialUiPreferences])
const [results, setResults] = useState<Track[]>([])
const [albums, setAlbums] = useState<AlbumEntry[]>([])
const [artists, setArtists] = useState<AlbumEntry[]>([])
const [sectionViews, setSectionViews] = useState<Partial<Record<MediaSection, string>>>(initialSectionViews)
const [view, setView] = useState<LibraryView>(() => getStoredSectionView(mediaSection, initialSectionViews))
const [sortBy, setSortBy] = useState<SortField>(initialUiPreferences.librarySortBy as SortField)
@@ -237,24 +227,12 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
const theme = useTheme()
const isCompactTableLayout = useMediaQuery(theme.breakpoints.down('sm'))
const { servers, updateServer } = useServers()
const { servers } = useServers()
const hasServers = servers.length > 0
const serverCacheKey = buildLibraryCacheKey(servers)
const serverSearchSignature = useMemo(() => servers.map((server) => [
server.id,
server.name,
server.host,
server.port,
server.apiKey,
server.ssl,
server.forceApiKeyInQuery,
].join('|')).join(','), [servers])
const searchCacheRef = React.useRef<Record<string, number[]>>({})
const trackCacheRef = React.useRef<Record<string, Track>>({})
const albumCacheRef = React.useRef<Record<string, AlbumEntry>>({})
const artistCacheRef = React.useRef<Record<string, AlbumEntry>>({})
const albumTracksRef = React.useRef<Record<string, Track[]>>({})
const searchAbortRef = useRef<AbortController | null>(null)
const playMetadataAbortRef = useRef<AbortController | null>(null)
const persistTimeoutRef = useRef<number | null>(null)
const detailsAbortRef = useRef<AbortController | null>(null)
@@ -275,7 +253,7 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
const hasArtistsView = sectionConfig.views.some((item) => item.id === 'artists')
const effectiveDisplayMode: DisplayMode = isAllSection ? 'table' : displayModePreference
const sortOptions = useMemo(() => isTrackLikeView ? TRACK_SORT_OPTIONS : ENTRY_SORT_OPTIONS, [isTrackLikeView])
const queryWords = useMemo(() => tokenizeSearchWords(query), [query])
const detailsTrackDownloading = !!detailsTrack && isTrackDownloading(detailsTrack)
function extractNamespaceValue(tags: string[] | null | undefined, ns: string): string | null {
if (!tags || !Array.isArray(tags)) return null
@@ -293,17 +271,6 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
return bestValue || null
}
function escapeHydrusSearchValue(value: string) {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function buildNamespaceSearch(ns: 'album' | 'artist', value: string, matchMode: 'contains' | 'exact' = 'contains') {
const trimmed = value.trim()
if (!trimmed) return `${ns}:*`
const escapedValue = escapeHydrusSearchValue(trimmed)
return matchMode === 'exact' ? `${ns}:"${escapedValue}"` : `${ns}:"*${escapedValue}*"`
}
function normalizeNamespaceValue(value?: string | null) {
return (value || '').trim().toLocaleLowerCase()
}
@@ -363,29 +330,85 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
return tracks.filter((track) => matchesMediaSection(track, section))
}
function buildSectionSearchTags(baseQuery: string, namespace?: 'album' | 'artist'): HydrusSearchTags {
const tags: HydrusSearchTags = []
const trimmedQuery = baseQuery.trim()
if (sectionConfig.systemPredicate) {
tags.push(sectionConfig.systemPredicate)
function normalizeSearchText(value?: string | null) {
return (value || '').replace(/_/g, ' ').trim().toLocaleLowerCase()
}
if (namespace) tags.push(buildNamespaceSearch(namespace, trimmedQuery))
return tags
function normalizeSearchTerm(value: string) {
let normalized = value.trim()
if (normalized.startsWith('"') && normalized.endsWith('"')) {
normalized = normalized.slice(1, -1)
}
function matchesTrackSearch(track: Track, words: string[]) {
if (words.length === 0) return true
normalized = normalized
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.replace(/^\*+|\*+$/g, '')
return normalizeSearchText(normalized)
}
function matchesSearchValue(value: string | undefined | null, term: string) {
if (!term) return true
return normalizeSearchText(value).includes(term)
}
function matchesNamespacedTag(tags: string[] | undefined, namespace: string, term: string) {
if (!tags || tags.length === 0) return false
const prefix = `${namespace.toLowerCase()}:`
return tags.some((tag) => {
if (typeof tag !== 'string') return false
if (!tag.toLowerCase().startsWith(prefix)) return false
return matchesSearchValue(tag.slice(prefix.length), term)
})
}
function matchesSystemPredicate(track: Track, rawValue: string) {
const normalized = normalizeSearchText(rawValue).replace(/^system:/, '')
if (!normalized || normalized === 'everything') return true
const fileTypeMatch = normalized.match(/^filetype\s*=\s*(audio|video|image|application)$/)
if (fileTypeMatch) {
return matchesMediaSection(track, fileTypeMatch[1] as MediaSection)
}
return true
}
function matchesTrackSearch(track: Track, rawQuery: string) {
const trimmedQuery = rawQuery.trim()
if (!trimmedQuery) return true
const tokens = trimmedQuery.match(LOCAL_SEARCH_TOKEN_PATTERN)?.filter(Boolean) ?? []
if (tokens.length === 0) return true
return tokens.every((token) => {
const separatorIndex = token.indexOf(':')
if (separatorIndex > 0) {
const namespace = token.slice(0, separatorIndex).toLowerCase()
const term = normalizeSearchTerm(token.slice(separatorIndex + 1))
if (namespace === 'system') return matchesSystemPredicate(track, token)
if (namespace === 'title') return matchesSearchValue(getTrackDisplayTitle(track), term)
if (namespace === 'artist') return matchesSearchValue(track.artist, term)
if (namespace === 'album') return matchesSearchValue(track.album, term)
return matchesNamespacedTag(track.tags, namespace, term)
}
const term = normalizeSearchTerm(token)
if (!term) return true
const searchableValues = [
getTrackDisplayTitle(track),
track.artist,
track.album,
track.serverName,
...(track.tags || []),
]
const searchableWords = new Set(searchableValues.flatMap((value) => tokenizeSearchWords(value)))
return words.every((word) => searchableWords.has(word))
return searchableValues.some((value) => matchesSearchValue(value, term))
})
}
function getTrackExtension(track: Track, details?: HydrusFileDetails | null) {
@@ -406,6 +429,7 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
trackCacheRef.current[cacheKey] = existing ? { ...existing, ...track } : { ...track }
}
setResults(Object.values(trackCacheRef.current))
schedulePersistLibraryCache()
}
@@ -495,337 +519,79 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
let cancelled = false
const restoreCachedLibrary = async () => {
setLoading(hasServers)
if (!hasServers || !serverCacheKey) {
trackCacheRef.current = {}
searchCacheRef.current = {}
albumTracksRef.current = {}
return
}
try {
const snapshot = await loadLibraryCache(serverCacheKey)
if (cancelled || !snapshot) return
trackCacheRef.current = {}
albumTracksRef.current = {}
searchCacheRef.current = snapshot.searchCache || {}
let localCounter = Date.now()
const hydratedTracks = (snapshot.tracks || []).map((track) => ({ ...track, id: ++localCounter }))
for (const track of hydratedTracks) {
const cacheKey = getTrackCacheKey(track.serverId, track.fileId)
if (cacheKey) trackCacheRef.current[cacheKey] = track
addTrackToAlbumCache(track)
}
const sectionTracks = filterTracksForView(hydratedTracks, view)
const cachedAlbums = buildNamespaceEntriesFromTracks(sectionTracks, 'album')
const cachedArtists = buildNamespaceEntriesFromTracks(sectionTracks, 'artist')
if (!cancelled) {
setAlbums(cachedAlbums)
setArtists(cachedArtists)
if ((view === 'tracks' || view === 'text' || view === 'data') && !query.trim() && !albumFilter && !artistFilter) {
setResults(sectionTracks)
}
}
} catch {
// ignore cache restore failures and fall back to live Hydrus data
}
}
void restoreCachedLibrary()
return () => {
cancelled = true
}
}, [albumFilter, artistFilter, hasServers, mediaSection, query, serverCacheKey, view])
useEffect(() => {
const controller = new AbortController()
searchAbortRef.current = controller
const performSearch = async () => {
if (!hasServers) {
setResults([])
setError(null)
setLoading(false)
return
}
const searchQuery = query.trim()
const isDefaultSectionSearch = view === 'tracks' && searchQuery.length === 0
const shouldUseLocalTextSearch = isTrackLikeView && searchQuery.length > 0
const cachedSectionTracks = filterTracksForView(Object.values(trackCacheRef.current), view)
try {
const snapshot = await loadLibraryCache(serverCacheKey)
if (cancelled) return
setLoading(true)
setError(null)
setResults((view === 'tracks' || view === 'text' || view === 'data') && !albumFilter && !artistFilter ? cachedSectionTracks : [])
trackCacheRef.current = {}
albumTracksRef.current = {}
searchCacheRef.current = snapshot?.searchCache || {}
if (view === 'tracks') {
setAlbums(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'album'))
setArtists(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'artist'))
} else if (view === 'albums') {
albumCacheRef.current = {}
setAlbums(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'album'))
} else {
artistCacheRef.current = {}
setArtists(buildNamespaceEntriesFromTracks(cachedSectionTracks, 'artist'))
let localCounter = Date.now()
const hydratedTracks = (snapshot?.tracks || []).map((track) => ({ ...track, id: ++localCounter }))
for (const track of hydratedTracks) {
const cacheKey = getTrackCacheKey(track.serverId, track.fileId)
if (cacheKey) trackCacheRef.current[cacheKey] = track
addTrackToAlbumCache(track)
}
const cache = searchCacheRef.current
let pending = servers.length
let localCounter = Date.now()
let successfulSearchCount = 0
const failedMessages: string[] = []
const finishOne = () => {
pending -= 1
if (pending <= 0 && !controller.signal.aborted) {
setError(successfulSearchCount === 0 && failedMessages.length > 0 ? failedMessages[0] : null)
if (!cancelled) {
setResults(hydratedTracks)
setError(null)
}
} catch {
if (cancelled) return
trackCacheRef.current = {}
searchCacheRef.current = {}
albumTracksRef.current = {}
setResults([])
} finally {
if (!cancelled) {
setLoading(false)
}
}
for (const s of servers) {
;(async () => {
const client = new HydrusClient(s)
try {
if (view === 'tracks' || view === 'text' || view === 'data') {
const trackSearchTags = shouldUseLocalTextSearch
? (sectionConfig.systemPredicate ? [sectionConfig.systemPredicate] : [])
: buildSectionSearchTags(searchQuery)
const cacheKey = `${s.id}|${mediaSection}|${view}|${JSON.stringify(trackSearchTags)}`
let ids: number[] = []
if (cache[cacheKey]) ids = cache[cacheKey]
else {
const pageSize = shouldUseLocalTextSearch || isAllSection ? SEARCH_TEXT_SCAN_LIMIT : isDefaultSectionSearch ? INITIAL_SYSTEM_FETCH_LIMIT : SEARCH_FETCH_LIMIT
ids = await client.searchFiles(trackSearchTags, pageSize, controller.signal)
cache[cacheKey] = ids
schedulePersistLibraryCache()
}
if (controller.signal.aborted) return
successfulSearchCount += 1
const handleCacheSyncEvent = (event: Event) => {
const detail = (event as CustomEvent<{ cacheKey?: string; phase?: string; error?: string }>).detail
if (!detail || detail.cacheKey !== serverCacheKey) return
try { updateServer && updateServer(s.id, { lastTest: { ok: true, message: 'Search OK', status: null, searchOk: true, rangeSupported: s.lastTest?.rangeSupported ?? false, timestamp: Date.now() } }) } catch {}
if (!ids || ids.length === 0) return
const newTracks: Track[] = []
const cachedTracks: Track[] = []
const baseTracksByFileId = new Map<number, Track>()
for (const fid of ids) {
localCounter += 1
const cachedTrack = trackCacheRef.current[getTrackCacheKey(s.id, fid)]
const track = cachedTrack
? { ...cachedTrack, id: localCounter, fileId: fid, serverId: s.id, serverName: s.name || s.host, url: client.getFileUrl(fid), thumbnail: cachedTrack.thumbnail || client.getThumbnailUrl(fid), mediaKind: cachedTrack.mediaKind || mediaSection }
: { id: localCounter, fileId: fid, serverId: s.id, serverName: s.name || s.host, title: '', url: client.getFileUrl(fid), thumbnail: client.getThumbnailUrl(fid), mediaKind: mediaSection }
newTracks.push(track)
baseTracksByFileId.set(fid, track)
if (cachedTrack?.tags?.length) {
cachedTracks.push(track)
addTrackToAlbumCache(track)
}
if (detail.phase === 'started') {
setLoading(true)
return
}
cacheTracks(newTracks)
setResults((prev) => {
const seen = new Set(prev.map((r) => `${r.serverId}:${r.fileId}`))
const combined = [...prev]
for (const t of newTracks) {
const key = `${t.serverId}:${t.fileId}`
if (!seen.has(key)) {
combined.push(t)
seen.add(key)
}
}
return combined
})
// Eagerly fetch tags for a larger subset when doing a system:everything seed so we can build artist/album groups
try {
const subsetIds = ids.filter((fid) => !trackCacheRef.current[getTrackCacheKey(s.id, fid)]?.tags?.length)
if (subsetIds.length) {
const [tagMap, mediaInfoMap] = await Promise.all([
client.getFilesTags(subsetIds, isDefaultSectionSearch ? 8 : 4, controller.signal),
mediaSection === 'application' ? client.getFilesMediaInfo(subsetIds, 6, controller.signal) : Promise.resolve({} as Record<number, { mimeType?: string; isVideo?: boolean }>),
])
if (controller.signal.aborted) return
const enrichedTrackMetadata = new Map<number, Partial<Track>>()
const enrichedTracks: Track[] = []
for (const fid of subsetIds) {
const baseTrack = baseTracksByFileId.get(fid)
if (!baseTrack) continue
const tags = tagMap[fid] || []
const title = extractTitleFromTags(tags)
const isVideo = /\.(m3u8|mp4|webm|ogg|mov)$/i.test(baseTrack.url)
const artist = extractNamespaceValue(tags, 'artist')
const album = extractNamespaceValue(tags, 'album')
const mediaInfo = mediaInfoMap[fid] || {}
const metadata: Partial<Track> = {
title: title || '',
tags: tags.length ? tags : undefined,
isVideo: mediaInfo.isVideo ?? (isVideo || undefined),
artist: artist || undefined,
album: album || undefined,
mimeType: mediaInfo.mimeType,
mediaKind: mediaSection,
if (detail.phase === 'failed' && detail.error) {
setError(detail.error)
}
const enrichedTrack: Track = {
...baseTrack,
...metadata
void restoreCachedLibrary()
}
enrichedTrackMetadata.set(fid, metadata)
enrichedTracks.push(enrichedTrack)
addTrackToAlbumCache(enrichedTrack)
void restoreCachedLibrary()
if (typeof window !== 'undefined') {
window.addEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
}
cacheTracks(enrichedTracks)
if (enrichedTrackMetadata.size > 0) {
setResults((prev) => prev.map((track) => {
if (track.serverId !== s.id || track.fileId == null) return track
const metadata = enrichedTrackMetadata.get(track.fileId)
return metadata ? { ...track, ...metadata } : track
}))
}
setAlbums(buildNamespaceEntriesFromTracks(filterTracksForView(Object.values(trackCacheRef.current), view), 'album'))
}
} catch (e) {
// per-server metadata fetch failed — ignore but continue
}
} else if (view === 'albums' || view === 'artists') {
const ns = view === 'albums' ? 'album' : 'artist'
const tagQuery = buildSectionSearchTags(query, ns)
const cacheKey = `${s.id}|${mediaSection}|${ns}|${JSON.stringify(tagQuery)}`
let ids: number[] = []
if (cache[cacheKey]) ids = cache[cacheKey]
else {
ids = await client.searchFiles(tagQuery, SEARCH_FETCH_LIMIT, controller.signal)
cache[cacheKey] = ids
schedulePersistLibraryCache()
}
if (controller.signal.aborted) return
successfulSearchCount += 1
try { updateServer && updateServer(s.id, { lastTest: { ok: true, message: 'Search OK', status: null, searchOk: true, rangeSupported: s.lastTest?.rangeSupported ?? false, timestamp: Date.now() } }) } catch {}
if (!ids || ids.length === 0) return
const subsetIds = ids
try {
const tagMap = await client.getFilesTags(subsetIds, 6, controller.signal)
if (controller.signal.aborted) return
const localAlbums: Record<string, { count: number; thumbnail?: string }> = {}
const hydratedTracks: Track[] = []
const mediaInfoMap = mediaSection === 'application' ? await client.getFilesMediaInfo(subsetIds, 6, controller.signal) : {}
for (const fid of subsetIds) {
const tags = tagMap[fid] || []
const title = extractTitleFromTags(tags) || ''
const album = extractNamespaceValue(tags, 'album') || undefined
const artist = extractNamespaceValue(tags, 'artist') || undefined
const baseTrack: Track = {
id: Date.now() + hydratedTracks.length,
fileId: fid,
serverId: s.id,
serverName: s.name || s.host,
title,
artist,
album,
tags: tags.length ? tags : undefined,
url: client.getFileUrl(fid),
thumbnail: client.getThumbnailUrl(fid),
mimeType: mediaInfoMap[fid]?.mimeType,
isVideo: mediaInfoMap[fid]?.isVideo,
mediaKind: mediaSection,
}
hydratedTracks.push(baseTrack)
addTrackToAlbumCache(baseTrack)
for (const t of tags) {
const m = t.match(new RegExp(`^${ns}:(.*)$`, 'i'))
if (!m) continue
const name = m[1].replace(/_/g, ' ').trim()
if (!name) continue
if (!localAlbums[name]) localAlbums[name] = { count: 0, thumbnail: undefined }
localAlbums[name].count += 1
if (!localAlbums[name].thumbnail) {
const url = baseTrack.thumbnail
if (url) localAlbums[name].thumbnail = url
}
}
}
cacheTracks(hydratedTracks)
// merge into global album/artist map
const targetRef = view === 'albums' ? albumCacheRef.current : artistCacheRef.current
for (const [name, info] of Object.entries(localAlbums)) {
const existing = targetRef[name]
if (existing) {
// add server-specific info
existing.servers = existing.servers || []
existing.servers.push({ serverId: s.id, count: info.count, thumbnail: info.thumbnail })
existing.totalCount = (existing.totalCount || 0) + info.count
} else {
targetRef[name] = { name, servers: [{ serverId: s.id, count: info.count, thumbnail: info.thumbnail }], totalCount: info.count }
}
}
// update UI progressively
const arr = Object.values(view === 'albums' ? albumCacheRef.current : artistCacheRef.current)
arr.sort((a, b) => a.name.localeCompare(b.name))
if (view === 'albums') setAlbums(arr)
else setArtists(arr)
} catch (e) {
// ignore per-server failures
}
}
} catch (err: any) {
if (controller.signal.aborted) return
const msg = err?.message ?? String(err)
failedMessages.push(msg)
try { updateServer && updateServer(s.id, { lastTest: { ok: false, message: msg, status: null, searchOk: false, rangeSupported: false, timestamp: Date.now() } }) } catch {}
} finally {
finishOne()
}
})()
}
}
const t = setTimeout(performSearch, 200)
return () => {
clearTimeout(t)
if (searchAbortRef.current === controller) searchAbortRef.current = null
try { controller.abort() } catch {}
cancelled = true
if (typeof window !== 'undefined') {
window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
}
}, [
mediaSection,
query,
hasServers,
serverSearchSignature,
updateServer,
view
])
}
}, [hasServers, serverCacheKey])
useEffect(() => {
syncingSectionViewRef.current = true
@@ -885,7 +651,6 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
useEffect(() => {
return () => {
try { searchAbortRef.current?.abort() } catch {}
try { playMetadataAbortRef.current?.abort() } catch {}
try { detailsAbortRef.current?.abort() } catch {}
if (persistTimeoutRef.current && typeof window !== 'undefined') {
@@ -900,7 +665,6 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
const handlePlayNow = async (track: Track) => {
if (!track?.url) return
try { searchAbortRef.current?.abort() } catch {}
setLoading(false)
try { playMetadataAbortRef.current?.abort() } catch {}
@@ -1018,6 +782,11 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
await handlePlayNow(track)
}
const handleDownloadTrack = () => {
if (!detailsTrack?.url || detailsTrackDownloading) return
onDownloadTrack(detailsTrack, detailsData)
}
const getTrackInteractionProps = (track: Track) => ({
onClick: () => { void handleTrackActivate(track) },
onContextMenu: (event: React.MouseEvent) => {
@@ -1073,11 +842,11 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
.filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(artistFilter))
}, [artistFilter, mediaSection, results])
const currentTrackResults = useMemo(() => {
const sectionTracks = filterTracksForView(results, view)
if (!isTrackLikeView || queryWords.length === 0) return sectionTracks
return sectionTracks.filter((track) => matchesTrackSearch(track, queryWords))
}, [isTrackLikeView, queryWords, results, view])
const sectionTracks = useMemo(() => filterTracksForSection(results, mediaSection), [mediaSection, results])
const queryMatchedSectionTracks = useMemo(() => sectionTracks.filter((track) => matchesTrackSearch(track, query)), [query, sectionTracks])
const currentTrackResults = useMemo(() => filterTracksForView(queryMatchedSectionTracks, view), [queryMatchedSectionTracks, view])
const albums = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'album'), [queryMatchedSectionTracks])
const artists = useMemo(() => buildNamespaceEntriesFromTracks(queryMatchedSectionTracks, 'artist'), [queryMatchedSectionTracks])
const baseArtistGroups = useMemo(() => {
if (!hasArtistsView || albumFilter || artistFilter) return []
return buildArtistGroupsFromTracks(currentTrackResults)
@@ -1150,32 +919,17 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
)
const openAlbumEntry = (name: string) => {
if (filterTracksForSection(albumTracksRef.current[name] || [], mediaSection).length > 0) {
setArtistFilter(null)
onQueryChange('')
setAlbumFilter(name)
setView('tracks')
return
}
setView('tracks')
onQueryChange(buildNamespaceSearch('album', name, 'contains'))
}
const openArtistEntry = (name: string) => {
const cachedArtistTracks = filterTracksForSection(Object.values(trackCacheRef.current), mediaSection)
.filter((track) => normalizeNamespaceValue(track.artist) === normalizeNamespaceValue(name))
if (cachedArtistTracks.length > 0) {
setAlbumFilter(null)
onQueryChange('')
setArtistFilter(name)
setView('tracks')
return
}
setView('tracks')
onQueryChange(buildNamespaceSearch('artist', name, 'contains'))
}
const renderTrackTable = (tracks: Track[], options?: { showAlbum?: boolean; showFileIdFallback?: boolean }) => (
@@ -1550,6 +1304,9 @@ export default function Library({ mediaSection, onPlayNow, query, onQueryChange,
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDownloadTrack} startIcon={<DownloadIcon />} disabled={!detailsTrack?.url || detailsLoading || detailsTrackDownloading}>
{detailsTrackDownloading ? 'Downloading...' : 'Download'}
</Button>
<Button onClick={closeDetails}>Close</Button>
</DialogActions>
</Dialog>
+17 -94
View File
@@ -30,18 +30,9 @@ import AddIcon from '@mui/icons-material/Add'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import type { Server } from '../context/ServersContext'
import { useServers } from '../context/ServersContext'
import { HydrusClient, extractTitleFromTags } from '../api/hydrusClient'
import { buildLibraryCacheKey, getLibraryCacheStats, loadLibraryCache, pruneLibraryCache, saveLibraryCache } from '../libraryCache'
import type { MediaSection, ServerSyncSummary, Track } from '../types'
const SYNC_SECTION_LIMIT = 2000
import { buildLibraryCacheKey, getLibraryCacheStats, pruneLibraryCache } from '../libraryCache'
import { syncLibraryCache } from '../librarySync'
const DEFAULT_SERVER_FORM = { name: '', host: '', port: undefined, apiKey: '', ssl: false, forceApiKeyInQuery: false }
const SYNC_SECTIONS: Array<{ id: MediaSection; label: string; predicate: string }> = [
{ id: 'audio', label: 'Audio', predicate: 'system:filetype = audio' },
{ id: 'video', label: 'Video', predicate: 'system:filetype = video' },
{ id: 'image', label: 'Image', predicate: 'system:filetype = image' },
{ id: 'application', label: 'Applications', predicate: 'system:filetype = application' },
]
type SettingsPageProps = {
onClose?: () => void
@@ -71,18 +62,6 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
const syncNoticeTimeoutsRef = useRef<Record<string, number>>({})
const currentCacheKey = useMemo(() => buildLibraryCacheKey(servers), [servers])
const 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((a, b) => b.length - a.length)[0] || null
}
const buildTrackCacheKey = (serverId?: string, fileId?: number) => (serverId && fileId != null ? `${serverId}:${fileId}` : '')
useEffect(() => {
setEditing(null)
setForm(DEFAULT_SERVER_FORM)
@@ -195,82 +174,26 @@ export default function SettingsPage({ onClose, devOverlayEnabled, onDevOverlayE
setSyncingServerId(server.id)
try {
const client = new HydrusClient(server)
const cacheKey = buildLibraryCacheKey(servers)
const snapshot = await loadLibraryCache(cacheKey)
const mergedSearchCache = { ...(snapshot?.searchCache ?? {}) }
const mergedTrackMap: Record<string, Track> = {}
const previousServerTrackKeys = new Set<string>()
let localCounter = Date.now()
for (const track of snapshot?.tracks ?? []) {
const hydratedTrack: Track = { ...track, id: ++localCounter }
const key = buildTrackCacheKey(hydratedTrack.serverId, hydratedTrack.fileId)
if (!key) continue
if (hydratedTrack.serverId === server.id) {
previousServerTrackKeys.add(key)
continue
}
mergedTrackMap[key] = hydratedTrack
}
const counts: ServerSyncSummary['counts'] = {}
const currentServerTrackKeys = new Set<string>()
for (const section of SYNC_SECTIONS) {
const searchTags = [section.predicate]
const ids = await client.searchFiles(searchTags, SYNC_SECTION_LIMIT)
counts[section.id] = ids.length
mergedSearchCache[`${server.id}|${section.id}|tracks|${JSON.stringify(searchTags)}`] = ids
if (ids.length === 0) continue
const tagMap = await client.getFilesTags(ids, 8)
const mediaInfoMap = section.id === 'application' ? await client.getFilesMediaInfo(ids, 6) : {}
for (const fileId of ids) {
const tags = tagMap[fileId] || []
const key = buildTrackCacheKey(server.id, fileId)
if (!key) continue
currentServerTrackKeys.add(key)
mergedTrackMap[key] = {
id: ++localCounter,
fileId,
serverId: server.id,
serverName: server.name || server.host,
title: extractTitleFromTags(tags) || '',
artist: extractNamespaceValue(tags, 'artist') || undefined,
album: extractNamespaceValue(tags, 'album') || undefined,
tags: tags.length ? tags : undefined,
url: client.getFileUrl(fileId),
thumbnail: client.getThumbnailUrl(fileId),
mimeType: mediaInfoMap[fileId]?.mimeType,
isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined),
mediaKind: section.id,
}
}
}
await saveLibraryCache(cacheKey, Object.values(mergedTrackMap), mergedSearchCache)
const total = Object.values(counts).reduce((sum, value) => sum + (value || 0), 0)
const addedCount = Array.from(currentServerTrackKeys).filter((key) => !previousServerTrackKeys.has(key)).length
const removedCount = Array.from(previousServerTrackKeys).filter((key) => !currentServerTrackKeys.has(key)).length
const summary: ServerSyncSummary = {
updatedAt: Date.now(),
total,
counts,
}
const result = await syncLibraryCache(servers, { targetServerIds: [server.id] })
const summary = result.summaries[server.id]
if (!summary) throw new Error('Sync did not return a server summary')
updateServer(server.id, { syncSummary: summary })
if (summary.message) {
setLastTest(summary.message)
await refreshCacheStats(result.cacheKey)
return
}
const addedCount = result.addedCounts[server.id] ?? 0
const removedCount = result.removedCounts[server.id] ?? 0
const completionMessage = addedCount === 0 && removedCount === 0
? `Sync complete. No file changes. ${total} cached items.`
? `Sync complete. No file changes. ${summary.total} cached items.`
: `Sync complete. Added ${addedCount} files, removed ${removedCount} files.`
setLastTest(`Sync complete. ${total} cached items.`)
setLastTest(`Sync complete. ${summary.total} cached items.`)
setSyncCompletionNotices((current) => ({ ...current, [server.id]: completionMessage }))
await refreshCacheStats(cacheKey)
await refreshCacheStats(result.cacheKey)
if (syncNoticeTimeoutsRef.current[server.id]) {
window.clearTimeout(syncNoticeTimeoutsRef.current[server.id])
}
+5 -2
View File
@@ -3,7 +3,10 @@ import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['@ffmpeg/ffmpeg', '@ffmpeg/util', '@ffmpeg/core'],
},
server: {
port: 5173
}
port: 5173,
},
})