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), 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), hasThumbnail: mediaInfoMap[fileId]?.hasThumbnail, 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 } }