updated audio thumbnail and library sync
This commit is contained in:
+63
-12
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user