diff --git a/package-lock.json b/package-lock.json index e957392..0fad13a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e97edda..b6d464d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/public/userscripts/api-media-player-open-in-mpv.user.js b/public/userscripts/api-media-player-open-in-mpv.user.js index a3233a6..c604035 100644 --- a/public/userscripts/api-media-player-open-in-mpv.user.js +++ b/public/userscripts/api-media-player-open-in-mpv.user.js @@ -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) } diff --git a/src/App.tsx b/src/App.tsx index 9ab548b..fc4aced 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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('audio') + const [activePage, setActivePage] = useState('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>({}) const mimeRequestCacheRef = useRef>>({}) const playRequestAbortRef = useRef(null) - const lastBrowsePageRef = useRef(activePage === 'settings' ? 'audio' : activePage) + const downloadAbortControllersRef = useRef>({}) + const downloadUrlsRef = useRef>({}) + const lastBrowsePageRef = useRef(activePage === 'settings' || activePage === 'downloads' ? 'audio' : activePage) + const [downloads, setDownloads] = useState([]) 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) => { + 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 ( + @@ -306,7 +812,7 @@ function App() { onToggleSidebar={toggleSidebar} searchQuery={libraryQuery} onSearchQueryChange={setLibraryQuery} - searchDisabled={activePage === 'settings'} + searchDisabled={activePage === 'settings' || activePage === 'downloads'} /> @@ -331,15 +837,27 @@ function App() { /> ) - : ( + : activePage === 'downloads' + ? ( + + ) + : ( - )} + )} @@ -348,6 +866,16 @@ function App() { ) : null} + + {activePage !== 'downloads' ? ( + + ) : null} diff --git a/src/audioMetadata.ts b/src/audioMetadata.ts new file mode 100644 index 0000000..8b84e67 --- /dev/null +++ b/src/audioMetadata.ts @@ -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 | 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 } \ No newline at end of file diff --git a/src/components/DevErrorPanel.tsx b/src/components/DevErrorPanel.tsx index eff527f..731b54d 100644 --- a/src/components/DevErrorPanel.tsx +++ b/src/components/DevErrorPanel.tsx @@ -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(() => 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 ( + + { + setDismissed(false) + setOpen(true) + }} + /> + + ) + } + return ( - + @@ -98,7 +123,7 @@ export default function DevErrorPanel() { - setOpen((v) => !v)} aria-label="toggle" sx={{ width: 32, height: 32 }}> + { setDismissed(true); setOpen(false) }} aria-label="dismiss dev log panel" sx={{ width: 32, height: 32 }}> diff --git a/src/components/DownloadsOverlay.tsx b/src/components/DownloadsOverlay.tsx new file mode 100644 index 0000000..6ab97bf --- /dev/null +++ b/src/components/DownloadsOverlay.tsx @@ -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 ( + theme.zIndex.modal - 1, + overflow: 'hidden', + }} + > + + + Downloads + + {activeCount > 0 && } + {finishedCount > 0 && } + {finishedCount > 0 && ( + + + + )} + setExpanded((current) => !current)} aria-label={expanded ? 'collapse downloads' : 'expand downloads'}> + {expanded ? : } + + + + {expanded && ( + + {downloads.map((download) => { + const progressPercent = getProgressPercent(download) + const progressText = buildProgressText(download) + + return ( + + + + + {download.title} + + {download.fileName && download.status !== 'downloading' && ( + + {download.fileName} + + )} + + {download.status === 'downloading' + ? ( + onCancel(download.id)} aria-label="cancel download"> + + + ) + : ( + + {download.status === 'completed' && download.saveHref && ( + onSaveAgain(download.id)} aria-label="save download again"> + + + )} + onDismiss(download.id)} aria-label="dismiss download"> + + + + )} + + + {download.status === 'downloading' && ( + + )} + + + {download.status === 'downloading' && progressPercent != null + ? `Downloading ${progressPercent.toFixed(0)}% • ${progressText}` + : progressText} + + + ) + })} + + )} + + ) +} \ No newline at end of file diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0029efb..e15deae 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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: }, { id: 'image', label: 'Image', icon: }, { id: 'application', label: 'Applications', icon: }, + { id: 'downloads', label: 'Downloads', icon: }, ] export default function Sidebar({ diff --git a/src/downloadStore.ts b/src/downloadStore.ts new file mode 100644 index 0000000..1d22918 --- /dev/null +++ b/src/downloadStore.ts @@ -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((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION) + + request.onupgradeneeded = () => { + const database = request.result + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME, { keyPath: 'id' }) + } + } + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('Failed to open download database')) + }) +} + +function withStore(mode: IDBTransactionMode, handler: (store: IDBObjectStore) => IDBRequest) { + return new Promise((resolve, reject) => { + openDownloadDatabase() + .then((database) => { + const transaction = database.transaction(STORE_NAME, mode) + const store = transaction.objectStore(STORE_NAME) + const request = handler(store) + + request.onsuccess = () => resolve(request.result) + request.onerror = () => reject(request.error ?? new Error('IndexedDB request failed')) + + transaction.oncomplete = () => database.close() + transaction.onerror = () => reject(transaction.error ?? new Error('IndexedDB transaction failed')) + transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB transaction aborted')) + }) + .catch(reject) + }) +} + +export async function listStoredDownloads(): Promise { + if (typeof indexedDB === 'undefined') return [] + const records = await withStore('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 } \ No newline at end of file diff --git a/src/librarySync.ts b/src/librarySync.ts new file mode 100644 index 0000000..72ae6ce --- /dev/null +++ b/src/librarySync.ts @@ -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; 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 + addedCounts: Record + removedCounts: Record +} + +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 { + 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 = {} + const snapshotTracksByServer: Record = {} + 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 = {} + const addedCounts: Record = {} + const removedCounts: Record = {} + + 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() + 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), + ]) + + 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 + } +} \ No newline at end of file diff --git a/src/pages/DownloadsPage.tsx b/src/pages/DownloadsPage.tsx new file mode 100644 index 0000000..5bd8092 --- /dev/null +++ b/src/pages/DownloadsPage.tsx @@ -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 ( + + + + + {download.title} + + {download.fileName && ( + + {download.fileName} + + )} + + + + + {download.status === 'downloading' && ( + + )} + + + {download.status === 'downloading' && progressPercent != null + ? `Downloading ${progressPercent.toFixed(0)}% • ${renderProgressText(download)}` + : renderProgressText(download)} + + + + {download.status === 'downloading' ? ( + + ) : null} + {download.status === 'completed' && download.saveHref ? ( + + ) : null} + + + + ) +} + +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 ( + + + Downloads + + + Finished downloads stay available across refreshes until you remove them from this page. + + + + + + {finishedDownloads.length > 0 && ( + + )} + + + + + + Active + + {activeDownloads.length > 0 ? ( + + {activeDownloads.map((download) => ( + + ))} + + ) : ( + + + No active downloads. + + + )} + + + + + + + Stored + + {finishedDownloads.length > 0 ? ( + + {finishedDownloads.map((download) => ( + + ))} + + ) : ( + + + Completed and failed downloads will appear here for later download or cleanup. + + + )} + + + + ) +} \ No newline at end of file diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index 215cddd..9561eb3 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -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 + 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 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>, [initialUiPreferences]) const [results, setResults] = useState([]) - const [albums, setAlbums] = useState([]) - const [artists, setArtists] = useState([]) const [sectionViews, setSectionViews] = useState>>(initialSectionViews) const [view, setView] = useState(() => getStoredSectionView(mediaSection, initialSectionViews)) const [sortBy, setSortBy] = useState(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>({}) const trackCacheRef = React.useRef>({}) - const albumCacheRef = React.useRef>({}) - const artistCacheRef = React.useRef>({}) const albumTracksRef = React.useRef>({}) - const searchAbortRef = useRef(null) const playMetadataAbortRef = useRef(null) const persistTimeoutRef = useRef(null) const detailsAbortRef = useRef(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() - 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), - ]) - if (controller.signal.aborted) return - - const enrichedTrackMetadata = new Map>() - 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 = { - 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 = {} - 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, )} + diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 6e70b02..3165093 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -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>({}) 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 = {} - const previousServerTrackKeys = new Set() - - 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() - - 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]) } diff --git a/vite.config.ts b/vite.config.mts similarity index 56% rename from vite.config.ts rename to vite.config.mts index 6ddd140..2e29132 100644 --- a/vite.config.ts +++ b/vite.config.mts @@ -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, + }, +}) \ No newline at end of file