import { imageDecryptService } from './imageDecryptService' type PreloadImagePayload = { sessionId?: string imageMd5?: string imageDatName?: string createTime?: number } type PreloadOptions = { allowDecrypt?: boolean allowCacheIndex?: boolean allowFilesystemScan?: boolean emitResolved?: boolean scope?: string priority?: 'high' | 'normal' | 'low' } type PreloadEnqueueResult = { success: true requested: number accepted: number mergedQueued: number skippedActive: number skippedPending: number ignoredCanceled: number rejectedCapacity: number deferred: number handledIdentities: string[] acceptedIdentities: string[] mergedQueuedIdentities: string[] skippedActiveIdentities: string[] skippedPendingIdentities: string[] rejectedIdentities: string[] deferredIdentities: string[] } type PreloadTask = PreloadImagePayload & { key: string identity: string allowDecrypt: boolean allowCacheIndex: boolean allowFilesystemScan: boolean scopes: Set emitResolvedScopes: Set priority: 'high' | 'normal' | 'low' } type PreloadHighWaterStats = { queued: number pending: number queuedCache: number queuedDecrypt: number queuedHigh: number queuedNormal: number queuedLow: number activeCache: number activeDecrypt: number activeIdentities: number } const isLikelyThumbnailCachePath = (localPath?: string): boolean => { const normalized = String(localPath || '').trim().toLowerCase() if (!normalized) return false return ( normalized.includes('_thumb') || normalized.includes('.thumb') || normalized.includes('_t.') || normalized.includes('.t.') ) } export class ImagePreloadService { private queue: PreloadTask[] = [] private pending = new Set() private queuedCacheByIdentity = new Map() private queuedDecryptByIdentity = new Map() private activeByIdentity = new Map() private canceledScopes = new Map() private totals = { accepted: 0, mergedQueued: 0, skippedActive: 0, ignoredCanceled: 0, droppedQueued: 0, canceledQueued: 0, canceledActive: 0, promotedActive: 0, rejectedCapacity: 0, deferredLowPriority: 0, lowPriorityIdleDeferrals: 0, skippedPending: 0, forcedThumbnailRefreshes: 0, forcedThumbnailRefreshFailures: 0, lowPriorityRejected: 0, activeCacheSnapshots: 0, activeCacheSnapshotSkipped: 0, activeCacheSnapshotCanceled: 0, started: 0, completed: 0 } private activeCache = 0 private activeDecrypt = 0 private queuedPriorityCounts: Record = { high: 0, normal: 0, low: 0 } private activeCacheSnapshotPending = new Set() private activeCacheSnapshotLastAt = new Map() private lastForegroundActivityAt = Date.now() private lowPriorityWakeTimer: ReturnType | null = null private highWater: PreloadHighWaterStats = { queued: 0, pending: 0, queuedCache: 0, queuedDecrypt: 0, queuedHigh: 0, queuedNormal: 0, queuedLow: 0, activeCache: 0, activeDecrypt: 0, activeIdentities: 0 } private readonly maxCacheConcurrent = 4 private readonly maxDecryptConcurrent = 2 private readonly maxQueueSize = 320 private readonly maxLowPriorityQueueSize = 160 private readonly lowPrioritySoftQueueSize = 240 private readonly canceledScopeTtlMs = 2 * 60 * 1000 private readonly activeCacheSnapshotCooldownMs = 1800 private readonly lowPriorityIdleDelayMs = 1500 enqueue(payloads: PreloadImagePayload[], options?: PreloadOptions): PreloadEnqueueResult { const result = this.createEnqueueResult(Array.isArray(payloads) ? payloads.length : 0) if (!Array.isArray(payloads) || payloads.length === 0) return result this.pruneCanceledScopes() const allowDecrypt = options?.allowDecrypt !== false const allowCacheIndex = options?.allowCacheIndex !== false const allowFilesystemScan = options?.allowFilesystemScan !== false const emitResolved = options?.emitResolved === true const scope = String(options?.scope || 'global').trim() || 'global' const priority = options?.priority || (allowDecrypt ? 'low' : 'normal') this.noteForegroundActivity(allowDecrypt, priority) if (this.isScopeCanceled(scope)) { this.totals.ignoredCanceled += payloads.length result.ignoredCanceled += payloads.length return result } for (const payload of payloads) { const identity = this.getTaskIdentity(payload) if (!identity) continue const activeTask = this.activeByIdentity.get(identity) if (activeTask && (!allowDecrypt || activeTask.allowDecrypt)) { activeTask.scopes.add(scope) activeTask.allowCacheIndex = activeTask.allowCacheIndex || allowCacheIndex activeTask.allowFilesystemScan = activeTask.allowFilesystemScan || allowFilesystemScan if (this.priorityScore(priority) < this.priorityScore(activeTask.priority)) { activeTask.priority = priority this.totals.promotedActive += 1 } if (emitResolved) { activeTask.emitResolvedScopes.add(scope) } if (!allowDecrypt && activeTask.allowDecrypt && emitResolved) { this.emitActiveCacheSnapshot(payload, identity, allowCacheIndex, scope) } this.totals.skippedActive += 1 result.skippedActive += 1 result.handledIdentities.push(identity) result.skippedActiveIdentities.push(identity) continue } const mode = allowDecrypt ? 'decrypt' : 'cache' const key = `${mode}|${identity}` const queuedMap = this.getQueuedMap(allowDecrypt) const queuedTask = queuedMap.get(identity) if (queuedTask) { this.totals.mergedQueued += 1 result.mergedQueued += 1 result.handledIdentities.push(identity) result.mergedQueuedIdentities.push(identity) this.mergeQueuedTaskPayload(queuedTask, payload) queuedTask.allowCacheIndex = queuedTask.allowCacheIndex || allowCacheIndex queuedTask.allowFilesystemScan = queuedTask.allowFilesystemScan || allowFilesystemScan queuedTask.scopes.add(scope) if (emitResolved) queuedTask.emitResolvedScopes.add(scope) if (this.priorityScore(priority) < this.priorityScore(queuedTask.priority)) { this.updateQueuedPriority(queuedTask, priority) } continue } if (this.shouldDeferLowPriorityDecrypt(allowDecrypt, priority)) { this.deferLowPriority(result, identity) continue } const queuedPeerTask = this.getQueuedMap(!allowDecrypt).get(identity) const shouldKeepIncomingCacheSeparate = !allowDecrypt && queuedPeerTask?.allowDecrypt === true if (queuedPeerTask && !shouldKeepIncomingCacheSeparate) { if (this.shouldDeferPeerUpgrade(queuedPeerTask, allowDecrypt, priority)) { this.deferLowPriority(result, identity) continue } this.totals.mergedQueued += 1 result.mergedQueued += 1 result.handledIdentities.push(identity) result.mergedQueuedIdentities.push(identity) this.mergeQueuedTaskPayload(queuedPeerTask, payload) queuedPeerTask.allowCacheIndex = queuedPeerTask.allowCacheIndex || allowCacheIndex queuedPeerTask.allowFilesystemScan = queuedPeerTask.allowFilesystemScan || allowFilesystemScan queuedPeerTask.scopes.add(scope) if (emitResolved) queuedPeerTask.emitResolvedScopes.add(scope) if (allowDecrypt && !queuedPeerTask.allowDecrypt) { this.updateQueuedMode(queuedPeerTask, true) } if (this.priorityScore(priority) < this.priorityScore(queuedPeerTask.priority)) { this.updateQueuedPriority(queuedPeerTask, priority) } continue } if (this.pending.has(key)) { this.totals.skippedPending += 1 result.skippedPending += 1 result.handledIdentities.push(identity) result.skippedPendingIdentities.push(identity) continue } if (!this.ensureQueueCapacity(priority)) { this.totals.rejectedCapacity += 1 if (this.isLowPrioritySoftLimitExceeded(priority)) { this.totals.lowPriorityRejected += 1 } result.rejectedCapacity += 1 result.rejectedIdentities.push(identity) continue } this.pending.add(key) const task = { ...payload, key, identity, allowDecrypt, allowCacheIndex, allowFilesystemScan, scopes: new Set([scope]), emitResolvedScopes: emitResolved ? new Set([scope]) : new Set(), priority } this.addQueuedTask(task) this.totals.accepted += 1 result.accepted += 1 result.handledIdentities.push(identity) result.acceptedIdentities.push(identity) this.recordHighWater() } this.sortQueue() this.processQueue() return result } cancelScope(scope: string): void { const normalizedScope = String(scope || '').trim() if (!normalizedScope) return this.canceledScopes.set(normalizedScope, Date.now()) this.dropActiveCacheSnapshotsForScope(normalizedScope) const kept: PreloadTask[] = [] for (const task of this.queue) { task.scopes.delete(normalizedScope) task.emitResolvedScopes.delete(normalizedScope) if (this.isCanceled(task)) { this.pending.delete(task.key) this.removeQueuedTask(task) this.totals.canceledQueued += 1 } else { kept.push(task) } } this.queue = kept for (const task of this.activeByIdentity.values()) { const hadScope = task.scopes.delete(normalizedScope) task.emitResolvedScopes.delete(normalizedScope) if (!hadScope) continue if (this.isCanceled(task)) { this.totals.canceledActive += 1 } } this.pruneCanceledScopes() this.clearLowPriorityWakeIfUnneeded() } getStats(): { queued: number pending: number activeCache: number activeDecrypt: number queuedIdentities: number queuedCache: number queuedDecrypt: number queuedHigh: number queuedNormal: number queuedLow: number activeIdentities: number queuedScopes: number activeScopes: number canceledScopes: number highWater: PreloadHighWaterStats totals: { accepted: number mergedQueued: number skippedActive: number ignoredCanceled: number droppedQueued: number canceledQueued: number canceledActive: number promotedActive: number rejectedCapacity: number deferredLowPriority: number lowPriorityIdleDeferrals: number skippedPending: number forcedThumbnailRefreshes: number forcedThumbnailRefreshFailures: number lowPriorityRejected: number activeCacheSnapshots: number activeCacheSnapshotSkipped: number activeCacheSnapshotCanceled: number started: number completed: number } } { this.recordHighWater() const queuedIdentities = new Set([ ...this.queuedCacheByIdentity.keys(), ...this.queuedDecryptByIdentity.keys() ]).size const priorityCounts = this.countQueuedPriorities() const highWater = { ...this.highWater } this.resetHighWater() return { queued: this.queue.length, pending: this.pending.size, activeCache: this.activeCache, activeDecrypt: this.activeDecrypt, queuedIdentities, queuedCache: this.queuedCacheByIdentity.size, queuedDecrypt: this.queuedDecryptByIdentity.size, queuedHigh: priorityCounts.high, queuedNormal: priorityCounts.normal, queuedLow: priorityCounts.low, activeIdentities: this.activeByIdentity.size, queuedScopes: this.countTaskScopes(this.queue), activeScopes: this.countTaskScopes(this.activeByIdentity.values()), canceledScopes: this.canceledScopes.size, highWater, totals: { ...this.totals } } } private priorityScore(priority: PreloadTask['priority']): number { if (priority === 'high') return 0 if (priority === 'normal') return 1 return 2 } private noteForegroundActivity(allowDecrypt: boolean, priority: PreloadTask['priority']): void { if (allowDecrypt && priority === 'low') return this.lastForegroundActivityAt = Date.now() } private createEnqueueResult(requested: number): PreloadEnqueueResult { return { success: true, requested, accepted: 0, mergedQueued: 0, skippedActive: 0, skippedPending: 0, ignoredCanceled: 0, rejectedCapacity: 0, deferred: 0, handledIdentities: [], acceptedIdentities: [], mergedQueuedIdentities: [], skippedActiveIdentities: [], skippedPendingIdentities: [], rejectedIdentities: [], deferredIdentities: [] } } private getTaskIdentity(payload: PreloadImagePayload): string { const imageMd5 = String(payload.imageMd5 || '').trim().toLowerCase() if (imageMd5) return `md5:${imageMd5}` const imageDatName = String(payload.imageDatName || '').trim().toLowerCase() if (imageDatName) { const sessionId = String(payload.sessionId || '').trim().toLowerCase() const createTime = Number(payload.createTime || 0) || 0 return `dat:${sessionId}|${createTime}|${imageDatName}` } return '' } private getQueuedMap(allowDecrypt: boolean): Map { return allowDecrypt ? this.queuedDecryptByIdentity : this.queuedCacheByIdentity } private deferLowPriority(result: PreloadEnqueueResult, identity: string): void { this.totals.deferredLowPriority += 1 result.deferred += 1 result.deferredIdentities.push(identity) } private shouldDeferLowPriorityDecrypt(allowDecrypt: boolean, priority: PreloadTask['priority']): boolean { if (!allowDecrypt || priority !== 'low') return false return ( this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0 || this.activeCache >= this.maxCacheConcurrent || (this.activeCache > 0 && this.queuedCacheByIdentity.size > 0) ) } private shouldDeferPeerUpgrade( peerTask: PreloadTask, allowDecrypt: boolean, priority: PreloadTask['priority'] ): boolean { return allowDecrypt && !peerTask.allowDecrypt && priority === 'low' && ( peerTask.priority !== 'low' || peerTask.emitResolvedScopes.size > 0 ) } private emitActiveCacheSnapshot( payload: PreloadImagePayload, identity: string, allowCacheIndex: boolean, scope: string ): void { if (this.isScopeCanceled(scope)) return const now = Date.now() this.pruneActiveCacheSnapshotCooldowns(now) const snapshotKey = `${scope}|${identity}` if (this.activeCacheSnapshotPending.has(snapshotKey)) { this.totals.activeCacheSnapshotSkipped += 1 return } const lastSnapshotAt = this.activeCacheSnapshotLastAt.get(snapshotKey) || 0 if (lastSnapshotAt && now - lastSnapshotAt < this.activeCacheSnapshotCooldownMs) { this.totals.activeCacheSnapshotSkipped += 1 return } this.activeCacheSnapshotPending.add(snapshotKey) this.activeCacheSnapshotLastAt.set(snapshotKey, now) this.totals.activeCacheSnapshots += 1 const cacheKey = this.getSnapshotCacheKey(payload) void imageDecryptService.resolveCachedImage({ sessionId: payload.sessionId, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, createTime: payload.createTime, preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: true, allowCacheIndex, allowCachePromotion: false, suppressEvents: true }).then((result) => { if (this.isScopeCanceled(scope)) { this.totals.activeCacheSnapshotCanceled += 1 return } if (!result?.success || !result.localPath || !cacheKey) return imageDecryptService.emitCacheResolvedPath({ sessionId: payload.sessionId, imageMd5: payload.imageMd5, imageDatName: payload.imageDatName, createTime: payload.createTime }, cacheKey, result.localPath) }).catch(() => { // Visible cache snapshots are best-effort; the active decrypt will still finish normally. }).finally(() => { this.activeCacheSnapshotPending.delete(snapshotKey) }) } private getSnapshotCacheKey(payload: PreloadImagePayload): string { return String(payload.imageMd5 || payload.imageDatName || '').trim().toLowerCase() } private pruneActiveCacheSnapshotCooldowns(now = Date.now()): void { const expiresBefore = now - this.activeCacheSnapshotCooldownMs for (const [key, timestamp] of this.activeCacheSnapshotLastAt.entries()) { if (timestamp >= expiresBefore) continue this.activeCacheSnapshotLastAt.delete(key) } } private dropActiveCacheSnapshotsForScope(scope: string): void { const prefix = `${scope}|` for (const key of Array.from(this.activeCacheSnapshotPending)) { if (key.startsWith(prefix)) this.activeCacheSnapshotPending.delete(key) } for (const key of Array.from(this.activeCacheSnapshotLastAt.keys())) { if (key.startsWith(prefix)) this.activeCacheSnapshotLastAt.delete(key) } } private mergeQueuedTaskPayload(task: PreloadTask, payload: PreloadImagePayload): void { const incomingSessionId = String(payload.sessionId || '').trim() if (!task.sessionId && incomingSessionId) { task.sessionId = incomingSessionId } const incomingCreateTime = Number(payload.createTime || 0) || 0 const currentCreateTime = Number(task.createTime || 0) || 0 if (!currentCreateTime && incomingCreateTime) { task.createTime = incomingCreateTime } const incomingImageMd5 = String(payload.imageMd5 || '').trim().toLowerCase() if (!task.imageMd5 && incomingImageMd5) { task.imageMd5 = incomingImageMd5 } const incomingDatName = String(payload.imageDatName || '').trim().toLowerCase() if (!task.imageDatName && incomingDatName) { task.imageDatName = incomingDatName } } private addQueuedTask(task: PreloadTask): void { this.queue.push(task) this.getQueuedMap(task.allowDecrypt).set(task.identity, task) this.queuedPriorityCounts[task.priority] += 1 } private updateQueuedPriority(task: PreloadTask, priority: PreloadTask['priority']): void { if (task.priority === priority) return this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1) task.priority = priority this.queuedPriorityCounts[priority] += 1 } private updateQueuedMode(task: PreloadTask, allowDecrypt: boolean): void { if (task.allowDecrypt === allowDecrypt) return this.getQueuedMap(task.allowDecrypt).delete(task.identity) this.pending.delete(task.key) task.allowDecrypt = allowDecrypt task.key = `${allowDecrypt ? 'decrypt' : 'cache'}|${task.identity}` this.getQueuedMap(task.allowDecrypt).set(task.identity, task) this.pending.add(task.key) } private removeQueuedTask(task: PreloadTask): void { this.getQueuedMap(task.allowDecrypt).delete(task.identity) this.queuedPriorityCounts[task.priority] = Math.max(0, this.queuedPriorityCounts[task.priority] - 1) } private ensureQueueCapacity(priority: PreloadTask['priority']): boolean { if (this.isLowPrioritySoftLimitExceeded(priority)) { return false } if (this.queue.length < this.maxQueueSize) return true const incomingScore = this.priorityScore(priority) let dropIndex = -1 let dropScore = incomingScore for (let index = this.queue.length - 1; index >= 0; index -= 1) { const score = this.priorityScore(this.queue[index].priority) if (score <= dropScore) continue dropScore = score dropIndex = index } if (dropIndex < 0) return false const [dropped] = this.queue.splice(dropIndex, 1) if (dropped) { this.pending.delete(dropped.key) this.removeQueuedTask(dropped) this.totals.droppedQueued += 1 } return true } private isLowPrioritySoftLimitExceeded(priority: PreloadTask['priority']): boolean { return priority === 'low' && ( this.queue.length >= this.lowPrioritySoftQueueSize || this.queuedPriorityCounts.low >= this.maxLowPriorityQueueSize ) } private isScopeCanceled(scope: string): boolean { return this.canceledScopes.has(scope) } private isCanceled(task: PreloadTask): boolean { if (task.scopes.size === 0) return true for (const scope of task.scopes) { if (!this.isScopeCanceled(scope)) return false } return true } private hasRunnableEmitScope(task: PreloadTask): boolean { for (const scope of Array.from(task.emitResolvedScopes)) { if (task.scopes.has(scope) && !this.isScopeCanceled(scope)) return true task.emitResolvedScopes.delete(scope) } return false } private emitTaskCacheResolved(task: PreloadTask, result?: { success?: boolean; localPath?: string }): void { if (!result?.success || !result.localPath) return if (!this.hasRunnableEmitScope(task)) return const cacheKey = this.getSnapshotCacheKey(task) if (!cacheKey) return imageDecryptService.emitCacheResolvedPath({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime }, cacheKey, result.localPath) } private pruneCanceledScopes(): void { if (this.canceledScopes.size === 0) return const referencedScopes = new Set() for (const task of this.queue) { for (const scope of task.scopes) referencedScopes.add(scope) } for (const task of this.activeByIdentity.values()) { for (const scope of task.scopes) referencedScopes.add(scope) } const expiresBefore = Date.now() - this.canceledScopeTtlMs for (const [scope, canceledAt] of this.canceledScopes.entries()) { if (referencedScopes.has(scope)) continue if (canceledAt >= expiresBefore) continue this.canceledScopes.delete(scope) } } private countTaskScopes(tasks: Iterable): number { const scopes = new Set() for (const task of tasks) { for (const scope of task.scopes) { scopes.add(scope) } } return scopes.size } private countQueuedPriorities(): Record { return { ...this.queuedPriorityCounts } } private recordHighWater(): void { const priorityCounts = this.countQueuedPriorities() this.highWater.queued = Math.max(this.highWater.queued, this.queue.length) this.highWater.pending = Math.max(this.highWater.pending, this.pending.size) this.highWater.queuedCache = Math.max(this.highWater.queuedCache, this.queuedCacheByIdentity.size) this.highWater.queuedDecrypt = Math.max(this.highWater.queuedDecrypt, this.queuedDecryptByIdentity.size) this.highWater.queuedHigh = Math.max(this.highWater.queuedHigh, priorityCounts.high) this.highWater.queuedNormal = Math.max(this.highWater.queuedNormal, priorityCounts.normal) this.highWater.queuedLow = Math.max(this.highWater.queuedLow, priorityCounts.low) this.highWater.activeCache = Math.max(this.highWater.activeCache, this.activeCache) this.highWater.activeDecrypt = Math.max(this.highWater.activeDecrypt, this.activeDecrypt) this.highWater.activeIdentities = Math.max(this.highWater.activeIdentities, this.activeByIdentity.size) } private resetHighWater(): void { const priorityCounts = this.countQueuedPriorities() this.highWater = { queued: this.queue.length, pending: this.pending.size, queuedCache: this.queuedCacheByIdentity.size, queuedDecrypt: this.queuedDecryptByIdentity.size, queuedHigh: priorityCounts.high, queuedNormal: priorityCounts.normal, queuedLow: priorityCounts.low, activeCache: this.activeCache, activeDecrypt: this.activeDecrypt, activeIdentities: this.activeByIdentity.size } } private sortQueue(): void { this.queue.sort((a, b) => this.priorityScore(a.priority) - this.priorityScore(b.priority)) } private hasRunnableCapacity(): boolean { return this.activeCache < this.maxCacheConcurrent || this.activeDecrypt < this.maxDecryptConcurrent } private canRunTask(task: PreloadTask): boolean { if (this.activeByIdentity.has(task.identity)) return false if (task.allowDecrypt && task.priority === 'low' && !this.canRunLowPriorityDecrypt()) return false return task.allowDecrypt ? this.activeDecrypt < this.maxDecryptConcurrent : this.activeCache < this.maxCacheConcurrent } private canRunLowPriorityDecrypt(): boolean { if (this.activeDecrypt > 0) return false if (this.activeCache > 0) return false if (this.queuedCacheByIdentity.size > 0) return false if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return false return Date.now() - this.lastForegroundActivityAt >= this.lowPriorityIdleDelayMs } private hasQueuedLowPriorityDecrypt(): boolean { return this.queue.some((task) => task.allowDecrypt && task.priority === 'low') } private scheduleLowPriorityWakeIfNeeded(): void { if (this.lowPriorityWakeTimer !== null) return if (!this.hasQueuedLowPriorityDecrypt()) return if (this.activeDecrypt > 0 || this.activeCache > 0) return if (this.queuedCacheByIdentity.size > 0) return if (this.queuedPriorityCounts.high > 0 || this.queuedPriorityCounts.normal > 0) return const elapsed = Date.now() - this.lastForegroundActivityAt const delayMs = Math.max(20, this.lowPriorityIdleDelayMs - elapsed) this.totals.lowPriorityIdleDeferrals += 1 this.lowPriorityWakeTimer = setTimeout(() => { this.lowPriorityWakeTimer = null this.processQueue() }, delayMs) } private clearLowPriorityWakeIfUnneeded(): void { if (this.lowPriorityWakeTimer === null) return if (this.hasQueuedLowPriorityDecrypt()) return clearTimeout(this.lowPriorityWakeTimer) this.lowPriorityWakeTimer = null } private takeNextRunnableTask(): PreloadTask | null { if (!this.hasRunnableCapacity()) return null const taskIndex = this.queue.findIndex((task) => this.canRunTask(task)) if (taskIndex < 0) return null const task = this.queue.splice(taskIndex, 1)[0] if (!task) return null this.removeQueuedTask(task) return task } private processQueue(): void { while (this.queue.length > 0) { const task = this.takeNextRunnableTask() if (!task) { this.scheduleLowPriorityWakeIfNeeded() return } this.activeByIdentity.set(task.identity, task) this.totals.started += 1 if (task.allowDecrypt) this.activeDecrypt += 1 else this.activeCache += 1 this.recordHighWater() void this.handleTask(task).finally(() => { if (task.allowDecrypt) this.activeDecrypt = Math.max(0, this.activeDecrypt - 1) else this.activeCache = Math.max(0, this.activeCache - 1) this.activeByIdentity.delete(task.identity) this.pending.delete(task.key) this.totals.completed += 1 this.pruneCanceledScopes() this.processQueue() }) } } private async handleTask(task: PreloadTask): Promise { const cacheKey = task.imageMd5 || task.imageDatName if (!cacheKey) return if (this.isCanceled(task)) return try { const cached = await imageDecryptService.resolveCachedImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: true, allowCacheIndex: task.allowCacheIndex, allowCachePromotion: false, allowFilesystemScan: task.allowFilesystemScan, suppressEvents: true }) if (cached.success) { this.emitTaskCacheResolved(task, cached) if (!task.allowDecrypt || this.isCanceled(task)) return const shouldRefreshCachedPreview = cached.hasUpdate === true || isLikelyThumbnailCachePath(cached.localPath) if (!shouldRefreshCachedPreview) return this.totals.forcedThumbnailRefreshes += 1 const refreshed = await imageDecryptService.decryptImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, force: true, hardlinkOnly: true, disableUpdateCheck: true, allowFilesystemScan: task.allowFilesystemScan, suppressEvents: true }) if (!refreshed.success) { this.totals.forcedThumbnailRefreshFailures += 1 } if (this.isCanceled(task)) return const resolved = await imageDecryptService.resolveCachedImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: true, allowCacheIndex: task.allowCacheIndex, allowCachePromotion: false, allowFilesystemScan: task.allowFilesystemScan, suppressEvents: true }) this.emitTaskCacheResolved(task, resolved) return } if (!task.allowDecrypt) return if (this.isCanceled(task)) return await imageDecryptService.decryptImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: true, allowFilesystemScan: task.allowFilesystemScan, suppressEvents: true }) if (this.isCanceled(task)) return const resolved = await imageDecryptService.resolveCachedImage({ sessionId: task.sessionId, imageMd5: task.imageMd5, imageDatName: task.imageDatName, createTime: task.createTime, preferFilePath: true, hardlinkOnly: true, disableUpdateCheck: true, allowCacheIndex: task.allowCacheIndex, allowCachePromotion: false, allowFilesystemScan: task.allowFilesystemScan, suppressEvents: true }) this.emitTaskCacheResolved(task, resolved) } catch { // ignore preload failures } } } export const imagePreloadService = new ImagePreloadService()