diff --git a/src/api/hydrusClient.ts b/src/api/hydrusClient.ts index 72e7cc3..69bae06 100644 --- a/src/api/hydrusClient.ts +++ b/src/api/hydrusClient.ts @@ -19,6 +19,7 @@ export type ConnectivityResult = { export type HydrusMediaInfo = { mimeType?: string isVideo?: boolean + hasThumbnail?: boolean } export type HydrusFileDetails = HydrusMediaInfo & { @@ -142,6 +143,33 @@ export class HydrusClient { return res.json().catch(() => null) } + private async getFilesMetadataPayload(fileIds: number[], signal?: AbortSignal) { + if (!fileIds || fileIds.length === 0) return [] + + const url = this.buildApiUrl('/get_files/file_metadata', { + file_ids: JSON.stringify(fileIds), + include_services_object: 'false', + }, this.cfg.forceApiKeyInQuery ?? false) + const headers = this.getHeaders(!(this.cfg.forceApiKeyInQuery ?? false)) + const res = await this.fetchWithAuthRetry(url, { method: 'GET', headers, signal }) + + if (res.status === 404) { + console.warn('[HydrusClient] getFilesMetadata 404', { url, status: res.status, fileCount: fileIds.length }) + return [] + } + + if (!res.ok) { + console.warn('[HydrusClient] getFilesMetadata Response Error', { status: res.status, statusText: res.statusText, fileCount: fileIds.length }) + return [] + } + + const data = await res.json().catch(() => null) + if (Array.isArray(data?.metadata)) return data.metadata + if (Array.isArray(data?.file_metadata)) return data.file_metadata + if (Array.isArray(data)) return data + return [] + } + private getFileMetadataEntry(data: any, fileId: number) { if (!data || typeof data !== 'object') return null @@ -198,6 +226,7 @@ export class HydrusClient { let height = 0 let frameCount = 0 let hasDuration = false + let hasThumbnail = false const mimeCandidates: string[] = [] const visit = (value: any, keyHint = '') => { @@ -215,6 +244,7 @@ export class HydrusClient { const lowerKey = keyHint.toLowerCase() if (lowerKey === 'width') width = Math.max(width, value) else if (lowerKey === 'height') height = Math.max(height, value) + else if ((lowerKey === 'thumbnail_width' || lowerKey === 'thumbnail_height') && value > 0) hasThumbnail = true else if (lowerKey.includes('frame')) frameCount = Math.max(frameCount, value) else if (lowerKey.includes('duration')) hasDuration = hasDuration || value > 0 return @@ -240,23 +270,25 @@ export class HydrusClient { visit(metadata) + const withThumbnail = (info: HydrusMediaInfo): HydrusMediaInfo => hasThumbnail ? { ...info, hasThumbnail: true } : info + for (const candidate of mimeCandidates) { const mimeType = this.normalizeMimeType(candidate) if (mimeType) { - return { + return withThumbnail({ mimeType, isVideo: mimeType.startsWith('video/') || mimeType === 'application/vnd.apple.mpegurl' - } + }) } const lower = candidate.toLowerCase() - if (lower.includes('video')) return { isVideo: true } - if (lower.includes('audio')) return { isVideo: false } + if (lower.includes('video')) return withThumbnail({ isVideo: true }) + if (lower.includes('audio')) return withThumbnail({ isVideo: false }) } - if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return { isVideo: true } - if (hasDuration) return { isVideo: false } + if ((width > 0 || height > 0) && (frameCount > 1 || hasDuration)) return withThumbnail({ isVideo: true }) + if (hasDuration) return withThumbnail({ isVideo: false }) - return {} + return withThumbnail({}) } private extractTagsFromMetadata(data: any, fileId: number): string[] { @@ -657,20 +689,39 @@ export class HydrusClient { const out: Record = {} if (!fileIds || fileIds.length === 0) return out + const uniqueFileIds = Array.from(new Set(fileIds.filter((fileId) => Number.isFinite(fileId)))) + const batchSize = 128 + const batches: number[][] = [] + for (let index = 0; index < uniqueFileIds.length; index += batchSize) { + batches.push(uniqueFileIds.slice(index, index + batchSize)) + } + let idx = 0 - const workers = new Array(Math.min(concurrency, fileIds.length)).fill(null).map(async () => { + const workers = new Array(Math.min(concurrency, batches.length)).fill(null).map(async () => { while (true) { if (signal?.aborted) throw createAbortError() const i = idx - if (i >= fileIds.length) break + if (i >= batches.length) break idx++ - const fid = fileIds[i] + + const batch = batches[i] try { - out[fid] = await this.getFileMediaInfo(fid, signal) + const entries = await this.getFilesMetadataPayload(batch, signal) + const entryMap = new Map() + + for (const entry of entries) { + const entryFileId = Number(entry?.file_id) + if (Number.isFinite(entryFileId)) entryMap.set(entryFileId, entry) + } + + for (const fid of batch) { + const entry = entryMap.get(fid) + out[fid] = entry ? this.extractMediaInfoFromMetadata(entry, fid) : {} + } } catch (error: any) { if (error?.name === 'AbortError') throw error - out[fid] = {} + for (const fid of batch) out[fid] = {} } } }) diff --git a/src/librarySync.ts b/src/librarySync.ts index 72ae6ce..1d4a962 100644 --- a/src/librarySync.ts +++ b/src/librarySync.ts @@ -118,7 +118,7 @@ export async function syncLibraryCache(servers: LibrarySyncServer[], options: { const [tagMap, mediaInfoMap] = await Promise.all([ client.getFilesTags(ids, 8), - section.id === 'application' ? client.getFilesMediaInfo(ids, 6) : Promise.resolve({} as Record), + client.getFilesMediaInfo(ids, 6), ]) for (const fileId of ids) { @@ -138,6 +138,7 @@ export async function syncLibraryCache(servers: LibrarySyncServer[], options: { tags: tags.length ? tags : undefined, url: client.getFileUrl(fileId), thumbnail: client.getThumbnailUrl(fileId), + hasThumbnail: mediaInfoMap[fileId]?.hasThumbnail, mimeType: mediaInfoMap[fileId]?.mimeType, isVideo: mediaInfoMap[fileId]?.isVideo ?? (section.id === 'video' ? true : undefined), mediaKind: section.id, diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx index e51d824..276c6a2 100644 --- a/src/pages/Library.tsx +++ b/src/pages/Library.tsx @@ -9,12 +9,25 @@ import { buildLibraryCacheKey, loadLibraryCache, saveLibraryCache } from '../lib 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 LONG_PRESS_DELAY_MS = 420 const LOCAL_SEARCH_TOKEN_PATTERN = /(?:[^\s"]+:"(?:[^"\\]|\\.)*"|"(?:[^"\\]|\\.)*"|\S+)/g const SEARCH_NAMESPACE_PATTERN = /^([a-z0-9_+-]+):(.*)$/i +type TrackArtworkKind = 'audio' | 'video' | 'image' | 'application' | 'file' + +function createFallbackArtworkDataUrl(label: string, accent: string, iconPath: string) { + return `data:image/svg+xml;utf8,${encodeURIComponent(`${label}` )}` +} + +const FALLBACK_ARTWORK: Record = { + audio: createFallbackArtworkDataUrl('AUDIO', '#22c55e', 'M90 44v41.85A12 12 0 1 0 98 97V60h18V44Z'), + video: createFallbackArtworkDataUrl('VIDEO', '#f97316', 'M64 48v36l32-18-32-18Z'), + image: createFallbackArtworkDataUrl('IMAGE', '#38bdf8', 'M50 86h60L94 64 78 83 68 74Zm18-28a7 7 0 1 0 0-.01Z'), + application: createFallbackArtworkDataUrl('FILE', '#a78bfa', 'M60 40h30l18 18v52H60Zm28 4v20h20'), + file: createFallbackArtworkDataUrl('MEDIA', '#94a3b8', 'M60 40h30l18 18v52H60Zm28 4v20h20'), +} + type Props = { mediaSection: MediaSection onPlayNow: (track: Track) => void | Promise @@ -240,6 +253,35 @@ function getTrackKindLabel(track: Track, fallbackLabel: string) { return fallbackLabel } +function getTrackArtworkKind(track?: Track): TrackArtworkKind { + if (!track) return 'file' + if (track.mediaKind && track.mediaKind !== 'all') { + if (track.mediaKind === 'audio' || track.mediaKind === 'video' || track.mediaKind === 'image') return track.mediaKind + if (track.mediaKind === 'application') return 'application' + } + + const mimeType = (track.mimeType || '').toLowerCase() + if (track.isVideo || mimeType.startsWith('video/')) return 'video' + if (mimeType.startsWith('audio/')) return 'audio' + if (mimeType.startsWith('image/')) return 'image' + if (mimeType.startsWith('text/') || mimeType.startsWith('application/')) return 'application' + return 'file' +} + +function getTrackFallbackArtwork(track?: Track) { + return FALLBACK_ARTWORK[getTrackArtworkKind(track)] +} + +function getTrackArtworkSrc(track?: Track) { + if (track?.thumbnail && track.hasThumbnail !== false) return track.thumbnail + return getTrackFallbackArtwork(track) +} + +function getEntryThumbnail(track?: Track) { + if (!track?.thumbnail) return undefined + return track.hasThumbnail === false ? undefined : track.thumbnail +} + export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTrackDownloading, primaryTapAction, query, onQueryChange, displayModePreference }: Props) { const initialUiPreferences = useMemo(() => loadUiPreferences(), []) const initialSectionViews = useMemo(() => initialUiPreferences.librarySectionViews as Partial>, [initialUiPreferences]) @@ -619,12 +661,12 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr const serverId = track.serverId || 'unknown' let serverEntry = entry.servers.find((server) => server.serverId === serverId) if (!serverEntry) { - serverEntry = { serverId, count: 0, thumbnail: track.thumbnail } + serverEntry = { serverId, count: 0, thumbnail: getEntryThumbnail(track) } entry.servers.push(serverEntry) } serverEntry.count += 1 - if (!serverEntry.thumbnail && track.thumbnail) serverEntry.thumbnail = track.thumbnail + if (!serverEntry.thumbnail) serverEntry.thumbnail = getEntryThumbnail(track) } return Object.values(entries).sort((a, b) => a.name.localeCompare(b.name)) @@ -967,8 +1009,10 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr onTouchMove: clearLongPressTimer, }) - const handleImageError = (event: React.SyntheticEvent) => { - event.currentTarget.src = NO_IMAGE_DATA_URL + const handleImageError = (event: React.SyntheticEvent, track?: Track) => { + const fallbackSrc = getTrackFallbackArtwork(track) + if (event.currentTarget.src === fallbackSrc) return + event.currentTarget.src = fallbackSrc } const getDisplayTitle = (track?: Track) => (track ? getTrackDisplayTitle(track) : '') @@ -1038,7 +1082,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr return ( - + ) => handleImageError(event, track)} alt={track?.title || '...'} loading="lazy" /> @@ -1119,7 +1163,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr {tracks.map((track) => ( - + ) => handleImageError(event, track)} alt={track.title || 'track'} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" /> {getDisplayTitle(track)} @@ -1150,7 +1194,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr - + ) => handleImageError(event, track)} alt={track.title || 'track'} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" /> {getDisplayTitle(track)} diff --git a/src/types.ts b/src/types.ts index f535df1..8c0b03c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,7 @@ export type Track = { album?: string url: string thumbnail?: string + hasThumbnail?: boolean duration?: number tags?: string[] // Optional MIME/type hints for rendering (video vs audio)