updated audio thumbnail and library sync

This commit is contained in:
2026-04-22 18:23:16 -07:00
parent e928181855
commit 199914a9b2
4 changed files with 118 additions and 21 deletions
+63 -12
View File
@@ -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<number, HydrusMediaInfo> = {}
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<number, any>()
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] = {}
}
}
})
+2 -1
View File
@@ -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<number, { mimeType?: string; isVideo?: boolean }>),
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,
+52 -8
View File
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 160" role="img" aria-label="${label}"><defs><linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#121826"/><stop offset="100%" stop-color="#1f2937"/></linearGradient></defs><rect width="160" height="160" rx="24" fill="url(#bg)"/><circle cx="80" cy="66" r="28" fill="${accent}" fill-opacity="0.18" stroke="${accent}" stroke-width="3"/><path d="${iconPath}" fill="${accent}"/><text x="80" y="128" text-anchor="middle" fill="#d1d5db" font-family="Segoe UI, Arial, sans-serif" font-size="15" font-weight="700" letter-spacing="1.2">${label}</text></svg>` )}`
}
const FALLBACK_ARTWORK: Record<TrackArtworkKind, string> = {
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<void>
@@ -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<Record<MediaSection, string>>, [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<HTMLImageElement>) => {
event.currentTarget.src = NO_IMAGE_DATA_URL
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>, 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 (
<Box key={track?.id ?? idx} sx={{ cursor: track?.url ? 'pointer' : 'default' }} {...(track?.url ? getTrackInteractionProps(track) : {})}>
<Box className="card-media">
<Box component="img" src={track?.thumbnail || NO_IMAGE_DATA_URL} onError={handleImageError} alt={track?.title || '...'} loading="lazy" />
<Box component="img" src={getTrackArtworkSrc(track)} onError={(event: React.SyntheticEvent<HTMLImageElement>) => handleImageError(event, track)} alt={track?.title || '...'} loading="lazy" />
<Box className="card-overlay">
<Box className="play-button" title={isVideoTrack ? 'Play video' : 'Play'}>
@@ -1119,7 +1163,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
{tracks.map((track) => (
<Paper key={`${track.serverId || 'local'}:${track.fileId || track.id}`} elevation={0} sx={{ border: '1px solid rgba(255,255,255,0.08)', bgcolor: 'background.paper', backgroundImage: 'none', borderRadius: 2, px: 1.25, py: 1.1, cursor: 'pointer' }} {...getTrackInteractionProps(track)}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, minWidth: 0 }}>
<Box component="img" src={track.thumbnail || NO_IMAGE_DATA_URL} onError={handleImageError} alt={track.title || 'track'} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
<Box component="img" src={getTrackArtworkSrc(track)} onError={(event: React.SyntheticEvent<HTMLImageElement>) => handleImageError(event, track)} alt={track.title || 'track'} sx={{ width: 48, height: 48, borderRadius: 1.25, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>{getDisplayTitle(track)}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }} noWrap>
@@ -1150,7 +1194,7 @@ export default function Library({ mediaSection, onPlayNow, onDownloadTrack, isTr
<TableRow key={`${track.serverId || 'local'}:${track.fileId || track.id}`} hover sx={{ cursor: 'pointer', '& .MuiTableCell-root': { borderColor: 'rgba(255,255,255,0.08)' } }} {...getTrackInteractionProps(track)}>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, minWidth: 0 }}>
<Box component="img" src={track.thumbnail || NO_IMAGE_DATA_URL} onError={handleImageError} alt={track.title || 'track'} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
<Box component="img" src={getTrackArtworkSrc(track)} onError={(event: React.SyntheticEvent<HTMLImageElement>) => handleImageError(event, track)} alt={track.title || 'track'} sx={{ width: 44, height: 44, borderRadius: 1, objectFit: 'cover', flexShrink: 0, bgcolor: '#0b0b0b' }} loading="lazy" />
<Box sx={{ minWidth: 0 }}>
<Typography variant="body2" noWrap>{getDisplayTitle(track)}</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
+1
View File
@@ -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)