import Foundation final class RDEPUBMetadataParseWorker { private static let maxRetryCount = 3 private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0] private final class ParseState { var summariesBySpineIndex: [Int: RDEPUBChapterSummary] var totalResolvedCount: Int var lastAppliedCount: Int init( summariesBySpineIndex: [Int: RDEPUBChapterSummary], totalResolvedCount: Int, lastAppliedCount: Int ) { self.summariesBySpineIndex = summariesBySpineIndex self.totalResolvedCount = totalResolvedCount self.lastAppliedCount = lastAppliedCount } } private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 weak var context: RDEPUBReaderContext? let cancellationController: RDEPUBMetadataParseCancellationController let pageMapRefreshInterval: Int private let token: UUID private let parser: RDEPUBParser private let publication: RDEPUBPublication private let pageSize: CGSize private let layoutConfig: RDEPUBTextLayoutConfig private let style: RDEPUBTextRenderStyle private let renderSignature: String private let allBuildableIndices: [Int] private let summaryDiskCache: RDEPUBChapterSummaryDiskCache? private let workerCount: Int private let contentHashBySpineIndex: [Int: String] private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)] init( context: RDEPUBReaderContext, cancellationController: RDEPUBMetadataParseCancellationController, token: UUID, parser: RDEPUBParser, publication: RDEPUBPublication ) { self.context = context self.cancellationController = cancellationController self.token = token self.parser = parser self.publication = publication let pageSize = context.currentTextPageSize() self.pageSize = pageSize self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) self.style = context.currentTextRenderStyle() self.renderSignature = context.currentRenderSignature() self.allBuildableIndices = publication.spine.indices.filter { index in guard publication.spine.indices.contains(index) else { return false } let item = publication.spine[index] return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) } self.summaryDiskCache = context.runtime?.summaryDiskCache self.workerCount = max(1, context.configuration.metadataParsingConcurrency) self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval var hashes: [Int: String] = [:] for spineIndex in allBuildableIndices { guard let href = publication.spine.indices.contains(spineIndex) ? publication.spine[spineIndex].href : nil, let html = parser.htmlString(forRelativePath: href) else { hashes[spineIndex] = "" continue } hashes[spineIndex] = html.rd_sha256Hex } self.contentHashBySpineIndex = hashes let ctx = context let spine = publication.spine let sig = renderSignature self.catalog = allBuildableIndices.map { spineIndex in let item = spine[spineIndex] return ( key: ctx.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: hashes[spineIndex] ?? "", renderSignature: sig ), spineIndex: spineIndex, href: item.href, title: item.title ) } } func start(token: UUID, restoreLocation: RDEPUBLocation?) { let cancellationController = self.cancellationController DispatchQueue.global(qos: .utility).async { [weak self] in guard let self else { return } let context = self.context guard let context, context.controller != nil, !cancellationController.isCancelled, context.paginationToken == token else { return } defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) } guard context.controller != nil, !cancellationController.isCancelled, context.paginationToken == token else { return } if let restoredPageMap = self.restoreBookPageMapIfPossible() { DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(restoredPageMap) } return } let prewarmStart = CFAbsoluteTimeGetCurrent() let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000) let restored = self.summaryDiskCache?.readAll(keys: self.catalog) let cachedSummaries = restored?.summaries ?? [:] let cachedSpineIndices = Set(cachedSummaries.keys) let resultLock = NSLock() let parseState = ParseState( summariesBySpineIndex: cachedSummaries, totalResolvedCount: cachedSpineIndices.count, lastAppliedCount: cachedSpineIndices.count ) if !cachedSpineIndices.isEmpty { let cachedMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(cachedMap) } } let prioritizedSpineIndices: [Int] if let priorityManager = context.runtime?.backgroundPriorityManager { let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() .flatMap { context.normalizedSpineIndex(for: $0) } prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder( allBuildableIndices: self.allBuildableIndices, currentSpineIndex: currentSpineIndex, cachedSpineIndices: cachedSpineIndices ) } else { prioritizedSpineIndices = self.allBuildableIndices.filter { !cachedSpineIndices.contains($0) } } let uncachedSpineIndices = prioritizedSpineIndices self.waitForReadingInteractionToSettle(cancellationController: cancellationController) guard !cancellationController.isCancelled, context.controller != nil, context.paginationToken == token else { return } let wallClockStart = CFAbsoluteTimeGetCurrent() var totalRenderMs: Double = 0 var totalWriteMs: Double = 0 var totalMergeMs: Double = 0 var completedChapters = 0 var failedChapters = 0 let timingLock = NSLock() let queue = OperationQueue() queue.name = "com.rdreader.metadata.parse" queue.qualityOfService = .utility queue.maxConcurrentOperationCount = self.workerCount cancellationController.attach(queue: queue) let refreshInterval = self.pageMapRefreshInterval for (offset, spineIndex) in uncachedSpineIndices.enumerated() { let operation = BlockOperation() operation.addExecutionBlock { [weak operation] in guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return } do { let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return nil } let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig) let renderStart = CFAbsoluteTimeGetCurrent() guard let result = try chapterBuilder.buildChapter( parser: self.parser, publication: self.publication, spineIndex: spineIndex, pageSize: self.pageSize, style: self.style ) else { return nil } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return nil } let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000 let chapter = result.chapter let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? "" let cacheKey = context.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: precomputedHash, renderSignature: self.renderSignature ) let summary = RDEPUBChapterSummary( pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, cfiMap: chapter.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return nil } let writeStart = CFAbsoluteTimeGetCurrent() self.summaryDiskCache?.write(summary: summary, for: cacheKey) let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 timingLock.lock() totalRenderMs += renderElapsed totalWriteMs += writeElapsed completedChapters += 1 timingLock.unlock() return summary } guard let renderResult else { return } guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled, operation?.isCancelled != true else { return } var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() parseState.summariesBySpineIndex[spineIndex] = renderResult parseState.totalResolvedCount += 1 if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval || parseState.totalResolvedCount == self.allBuildableIndices.count { parseState.lastAppliedCount = parseState.totalResolvedCount snapshot = parseState.summariesBySpineIndex } resultLock.unlock() if let snapshot { let mergeStart = CFAbsoluteTimeGetCurrent() let partialMap = self.buildPageMap(summaries: snapshot) let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000 timingLock.lock() totalMergeMs += mergeElapsed timingLock.unlock() DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } } catch { guard !cancellationController.isCancelled, context.paginationToken == token, context.controller != nil, operation?.isCancelled != true else { return } timingLock.lock() failedChapters += 1 timingLock.unlock() self.scheduleRetry( spineIndex: spineIndex, retryCount: 0, resultLock: resultLock, parseState: parseState, cancellationController: cancellationController ) } } queue.addOperation(operation) } queue.waitUntilAllOperationsAreFinished() if !cancellationController.isCancelled, context.paginationToken == token, context.controller != nil { self.summaryDiskCache?.flushPendingWrites() } let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) timingLock.lock() let renderTotal = Int(totalRenderMs) let writeTotal = Int(totalWriteMs) let mergeTotal = Int(totalMergeMs) let rendered = completedChapters let failed = failedChapters timingLock.unlock() let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0 context.lastMetadataParseWallClockMs = wallClockMs context.lastMetadataParseConcurrency = self.workerCount guard context.controller != nil, context.paginationToken == token, !cancellationController.isCancelled else { return } let finalMergeStart = CFAbsoluteTimeGetCurrent() let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) if let coverageStore = context.runtime?.backgroundCoverageStore { let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) let lowerSpine = resolvedSpineIndices.min() ?? 0 let upperSpine = resolvedSpineIndices.max() ?? 0 let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16 let segment = RDEPUBBackgroundCoverageSegment( lowerSpineIndex: lowerSpine, upperSpineIndex: upperSpine, pageMap: pageMap, resolvedSpineIndices: resolvedSpineIndices, generatedAt: CFAbsoluteTimeGetCurrent(), renderSignature: self.renderSignature, estimatedMemoryBytes: estimatedBytes ) coverageStore.addSegment(segment) } DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(pageMap) } } } // MARK: - Private private func waitForReadingInteractionToSettle( cancellationController: RDEPUBMetadataParseCancellationController? = nil ) { let semaphore = DispatchSemaphore(value: 0) let maxWaitIterations = 100 // ~8 seconds max wait var iteration = 0 while context?.controller != nil, cancellationController?.isCancelled != true, iteration < maxWaitIterations { let elapsed = context?.secondsSinceLastUserNavigation() ?? 0 if elapsed >= backgroundInteractionCooldown { break } semaphore.wait(timeout: .now() + 0.08) iteration += 1 } } private func scheduleRetry( spineIndex: Int, retryCount: Int, resultLock: NSLock, parseState: ParseState, cancellationController: RDEPUBMetadataParseCancellationController ) { guard retryCount < Self.maxRetryCount else { return } let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)] DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in guard let self, let context = self.context else { return } guard context.controller != nil, context.paginationToken == self.token, !cancellationController.isCancelled else { return } do { let chapterBuilder = context.makeTextBookBuilder(layoutConfig: self.layoutConfig) guard let result = try chapterBuilder.buildChapter( parser: self.parser, publication: self.publication, spineIndex: spineIndex, pageSize: self.pageSize, style: self.style ) else { return } guard context.controller != nil, context.paginationToken == self.token, !cancellationController.isCancelled else { return } let chapter = result.chapter let precomputedHash = self.contentHashBySpineIndex[spineIndex] ?? "" let cacheKey = context.chapterCacheKey( forSpineIndex: spineIndex, precomputedContentHash: precomputedHash, renderSignature: self.renderSignature ) let summary = RDEPUBChapterSummary( pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, pageCount: chapter.pages.count, fragmentOffsets: chapter.fragmentOffsets, cfiMap: chapter.cfiMap, renderSignature: cacheKey.renderSignature, schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) guard context.controller != nil, context.paginationToken == self.token, !cancellationController.isCancelled else { return } self.summaryDiskCache?.write(summary: summary, for: cacheKey) resultLock.lock() parseState.summariesBySpineIndex[spineIndex] = summary parseState.totalResolvedCount += 1 let shouldRefresh = parseState.totalResolvedCount - parseState.lastAppliedCount >= self.pageMapRefreshInterval || parseState.totalResolvedCount == self.allBuildableIndices.count if shouldRefresh { parseState.lastAppliedCount = parseState.totalResolvedCount } resultLock.unlock() if shouldRefresh { let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == self.token, context.controller != nil, !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } } catch { self.scheduleRetry( spineIndex: spineIndex, retryCount: retryCount + 1, resultLock: resultLock, parseState: parseState, cancellationController: cancellationController ) } } } private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? { guard let summaryDiskCache else { return nil } guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else { return nil } let restored = summaryDiskCache.readAll(keys: catalog) guard restored.summaries.count == catalog.count else { return nil } return restored.mapBuilder.build() } private func buildPageMap( summaries: [Int: RDEPUBChapterSummary] ) -> RDEPUBBookPageMap { var builder = RDEPUBBookPageMap.Builder() for item in catalog { guard let summary = summaries[item.spineIndex] else { continue } builder.add( spineIndex: item.spineIndex, href: item.href, title: item.title, pageCount: summary.pageCount, fragmentOffsets: summary.fragmentOffsets ) } return builder.build() } }