update dependencies and add downloads page, various fixes
This commit is contained in:
Generated
+49
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+539
-11
@@ -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,15 +837,27 @@ 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}
|
||||
/>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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', 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', left: { xs: 12, sm: 'auto' }, right: 12, bottom: 12, zIndex: 9999 }}>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+152
-395
@@ -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)
|
||||
}
|
||||
|
||||
if (namespace) tags.push(buildNamespaceSearch(namespace, trimmedQuery))
|
||||
|
||||
return tags
|
||||
function normalizeSearchText(value?: string | null) {
|
||||
return (value || '').replace(/_/g, ' ').trim().toLocaleLowerCase()
|
||||
}
|
||||
|
||||
function matchesTrackSearch(track: Track, words: string[]) {
|
||||
if (words.length === 0) return true
|
||||
function normalizeSearchTerm(value: string) {
|
||||
let normalized = value.trim()
|
||||
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
||||
normalized = normalized.slice(1, -1)
|
||||
}
|
||||
|
||||
const searchableValues = [
|
||||
getTrackDisplayTitle(track),
|
||||
...(track.tags || []),
|
||||
]
|
||||
normalized = normalized
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\')
|
||||
.replace(/^\*+|\*+$/g, '')
|
||||
|
||||
const searchableWords = new Set(searchableValues.flatMap((value) => tokenizeSearchWords(value)))
|
||||
return words.every((word) => searchableWords.has(word))
|
||||
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 || []),
|
||||
]
|
||||
|
||||
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[] = []
|
||||
const handleCacheSyncEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ cacheKey?: string; phase?: string; error?: string }>).detail
|
||||
if (!detail || detail.cacheKey !== serverCacheKey) return
|
||||
|
||||
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 (detail.phase === 'started') {
|
||||
setLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return
|
||||
successfulSearchCount += 1
|
||||
if (detail.phase === 'failed' && detail.error) {
|
||||
setError(detail.error)
|
||||
}
|
||||
|
||||
try { updateServer && updateServer(s.id, { lastTest: { ok: true, message: 'Search OK', status: null, searchOk: true, rangeSupported: s.lastTest?.rangeSupported ?? false, timestamp: Date.now() } }) } catch {}
|
||||
void restoreCachedLibrary()
|
||||
}
|
||||
|
||||
if (!ids || ids.length === 0) return
|
||||
void restoreCachedLibrary()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
const enrichedTrack: Track = {
|
||||
...baseTrack,
|
||||
...metadata
|
||||
}
|
||||
|
||||
enrichedTrackMetadata.set(fid, metadata)
|
||||
enrichedTracks.push(enrichedTrack)
|
||||
addTrackToAlbumCache(enrichedTrack)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener(LIBRARY_CACHE_SYNC_EVENT, handleCacheSyncEvent as EventListener)
|
||||
}
|
||||
}
|
||||
|
||||
const t = setTimeout(performSearch, 200)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
if (searchAbortRef.current === controller) searchAbortRef.current = null
|
||||
try { controller.abort() } catch {}
|
||||
}
|
||||
}, [
|
||||
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
|
||||
}
|
||||
|
||||
setArtistFilter(null)
|
||||
onQueryChange('')
|
||||
setAlbumFilter(name)
|
||||
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
|
||||
}
|
||||
|
||||
setAlbumFilter(null)
|
||||
onQueryChange('')
|
||||
setArtistFilter(name)
|
||||
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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user