188 lines
7.3 KiB
TypeScript
188 lines
7.3 KiB
TypeScript
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<MediaSection, 'all'>; 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<string, ServerSyncSummary>
|
||
|
|
addedCounts: Record<string, number>
|
||
|
|
removedCounts: Record<string, number>
|
||
|
|
}
|
||
|
|
|
||
|
|
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<LibraryCacheSyncResult> {
|
||
|
|
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<string, Track> = {}
|
||
|
|
const snapshotTracksByServer: Record<string, Track[]> = {}
|
||
|
|
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<string, ServerSyncSummary> = {}
|
||
|
|
const addedCounts: Record<string, number> = {}
|
||
|
|
const removedCounts: Record<string, number> = {}
|
||
|
|
|
||
|
|
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<string>()
|
||
|
|
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<number, { mimeType?: string; isVideo?: boolean }>),
|
||
|
|
])
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|
||
|
|
}
|