Files
api-HydrusNetwork/src/librarySync.ts
T

189 lines
7.2 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),
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
}
}