mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-10-25 17:36:32 +08:00
864 lines
30 KiB
TypeScript
864 lines
30 KiB
TypeScript
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<string>
|
|
emitResolvedScopes: Set<string>
|
|
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<string>()
|
|
private queuedCacheByIdentity = new Map<string, PreloadTask>()
|
|
private queuedDecryptByIdentity = new Map<string, PreloadTask>()
|
|
private activeByIdentity = new Map<string, PreloadTask>()
|
|
private canceledScopes = new Map<string, number>()
|
|
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<PreloadTask['priority'], number> = {
|
|
high: 0,
|
|
normal: 0,
|
|
low: 0
|
|
}
|
|
private activeCacheSnapshotPending = new Set<string>()
|
|
private activeCacheSnapshotLastAt = new Map<string, number>()
|
|
private lastForegroundActivityAt = Date.now()
|
|
private lowPriorityWakeTimer: ReturnType<typeof setTimeout> | 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<string>(),
|
|
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<string, PreloadTask> {
|
|
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<string>()
|
|
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<PreloadTask>): number {
|
|
const scopes = new Set<string>()
|
|
for (const task of tasks) {
|
|
for (const scope of task.scopes) {
|
|
scopes.add(scope)
|
|
}
|
|
}
|
|
return scopes.size
|
|
}
|
|
|
|
private countQueuedPriorities(): Record<PreloadTask['priority'], number> {
|
|
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<void> {
|
|
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()
|