diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift index 74eb7b6..3f62e0c 100644 --- a/World Manager for Minecraft/Services/SourceLibrary.swift +++ b/World Manager for Minecraft/Services/SourceLibrary.swift @@ -10,7 +10,7 @@ import Foundation import OSLog @MainActor -final class SourceLibrary: ObservableObject { +final class SourceLibrary: ObservableObject, SourceScanSessionHosting { private static let enrichmentWorkerCount = 4 private static let sizeWorkerCount = 2 private static let minimumVisibleScanDuration: TimeInterval = 0.8 @@ -229,373 +229,24 @@ final class SourceLibrary: ObservableObject { } private func scanSource(withID sourceID: URL, mode: SourceDiscoveryMode) async { - var workerTasks: [Task] = [] - var sizeWorkerTasks: [Task] = [] - let scanStartTime = Date() defer { - workerTasks.forEach { $0.cancel() } - sizeWorkerTasks.forEach { $0.cancel() } scanTasks[sourceID] = nil } guard let source = source(withID: sourceID) else { return } - let previousSource = source - let performanceContext = SourceScanPolicy.performanceContext(for: source) - - updateSource(sourceID) { source in - source.isScanning = true - source.scanError = nil - source.scanDiagnostic = nil - source.scanStatus = SourceScanPolicy.initialStatus(for: source, mode: mode) - source.scanProgress = nil - source.indexedItemCount = 0 - source.indexedDetailCount = 0 - source.previewLoadedCount = 0 - source.sizeLoadedCount = 0 - } - - updateSource(sourceID) { source in - source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) - } - let currentAvailability = await sourceAccessMethod.availability(for: source) - updateSource(sourceID) { source in - source.availability = currentAvailability - } - - let scanContextURL = source.folderURL - await WorldScanner.beginScanSession(for: scanContextURL) - defer { - Task.detached(priority: .utility) { - await WorldScanner.endScanSession(for: scanContextURL) - } - } - - updateSource(sourceID) { source in - source.availability = .available - source.scanStatus = SourceScanPolicy.scanningLibraryStatus(for: source, mode: mode) - } - - do { - let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL) - let enrichmentQueue = EnrichmentWorkQueue() - let enrichmentWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.enrichmentWorkerCount - let sizeWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.sizeWorkerCount - workerTasks = (0.. { continuation in - let accessMethod = sourceAccessMethod - let discoveryTask = Task.detached(priority: .userInitiated) { - do { - try await accessMethod.discoverItems(for: source, mode: mode) { item in - continuation.yield(item) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - - continuation.onTermination = { @Sendable _ in - discoveryTask.cancel() - } - } - - let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) }) - let previousSnapshotByItemID = Dictionary( - uniqueKeysWithValues: (previousSource.snapshot?.itemSnapshots ?? []).map { ($0.id, $0) } - ) - let shouldReconcileFromCache = mode == .reconcile && previousSource.hasCachedContent - - var discoveredCount = 0 - var discoveredCollectionNames = Set() - let discoveryStartTime = Date() - - for try await item in discoveryStream { - guard !Task.isCancelled else { - break - } - - discoveredCount += 1 - discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) - let itemForIndex: MinecraftContentItem - if shouldReconcileFromCache, - let cachedItem = previousItemsByID[item.id], - SourceScanPolicy.shouldReuseCachedItem( - cachedItem, - forDiscoveredItem: item, - source: source, - previousSnapshot: previousSnapshotByItemID[item.id] - ) { - itemForIndex = cachedItem - } else { - itemForIndex = item - } - - if let snapshot = await index.addDiscoveredItem( - itemForIndex, - discoveredCount: discoveredCount - ) { - applySnapshot(snapshot, to: sourceID) - } - if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { - await enrichmentQueue.enqueue(item) - } - } - - if mode == .reconcile, source.origin.kind == .connectedDevice { - let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in - item.collectionRootURL.lastPathComponent - } - - for (collectionName, cachedItems) in cachedItemsByCollection { - guard !cachedItems.isEmpty else { - continue - } - - guard !discoveredCollectionNames.contains(collectionName) else { - continue - } - - for cachedItem in cachedItems { - discoveredCount += 1 - if let snapshot = await index.addDiscoveredItem( - cachedItem, - discoveredCount: discoveredCount - ) { - applySnapshot(snapshot, to: sourceID) - } - } - } - } - - logScanStage( - "Discovery", - elapsed: Date().timeIntervalSince(discoveryStartTime), - context: performanceContext, - itemCount: discoveredCount - ) - - if let snapshot = await index.markDiscoveryFinished() { - applySnapshot(snapshot, to: sourceID) - } - await enrichmentQueue.finish() - let enrichmentStartTime = Date() - - for workerTask in workerTasks { - await workerTask.value - } - - logScanStage( - "Enrichment", - elapsed: Date().timeIntervalSince(enrichmentStartTime), - context: performanceContext, - itemCount: discoveredCount - ) - - if let snapshot = await index.markMetadataFinished() { - applySnapshot(snapshot, to: sourceID) - } - persistSourceIfAvailable(withID: sourceID) - - let previewStageStartTime = Date() - let previewSeedItems = await index.currentItems() - let previewItems = await sourceAccessMethod.loadPreviewAssets( - for: previewSeedItems.filter { !$0.previewLoaded }, - in: source - ) - for previewItem in previewItems { - if let snapshot = await index.applyPreviewItem(previewItem) { - applySnapshot(snapshot, to: sourceID) - } - } - - logScanStage( - "Previews", - elapsed: Date().timeIntervalSince(previewStageStartTime), - context: performanceContext, - itemCount: discoveredCount - ) - - if let snapshot = await index.markPreviewsFinished() { - applySnapshot(snapshot, to: sourceID) - } - persistSourceIfAvailable(withID: sourceID) - - if source.origin.kind == .connectedDevice { - let sizeStageStartTime = Date() - let sizeSeedItems = await index.currentItems() - let sizedItems = await sourceAccessMethod.loadSizeAssets( - for: sizeSeedItems.filter { !$0.sizeLoaded }, - in: source - ) - for sizedItem in sizedItems { - if let snapshot = await index.applySizedItem(sizedItem) { - applySnapshot(snapshot, to: sourceID) - } - } - - logScanStage( - "Size", - elapsed: Date().timeIntervalSince(sizeStageStartTime), - context: performanceContext, - itemCount: discoveredCount - ) - - let elapsedScanTime = Date().timeIntervalSince(scanStartTime) - if elapsedScanTime < Self.minimumVisibleScanDuration { - try? await Task.sleep( - for: .seconds(Self.minimumVisibleScanDuration - elapsedScanTime) - ) - } - - if let snapshot = await index.finishScan() { - applySnapshot(snapshot, to: sourceID) - } - updateSource(sourceID) { source in - if source.origin.kind == .localFolder { - source.snapshot = SourceScanPolicy.buildSnapshot(for: source, scanRootURL: scanContextURL) - } else { - source.snapshot = nil - } - } - persistSourceIfAvailable(withID: sourceID) - logScanStage( - "Total", - elapsed: Date().timeIntervalSince(scanStartTime), - context: performanceContext, - itemCount: discoveredCount - ) - - if let completedSource = self.source(withID: sourceID) { - await notificationService.notifyScanCompleted( - for: completedSource, - duration: Date().timeIntervalSince(scanStartTime) - ) - } - return - } - - let sizeQueue = EnrichmentWorkQueue() - sizeWorkerTasks = (0.. Void) { + func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) { guard let index = sources.firstIndex(where: { $0.id == sourceID }) else { return } @@ -783,7 +434,7 @@ final class SourceLibrary: ObservableObject { sources[index] = source } - private func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { + func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) { updateSource(sourceID) { source in source.displayItems = snapshot.displayItems source.rawItems = snapshot.rawItems @@ -1239,7 +890,7 @@ final class SourceLibrary: ObservableObject { return normalizedItems } - private func persistSourceIfAvailable(withID sourceID: URL) { + func persistSourceIfAvailable(withID sourceID: URL) { guard let source = source(withID: sourceID) else { return } @@ -1433,552 +1084,8 @@ final class SourceLibrary: ObservableObject { private func relativePathHint(for item: MinecraftContentItem, sourceRootURL: URL) -> String { item.folderURL.path.replacingOccurrences(of: sourceRootURL.path + "/", with: "") } - } -private struct PackMetadata { - let uuid: String? - let version: String? - let identity: PackIdentity -} - -private actor EnrichmentWorkQueue { - private var pendingItems: [MinecraftContentItem] = [] - private var isFinished = false - private var waitingContinuations: [CheckedContinuation] = [] - - func enqueue(_ item: MinecraftContentItem) { - if let continuation = waitingContinuations.first { - waitingContinuations.removeFirst() - continuation.resume(returning: item) - return - } - - pendingItems.append(item) - } - - func next() async -> MinecraftContentItem? { - if !pendingItems.isEmpty { - return pendingItems.removeFirst() - } - - if isFinished { - return nil - } - - return await withCheckedContinuation { continuation in - waitingContinuations.append(continuation) - } - } - - func finish() { - isFinished = true - - for continuation in waitingContinuations { - continuation.resume(returning: nil) - } - - waitingContinuations.removeAll() - } -} - -private struct SourceIndexSnapshot { - let displayItems: [MinecraftContentItem] - let rawItems: [MinecraftContentItem] - let logicalPacks: [LogicalPack] - let logicalWorlds: [LogicalWorld] - let packInstances: [PackInstance] - let worldPackRelationships: [WorldPackRelationship] - let indexedItemCount: Int - let indexedDetailCount: Int - let previewLoadedCount: Int - let sizeLoadedCount: Int - let scanStatus: String - let scanProgress: Double? - let isScanning: Bool - let previewStageElapsed: TimeInterval? - let previewStageDuration: TimeInterval? - let sizeStageElapsed: TimeInterval? - let sizeStageDuration: TimeInterval? - let lastScanDate: Date? -} - -private actor SourceIndexActor { - private let sourceID: URL - private let folderURL: URL - private let publishInterval: TimeInterval = 0.12 - - private var orderedItemIDs: [URL] = [] - private var itemsByID: [URL: MinecraftContentItem] = [:] - private var packMetadataByItemID: [URL: PackMetadata] = [:] - private var packIdentityByItemID: [URL: String] = [:] - private var packIdentityValueByID: [String: PackIdentity] = [:] - private var packItemIDsByIdentityID: [String: Set] = [:] - private var packRepresentativeItemIDByIdentityID: [String: URL] = [:] - private var indexedItemCount = 0 - private var indexedDetailCount = 0 - private var previewLoadedCount = 0 - private var sizeLoadedCount = 0 - private var discoveryFinished = false - private var metadataFinished = false - private var previewsFinished = false - private var sizesFinished = false - private var previewStageStartedAt: Date? - private var previewStageFinishedAt: Date? - private var sizeStageStartedAt: Date? - private var sizeStageFinishedAt: Date? - private var lastPublishedAt: Date? - - init(sourceID: URL, folderURL: URL) { - self.sourceID = sourceID - self.folderURL = folderURL - } - - func addDiscoveredItem(_ item: MinecraftContentItem, discoveredCount: Int) -> SourceIndexSnapshot? { - orderedItemIDs.append(item.id) - itemsByID[item.id] = item - indexedItemCount = discoveredCount - if item.metadataLoaded { - indexedDetailCount += 1 - } - if item.previewLoaded { - previewLoadedCount += 1 - } - if item.sizeLoaded { - sizeLoadedCount += 1 - } - return snapshotIfNeeded() - } - - func applyEnrichedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { - let previous = itemsByID[item.id] - itemsByID[item.id] = item - if item.metadataLoaded, previous?.metadataLoaded != true { - indexedDetailCount += 1 - } - - if isLogicalPackType(item.contentType) { - refreshTrackedPackIdentity(for: item, previousItem: previous) - } - - return snapshotIfNeeded() - } - - func applySizedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { - guard var current = itemsByID[item.id] else { - return nil - } - - let wasSizeLoaded = current.sizeLoaded - current.sizeBytes = item.sizeBytes - current.sizeLoaded = item.sizeLoaded - itemsByID[item.id] = current - if item.sizeLoaded, wasSizeLoaded != true { - sizeLoadedCount += 1 - } - return snapshotIfNeeded() - } - - func applyPreviewItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { - let previous = itemsByID[item.id] - itemsByID[item.id] = item - if item.previewLoaded, previous?.previewLoaded != true { - previewLoadedCount += 1 - } - - return snapshotIfNeeded() - } - - func markDiscoveryFinished() -> SourceIndexSnapshot? { - discoveryFinished = true - return buildSnapshot(force: true) - } - - func markMetadataFinished() -> SourceIndexSnapshot? { - discoveryFinished = true - metadataFinished = true - previewStageStartedAt = previewStageStartedAt ?? Date() - return buildSnapshot(force: true) - } - - func markPreviewsFinished() -> SourceIndexSnapshot? { - discoveryFinished = true - metadataFinished = true - previewsFinished = true - let now = Date() - previewStageStartedAt = previewStageStartedAt ?? now - previewStageFinishedAt = previewStageFinishedAt ?? now - sizeStageStartedAt = sizeStageStartedAt ?? now - return buildSnapshot(force: true) - } - - func finishScan() -> SourceIndexSnapshot? { - discoveryFinished = true - metadataFinished = true - previewsFinished = true - sizesFinished = true - let now = Date() - previewStageFinishedAt = previewStageFinishedAt ?? now - sizeStageStartedAt = sizeStageStartedAt ?? now - sizeStageFinishedAt = sizeStageFinishedAt ?? now - return buildSnapshot(force: true) - } - - func currentItems() -> [MinecraftContentItem] { - orderedItemIDs.compactMap { itemsByID[$0] } - } - - private func snapshotIfNeeded() -> SourceIndexSnapshot? { - buildSnapshot(force: false) - } - - private func buildSnapshot(force: Bool) -> SourceIndexSnapshot? { - let now = Date() - if !force, let lastPublishedAt, now.timeIntervalSince(lastPublishedAt) < publishInterval { - return nil - } - - lastPublishedAt = now - - let rawItems = orderedItemIDs.compactMap { itemsByID[$0] } - let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) }) - let logicalPacks = buildLogicalPacks(rawItemsByID: rawItemsByID) - let dedupedDisplayItems = buildDisplayItems( - from: rawItems, - logicalPacks: logicalPacks, - rawItemsByID: rawItemsByID - ) - let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) - let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) - let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) - let previewStageElapsed = previewStageStartedAt.map { now.timeIntervalSince($0) } - let previewStageDuration = previewStageStartedAt.flatMap { startedAt in - previewStageFinishedAt.map { $0.timeIntervalSince(startedAt) } - } - let sizeStageElapsed = sizeStageStartedAt.map { now.timeIntervalSince($0) } - let sizeStageDuration = sizeStageStartedAt.flatMap { startedAt in - sizeStageFinishedAt.map { $0.timeIntervalSince(startedAt) } - } - let scanStatus: String - - if !discoveryFinished { - scanStatus = indexedItemCount == 0 - ? "Scanning Minecraft library..." - : "Found \(indexedItemCount) items. Loading metadata..." - - return SourceIndexSnapshot( - displayItems: dedupedDisplayItems, - rawItems: rawItems, - logicalPacks: logicalPacks, - logicalWorlds: [], - packInstances: [], - worldPackRelationships: [], - indexedItemCount: indexedItemCount, - indexedDetailCount: indexedDetailCount, - previewLoadedCount: previewLoadedCount, - sizeLoadedCount: sizeLoadedCount, - scanStatus: scanStatus, - scanProgress: nil, - isScanning: true, - previewStageElapsed: previewStageElapsed, - previewStageDuration: previewStageDuration, - sizeStageElapsed: sizeStageElapsed, - sizeStageDuration: sizeStageDuration, - lastScanDate: nil - ) - } - - if !metadataFinished { - scanStatus = indexedItemCount == 0 - ? "No Minecraft items found." - : "Loading metadata for \(indexedDetailCount) of \(indexedItemCount) items..." - - return SourceIndexSnapshot( - displayItems: dedupedDisplayItems, - rawItems: rawItems, - logicalPacks: logicalPacks, - logicalWorlds: [], - packInstances: [], - worldPackRelationships: [], - indexedItemCount: indexedItemCount, - indexedDetailCount: indexedDetailCount, - previewLoadedCount: previewLoadedCount, - sizeLoadedCount: sizeLoadedCount, - scanStatus: scanStatus, - scanProgress: progressAfterDiscovery(metadataFraction), - isScanning: true, - previewStageElapsed: previewStageElapsed, - previewStageDuration: previewStageDuration, - sizeStageElapsed: sizeStageElapsed, - sizeStageDuration: sizeStageDuration, - lastScanDate: nil - ) - } - - if !previewsFinished { - if indexedItemCount == 0 { - scanStatus = "No Minecraft items found." - } else if previewLoadedCount == 0 { - scanStatus = "Preparing previews..." - } else { - scanStatus = "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." - } - - return SourceIndexSnapshot( - displayItems: dedupedDisplayItems, - rawItems: rawItems, - logicalPacks: logicalPacks, - logicalWorlds: [], - packInstances: [], - worldPackRelationships: [], - indexedItemCount: indexedItemCount, - indexedDetailCount: indexedDetailCount, - previewLoadedCount: previewLoadedCount, - sizeLoadedCount: sizeLoadedCount, - scanStatus: scanStatus, - scanProgress: progressAfterMetadata(previewFraction), - isScanning: true, - previewStageElapsed: previewStageElapsed, - previewStageDuration: previewStageDuration, - sizeStageElapsed: sizeStageElapsed, - sizeStageDuration: sizeStageDuration, - lastScanDate: nil - ) - } - - let rawWorlds = rawItems.filter { $0.contentType == .world } - var packInstances: [PackInstance] = [] - for logicalPack in logicalPacks { - for itemID in logicalPack.instanceItemIDs { - guard let item = rawItemsByID[itemID] else { - continue - } - - packInstances.append( - PackInstance( - id: item.id, - itemID: item.id, - sourceID: sourceID, - logicalPackID: logicalPack.id, - origin: isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection, - hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds) - ) - ) - } - } - - let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id.canonicalKey, $0) }) - var worldRelationships: [WorldPackRelationship] = [] - var logicalWorlds: [LogicalWorld] = [] - - for world in rawWorlds { - var usedPackIDsByID: [String: PackIdentity] = [:] - var unresolvedReferences: [ContentPackReference] = [] - - for reference in world.packReferences { - let referenceIdentity = PackIdentity( - type: reference.type, - uuid: reference.uuid, - version: reference.version, - fallbackName: reference.name, - fallbackLocationHint: world.folderName - ) - let resolvedID = logicalPacksByID[referenceIdentity.canonicalKey]?.id - - if let resolvedID { - usedPackIDsByID[resolvedID.id] = resolvedID - } else { - unresolvedReferences.append(reference) - } - - worldRelationships.append( - WorldPackRelationship( - worldItemID: world.id, - logicalPackID: resolvedID, - reference: reference - ) - ) - } - - logicalWorlds.append( - LogicalWorld( - id: world.id, - itemID: world.id, - usedPackIDs: usedPackIDsByID.values.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, - unresolvedReferences: unresolvedReferences - ) - ) - } - - logicalWorlds.sort { - guard - let lhs = rawItemsByID[$0.itemID], - let rhs = rawItemsByID[$1.itemID] - else { - return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending - } - - return WorldScanner.sortItems(lhs, rhs) - } - - if !sizesFinished { - if indexedItemCount == 0 { - scanStatus = "No Minecraft items found." - } else if sizeLoadedCount == 0 { - scanStatus = "Preparing size calculations..." - } else { - scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." - } - } else { - scanStatus = indexedItemCount == 0 - ? "No Minecraft items found." - : "Loaded \(indexedDetailCount) items." - } - - return SourceIndexSnapshot( - displayItems: dedupedDisplayItems, - rawItems: rawItems, - logicalPacks: logicalPacks, - logicalWorlds: logicalWorlds, - packInstances: packInstances.sorted { - $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending - }, - worldPackRelationships: worldRelationships, - indexedItemCount: indexedItemCount, - indexedDetailCount: indexedDetailCount, - previewLoadedCount: previewLoadedCount, - sizeLoadedCount: sizeLoadedCount, - scanStatus: scanStatus, - scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction), - isScanning: !sizesFinished, - previewStageElapsed: previewStageElapsed, - previewStageDuration: previewStageDuration, - sizeStageElapsed: sizeStageElapsed, - sizeStageDuration: sizeStageDuration, - lastScanDate: sizesFinished ? now : nil - ) - } - - private func progressFraction(completed: Int, total: Int) -> Double { - guard total > 0 else { - return 1 - } - - return min(max(Double(completed) / Double(total), 0), 1) - } - - private func progressAfterDiscovery(_ metadataFraction: Double) -> Double { - 0.1 + (metadataFraction * 0.55) - } - - private func progressAfterMetadata(_ previewFraction: Double) -> Double { - 0.65 + (previewFraction * 0.1) - } - - private func progressAfterPreviews(_ sizeFraction: Double) -> Double { - 0.75 + (sizeFraction * 0.25) - } - - private func buildDisplayItems( - from rawItems: [MinecraftContentItem], - logicalPacks: [LogicalPack], - rawItemsByID: [URL: MinecraftContentItem] - ) -> [MinecraftContentItem] { - var normalizedItemIDs = Set() - var normalizedItems: [MinecraftContentItem] = [] - - for item in rawItems where item.contentType == .world { - guard normalizedItemIDs.insert(item.id).inserted else { - continue - } - - normalizedItems.append(item) - } - - for logicalPack in logicalPacks { - guard - let item = rawItemsByID[logicalPack.representativeItemID], - normalizedItemIDs.insert(item.id).inserted - else { - continue - } - - normalizedItems.append(item) - } - - for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate { - guard normalizedItemIDs.insert(item.id).inserted else { - continue - } - - normalizedItems.append(item) - } - - return normalizedItems - } - - private func buildRawDisplayItems(from rawItems: [MinecraftContentItem]) -> [MinecraftContentItem] { - rawItems.sorted(by: WorldScanner.sortItems) - } - - private func buildLogicalPacks(rawItemsByID: [URL: MinecraftContentItem]) -> [LogicalPack] { - packItemIDsByIdentityID.keys.sorted { - let lhs = packRepresentativeItemIDByIdentityID[$0].flatMap { rawItemsByID[$0]?.displayName } ?? "" - let rhs = packRepresentativeItemIDByIdentityID[$1].flatMap { rawItemsByID[$0]?.displayName } ?? "" - let nameOrder = lhs.localizedStandardCompare(rhs) - if nameOrder != .orderedSame { - return nameOrder == .orderedAscending - } - - return $0.localizedStandardCompare($1) == .orderedAscending - }.compactMap { identityID in - guard - let identity = packIdentityValueByID[identityID], - let representativeItemID = packRepresentativeItemIDByIdentityID[identityID], - let representativeItem = rawItemsByID[representativeItemID], - let instanceItemIDs = packItemIDsByIdentityID[identityID] - else { - return nil - } - - let metadata = packMetadataByItemID[representativeItemID] - - return LogicalPack( - id: identity, - contentType: identity.type, - displayName: representativeItem.displayName, - uuid: metadata?.uuid, - version: metadata?.version, - representativeItemID: representativeItemID, - instanceItemIDs: instanceItemIDs.sorted { - $0.path.localizedStandardCompare($1.path) == .orderedAscending - }, - isSuspicious: identity.isSuspicious - ) - } - } - - private func packMetadata(for item: MinecraftContentItem) -> PackMetadata { - let uuid = item.packUUID - let version = item.packVersion - - return PackMetadata( - uuid: uuid, - version: version, - identity: PackIdentity( - type: item.contentType, - uuid: uuid, - version: version, - fallbackName: item.displayName, - fallbackLocationHint: relativePathHint(for: item) - ) - ) - } - - private func relativePathHint(for item: MinecraftContentItem) -> String { - item.folderURL.path.replacingOccurrences(of: folderURL.path + "/", with: "") - } - private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { let candidateEmbedded = isEmbeddedWorldPack(candidate) let existingEmbedded = isEmbeddedWorldPack(existing) @@ -2015,84 +1122,3 @@ private actor SourceIndexActor { packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") })?.id } - - private func refreshTrackedPackIdentity(for item: MinecraftContentItem, previousItem: MinecraftContentItem?) { - let previousIdentityID = packIdentityByItemID[item.id] - let newMetadata = packMetadata(for: item) - let newIdentity = newMetadata.identity - let newIdentityID = newIdentity.canonicalKey - - packMetadataByItemID[item.id] = newMetadata - packIdentityByItemID[item.id] = newIdentityID - packIdentityValueByID[newIdentityID] = newIdentity - - if let previousIdentityID, previousIdentityID != newIdentityID { - removePackItem(itemID: item.id, fromIdentityID: previousIdentityID) - } - - packItemIDsByIdentityID[newIdentityID, default: []].insert(item.id) - refreshRepresentative(forIdentityID: newIdentityID) - - if let previousItem, previousIdentityID == newIdentityID { - guard - let representativeItemID = packRepresentativeItemIDByIdentityID[newIdentityID], - let currentRepresentative = itemsByID[representativeItemID] - else { - return - } - - if shouldPreferPackItem(item, over: currentRepresentative) || representativeItemID == item.id { - refreshRepresentative(forIdentityID: newIdentityID) - } else if representativeItemID == previousItem.id { - refreshRepresentative(forIdentityID: newIdentityID) - } - } - } - - private func removePackItem(itemID: URL, fromIdentityID identityID: String) { - guard var itemIDs = packItemIDsByIdentityID[identityID] else { - return - } - - itemIDs.remove(itemID) - if itemIDs.isEmpty { - packItemIDsByIdentityID[identityID] = nil - packRepresentativeItemIDByIdentityID[identityID] = nil - packIdentityValueByID[identityID] = nil - } else { - packItemIDsByIdentityID[identityID] = itemIDs - if packRepresentativeItemIDByIdentityID[identityID] == itemID { - refreshRepresentative(forIdentityID: identityID) - } - } - } - - private func refreshRepresentative(forIdentityID identityID: String) { - guard let itemIDs = packItemIDsByIdentityID[identityID] else { - packRepresentativeItemIDByIdentityID[identityID] = nil - return - } - - let candidateIDs = itemIDs.sorted { - $0.path.localizedStandardCompare($1.path) == .orderedAscending - } - - guard - let firstID = candidateIDs.first, - let firstItem = itemsByID[firstID] - else { - packRepresentativeItemIDByIdentityID[identityID] = nil - return - } - - let representative = candidateIDs.dropFirst().compactMap { itemsByID[$0] }.reduce(firstItem) { current, candidate in - shouldPreferPackItem(candidate, over: current) ? candidate : current - } - - packRepresentativeItemIDByIdentityID[identityID] = representative.id - } - - private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { - contentType == .behaviorPack || contentType == .resourcePack - } -} diff --git a/World Manager for Minecraft/Services/SourceScanExecution.swift b/World Manager for Minecraft/Services/SourceScanExecution.swift new file mode 100644 index 0000000..03d6b58 --- /dev/null +++ b/World Manager for Minecraft/Services/SourceScanExecution.swift @@ -0,0 +1,1047 @@ +// +// SourceScanExecution.swift +// World Manager for Minecraft +// +// Created by OpenAI Codex on 2026-05-29. +// + +import Foundation + +@MainActor +protocol SourceScanSessionHosting: AnyObject { + func source(withID sourceID: URL) -> MinecraftSource? + func updateSource(_ sourceID: URL, mutate: (inout MinecraftSource) -> Void) + func applySnapshot(_ snapshot: SourceIndexSnapshot, to sourceID: URL) + func persistSourceIfAvailable(withID sourceID: URL) + func logScanStage(_ stage: String, elapsed: TimeInterval, context: String, itemCount: Int) +} + +enum SourceScanExecutor { + static func execute( + sourceID: URL, + mode: SourceDiscoveryMode, + source: MinecraftSource, + host: SourceScanSessionHosting, + sourceAccessMethod: SourceAccessMethod, + notificationService: ScanNotificationServicing, + enrichmentWorkerCount: Int, + sizeWorkerCount: Int, + minimumVisibleScanDuration: TimeInterval + ) async { + var workerTasks: [Task] = [] + var sizeWorkerTasks: [Task] = [] + let scanStartTime = Date() + defer { + workerTasks.forEach { $0.cancel() } + sizeWorkerTasks.forEach { $0.cancel() } + } + + let previousSource = source + let performanceContext = SourceScanPolicy.performanceContext(for: source) + + await host.updateSource(sourceID) { source in + source.isScanning = true + source.scanError = nil + source.scanDiagnostic = nil + source.scanStatus = SourceScanPolicy.initialStatus(for: source, mode: mode) + source.scanProgress = nil + source.indexedItemCount = 0 + source.indexedDetailCount = 0 + source.previewLoadedCount = 0 + source.sizeLoadedCount = 0 + } + + await host.updateSource(sourceID) { source in + source.accessDescriptor = sourceAccessMethod.accessDescriptor(for: source) + } + let currentAvailability = await sourceAccessMethod.availability(for: source) + await host.updateSource(sourceID) { source in + source.availability = currentAvailability + } + + let scanContextURL = source.folderURL + await WorldScanner.beginScanSession(for: scanContextURL) + defer { + Task.detached(priority: .utility) { + await WorldScanner.endScanSession(for: scanContextURL) + } + } + + await host.updateSource(sourceID) { source in + source.availability = .available + source.scanStatus = SourceScanPolicy.scanningLibraryStatus(for: source, mode: mode) + } + + do { + let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL) + let enrichmentQueue = EnrichmentWorkQueue() + let resolvedEnrichmentWorkerCount = source.origin.kind == .connectedDevice ? 1 : enrichmentWorkerCount + let resolvedSizeWorkerCount = source.origin.kind == .connectedDevice ? 1 : sizeWorkerCount + workerTasks = (0.. { continuation in + let discoveryTask = Task.detached(priority: .userInitiated) { + do { + try await sourceAccessMethod.discoverItems(for: source, mode: mode) { item in + continuation.yield(item) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { @Sendable _ in + discoveryTask.cancel() + } + } + + let previousItemsByID = Dictionary(uniqueKeysWithValues: previousSource.rawItems.map { ($0.id, $0) }) + let previousSnapshotByItemID = Dictionary( + uniqueKeysWithValues: (previousSource.snapshot?.itemSnapshots ?? []).map { ($0.id, $0) } + ) + let shouldReconcileFromCache = mode == .reconcile && previousSource.hasCachedContent + + var discoveredCount = 0 + var discoveredCollectionNames = Set() + let discoveryStartTime = Date() + + for try await item in discoveryStream { + guard !Task.isCancelled else { + break + } + + discoveredCount += 1 + discoveredCollectionNames.insert(item.collectionRootURL.lastPathComponent) + let itemForIndex: MinecraftContentItem + if shouldReconcileFromCache, + let cachedItem = previousItemsByID[item.id], + SourceScanPolicy.shouldReuseCachedItem( + cachedItem, + forDiscoveredItem: item, + source: source, + previousSnapshot: previousSnapshotByItemID[item.id] + ) { + itemForIndex = cachedItem + } else { + itemForIndex = item + } + + if let snapshot = await index.addDiscoveredItem( + itemForIndex, + discoveredCount: discoveredCount + ) { + await host.applySnapshot(snapshot, to: sourceID) + } + if itemForIndex.id == item.id, itemForIndex.metadataLoaded == false { + await enrichmentQueue.enqueue(item) + } + } + + if mode == .reconcile, source.origin.kind == .connectedDevice { + let cachedItemsByCollection = Dictionary(grouping: previousSource.rawItems) { item in + item.collectionRootURL.lastPathComponent + } + + for (collectionName, cachedItems) in cachedItemsByCollection { + guard !cachedItems.isEmpty else { + continue + } + + guard !discoveredCollectionNames.contains(collectionName) else { + continue + } + + for cachedItem in cachedItems { + discoveredCount += 1 + if let snapshot = await index.addDiscoveredItem( + cachedItem, + discoveredCount: discoveredCount + ) { + await host.applySnapshot(snapshot, to: sourceID) + } + } + } + } + + await host.logScanStage( + "Discovery", + elapsed: Date().timeIntervalSince(discoveryStartTime), + context: performanceContext, + itemCount: discoveredCount + ) + + if let snapshot = await index.markDiscoveryFinished() { + await host.applySnapshot(snapshot, to: sourceID) + } + await enrichmentQueue.finish() + let enrichmentStartTime = Date() + + for workerTask in workerTasks { + await workerTask.value + } + + await host.logScanStage( + "Enrichment", + elapsed: Date().timeIntervalSince(enrichmentStartTime), + context: performanceContext, + itemCount: discoveredCount + ) + + if let snapshot = await index.markMetadataFinished() { + await host.applySnapshot(snapshot, to: sourceID) + } + await host.persistSourceIfAvailable(withID: sourceID) + + let previewStageStartTime = Date() + let previewSeedItems = await index.currentItems() + let previewItems = await sourceAccessMethod.loadPreviewAssets( + for: previewSeedItems.filter { !$0.previewLoaded }, + in: source + ) + for previewItem in previewItems { + if let snapshot = await index.applyPreviewItem(previewItem) { + await host.applySnapshot(snapshot, to: sourceID) + } + } + + await host.logScanStage( + "Previews", + elapsed: Date().timeIntervalSince(previewStageStartTime), + context: performanceContext, + itemCount: discoveredCount + ) + + if let snapshot = await index.markPreviewsFinished() { + await host.applySnapshot(snapshot, to: sourceID) + } + await host.persistSourceIfAvailable(withID: sourceID) + + if source.origin.kind == .connectedDevice { + try await finishConnectedDeviceScan( + sourceID: sourceID, + source: source, + host: host, + sourceAccessMethod: sourceAccessMethod, + notificationService: notificationService, + index: index, + discoveredCount: discoveredCount, + scanStartTime: scanStartTime, + scanContextURL: scanContextURL, + performanceContext: performanceContext, + minimumVisibleScanDuration: minimumVisibleScanDuration + ) + return + } + + let sizeQueue = EnrichmentWorkQueue() + sizeWorkerTasks = (0..] = [] + + func enqueue(_ item: MinecraftContentItem) { + if let continuation = waitingContinuations.first { + waitingContinuations.removeFirst() + continuation.resume(returning: item) + return + } + + pendingItems.append(item) + } + + func next() async -> MinecraftContentItem? { + if !pendingItems.isEmpty { + return pendingItems.removeFirst() + } + + if isFinished { + return nil + } + + return await withCheckedContinuation { continuation in + waitingContinuations.append(continuation) + } + } + + func finish() { + isFinished = true + + for continuation in waitingContinuations { + continuation.resume(returning: nil) + } + + waitingContinuations.removeAll() + } +} + +struct SourceIndexSnapshot { + let displayItems: [MinecraftContentItem] + let rawItems: [MinecraftContentItem] + let logicalPacks: [LogicalPack] + let logicalWorlds: [LogicalWorld] + let packInstances: [PackInstance] + let worldPackRelationships: [WorldPackRelationship] + let indexedItemCount: Int + let indexedDetailCount: Int + let previewLoadedCount: Int + let sizeLoadedCount: Int + let scanStatus: String + let scanProgress: Double? + let isScanning: Bool + let previewStageElapsed: TimeInterval? + let previewStageDuration: TimeInterval? + let sizeStageElapsed: TimeInterval? + let sizeStageDuration: TimeInterval? + let lastScanDate: Date? +} + +private actor SourceIndexActor { + private let sourceID: URL + private let folderURL: URL + private let publishInterval: TimeInterval = 0.12 + + private var orderedItemIDs: [URL] = [] + private var itemsByID: [URL: MinecraftContentItem] = [:] + private var packMetadataByItemID: [URL: PackMetadata] = [:] + private var packIdentityByItemID: [URL: String] = [:] + private var packIdentityValueByID: [String: PackIdentity] = [:] + private var packItemIDsByIdentityID: [String: Set] = [:] + private var packRepresentativeItemIDByIdentityID: [String: URL] = [:] + private var indexedItemCount = 0 + private var indexedDetailCount = 0 + private var previewLoadedCount = 0 + private var sizeLoadedCount = 0 + private var discoveryFinished = false + private var metadataFinished = false + private var previewsFinished = false + private var sizesFinished = false + private var previewStageStartedAt: Date? + private var previewStageFinishedAt: Date? + private var sizeStageStartedAt: Date? + private var sizeStageFinishedAt: Date? + private var lastPublishedAt: Date? + + init(sourceID: URL, folderURL: URL) { + self.sourceID = sourceID + self.folderURL = folderURL + } + + func addDiscoveredItem(_ item: MinecraftContentItem, discoveredCount: Int) -> SourceIndexSnapshot? { + orderedItemIDs.append(item.id) + itemsByID[item.id] = item + indexedItemCount = discoveredCount + if item.metadataLoaded { + indexedDetailCount += 1 + } + if item.previewLoaded { + previewLoadedCount += 1 + } + if item.sizeLoaded { + sizeLoadedCount += 1 + } + return snapshotIfNeeded() + } + + func applyEnrichedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { + let previous = itemsByID[item.id] + itemsByID[item.id] = item + if item.metadataLoaded, previous?.metadataLoaded != true { + indexedDetailCount += 1 + } + + if isLogicalPackType(item.contentType) { + refreshTrackedPackIdentity(for: item, previousItem: previous) + } + + return snapshotIfNeeded() + } + + func applySizedItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { + guard var current = itemsByID[item.id] else { + return nil + } + + let wasSizeLoaded = current.sizeLoaded + current.sizeBytes = item.sizeBytes + current.sizeLoaded = item.sizeLoaded + itemsByID[item.id] = current + if item.sizeLoaded, wasSizeLoaded != true { + sizeLoadedCount += 1 + } + return snapshotIfNeeded() + } + + func applyPreviewItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? { + let previous = itemsByID[item.id] + itemsByID[item.id] = item + if item.previewLoaded, previous?.previewLoaded != true { + previewLoadedCount += 1 + } + + return snapshotIfNeeded() + } + + func markDiscoveryFinished() -> SourceIndexSnapshot? { + discoveryFinished = true + return buildSnapshot(force: true) + } + + func markMetadataFinished() -> SourceIndexSnapshot? { + discoveryFinished = true + metadataFinished = true + previewStageStartedAt = previewStageStartedAt ?? Date() + return buildSnapshot(force: true) + } + + func markPreviewsFinished() -> SourceIndexSnapshot? { + discoveryFinished = true + metadataFinished = true + previewsFinished = true + let now = Date() + previewStageStartedAt = previewStageStartedAt ?? now + previewStageFinishedAt = previewStageFinishedAt ?? now + sizeStageStartedAt = sizeStageStartedAt ?? now + return buildSnapshot(force: true) + } + + func finishScan() -> SourceIndexSnapshot? { + discoveryFinished = true + metadataFinished = true + previewsFinished = true + sizesFinished = true + let now = Date() + previewStageFinishedAt = previewStageFinishedAt ?? now + sizeStageStartedAt = sizeStageStartedAt ?? now + sizeStageFinishedAt = sizeStageFinishedAt ?? now + return buildSnapshot(force: true) + } + + func currentItems() -> [MinecraftContentItem] { + orderedItemIDs.compactMap { itemsByID[$0] } + } + + private func snapshotIfNeeded() -> SourceIndexSnapshot? { + buildSnapshot(force: false) + } + + private func buildSnapshot(force: Bool) -> SourceIndexSnapshot? { + let now = Date() + if !force, let lastPublishedAt, now.timeIntervalSince(lastPublishedAt) < publishInterval { + return nil + } + + lastPublishedAt = now + + let rawItems = orderedItemIDs.compactMap { itemsByID[$0] } + let rawItemsByID = Dictionary(uniqueKeysWithValues: rawItems.map { ($0.id, $0) }) + let logicalPacks = buildLogicalPacks(rawItemsByID: rawItemsByID) + let dedupedDisplayItems = buildDisplayItems( + from: rawItems, + logicalPacks: logicalPacks, + rawItemsByID: rawItemsByID + ) + let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount) + let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount) + let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount) + let previewStageElapsed = previewStageStartedAt.map { now.timeIntervalSince($0) } + let previewStageDuration = previewStageStartedAt.flatMap { startedAt in + previewStageFinishedAt.map { $0.timeIntervalSince(startedAt) } + } + let sizeStageElapsed = sizeStageStartedAt.map { now.timeIntervalSince($0) } + let sizeStageDuration = sizeStageStartedAt.flatMap { startedAt in + sizeStageFinishedAt.map { $0.timeIntervalSince(startedAt) } + } + let scanStatus: String + + if !discoveryFinished { + scanStatus = indexedItemCount == 0 + ? "Scanning Minecraft library..." + : "Found \(indexedItemCount) items. Loading metadata..." + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: [], + packInstances: [], + worldPackRelationships: [], + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, + scanStatus: scanStatus, + scanProgress: nil, + isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, + lastScanDate: nil + ) + } + + if !metadataFinished { + scanStatus = indexedItemCount == 0 + ? "No Minecraft items found." + : "Loading metadata for \(indexedDetailCount) of \(indexedItemCount) items..." + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: [], + packInstances: [], + worldPackRelationships: [], + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, + scanStatus: scanStatus, + scanProgress: progressAfterDiscovery(metadataFraction), + isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, + lastScanDate: nil + ) + } + + if !previewsFinished { + if indexedItemCount == 0 { + scanStatus = "No Minecraft items found." + } else if previewLoadedCount == 0 { + scanStatus = "Preparing previews..." + } else { + scanStatus = "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..." + } + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: [], + packInstances: [], + worldPackRelationships: [], + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, + scanStatus: scanStatus, + scanProgress: progressAfterMetadata(previewFraction), + isScanning: true, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, + lastScanDate: nil + ) + } + + let rawWorlds = rawItems.filter { $0.contentType == .world } + var packInstances: [PackInstance] = [] + for logicalPack in logicalPacks { + for itemID in logicalPack.instanceItemIDs { + guard let item = rawItemsByID[itemID] else { + continue + } + + packInstances.append( + PackInstance( + id: item.id, + itemID: item.id, + sourceID: sourceID, + logicalPackID: logicalPack.id, + origin: isEmbeddedWorldPack(item) ? .embeddedInWorld : .foundInCollection, + hostWorldItemID: hostWorldItemID(for: item, in: rawWorlds) + ) + ) + } + } + + let logicalPacksByID = Dictionary(uniqueKeysWithValues: logicalPacks.map { ($0.id.canonicalKey, $0) }) + var worldRelationships: [WorldPackRelationship] = [] + var logicalWorlds: [LogicalWorld] = [] + + for world in rawWorlds { + var usedPackIDsByID: [String: PackIdentity] = [:] + var unresolvedReferences: [ContentPackReference] = [] + + for reference in world.packReferences { + let referenceIdentity = PackIdentity( + type: reference.type, + uuid: reference.uuid, + version: reference.version, + fallbackName: reference.name, + fallbackLocationHint: world.folderName + ) + let resolvedID = logicalPacksByID[referenceIdentity.canonicalKey]?.id + + if let resolvedID { + usedPackIDsByID[resolvedID.id] = resolvedID + } else { + unresolvedReferences.append(reference) + } + + worldRelationships.append( + WorldPackRelationship( + worldItemID: world.id, + logicalPackID: resolvedID, + reference: reference + ) + ) + } + + logicalWorlds.append( + LogicalWorld( + id: world.id, + itemID: world.id, + usedPackIDs: usedPackIDsByID.values.sorted { $0.id.localizedStandardCompare($1.id) == .orderedAscending }, + unresolvedReferences: unresolvedReferences + ) + ) + } + + logicalWorlds.sort { + guard + let lhs = rawItemsByID[$0.itemID], + let rhs = rawItemsByID[$1.itemID] + else { + return $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + } + + return WorldScanner.sortItems(lhs, rhs) + } + + if !sizesFinished { + if indexedItemCount == 0 { + scanStatus = "No Minecraft items found." + } else if sizeLoadedCount == 0 { + scanStatus = "Preparing size calculations..." + } else { + scanStatus = "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..." + } + } else { + scanStatus = indexedItemCount == 0 + ? "No Minecraft items found." + : "Loaded \(indexedDetailCount) items." + } + + return SourceIndexSnapshot( + displayItems: dedupedDisplayItems, + rawItems: rawItems, + logicalPacks: logicalPacks, + logicalWorlds: logicalWorlds, + packInstances: packInstances.sorted { + $0.itemID.path.localizedStandardCompare($1.itemID.path) == .orderedAscending + }, + worldPackRelationships: worldRelationships, + indexedItemCount: indexedItemCount, + indexedDetailCount: indexedDetailCount, + previewLoadedCount: previewLoadedCount, + sizeLoadedCount: sizeLoadedCount, + scanStatus: scanStatus, + scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction), + isScanning: !sizesFinished, + previewStageElapsed: previewStageElapsed, + previewStageDuration: previewStageDuration, + sizeStageElapsed: sizeStageElapsed, + sizeStageDuration: sizeStageDuration, + lastScanDate: sizesFinished ? now : nil + ) + } + + private func progressFraction(completed: Int, total: Int) -> Double { + guard total > 0 else { + return 1 + } + + return min(max(Double(completed) / Double(total), 0), 1) + } + + private func progressAfterDiscovery(_ metadataFraction: Double) -> Double { + 0.1 + (metadataFraction * 0.55) + } + + private func progressAfterMetadata(_ previewFraction: Double) -> Double { + 0.65 + (previewFraction * 0.1) + } + + private func progressAfterPreviews(_ sizeFraction: Double) -> Double { + 0.75 + (sizeFraction * 0.25) + } + + private func buildDisplayItems( + from rawItems: [MinecraftContentItem], + logicalPacks: [LogicalPack], + rawItemsByID: [URL: MinecraftContentItem] + ) -> [MinecraftContentItem] { + var normalizedItemIDs = Set() + var normalizedItems: [MinecraftContentItem] = [] + + for item in rawItems where item.contentType == .world { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + for logicalPack in logicalPacks { + guard + let item = rawItemsByID[logicalPack.representativeItemID], + normalizedItemIDs.insert(item.id).inserted + else { + continue + } + + normalizedItems.append(item) + } + + for item in rawItems where item.contentType == .skinPack || item.contentType == .worldTemplate { + guard normalizedItemIDs.insert(item.id).inserted else { + continue + } + + normalizedItems.append(item) + } + + return normalizedItems + } + + private func buildLogicalPacks(rawItemsByID: [URL: MinecraftContentItem]) -> [LogicalPack] { + packItemIDsByIdentityID.keys.sorted { + let lhs = packRepresentativeItemIDByIdentityID[$0].flatMap { rawItemsByID[$0]?.displayName } ?? "" + let rhs = packRepresentativeItemIDByIdentityID[$1].flatMap { rawItemsByID[$0]?.displayName } ?? "" + let nameOrder = lhs.localizedStandardCompare(rhs) + if nameOrder != .orderedSame { + return nameOrder == .orderedAscending + } + + return $0.localizedStandardCompare($1) == .orderedAscending + }.compactMap { identityID in + guard + let identity = packIdentityValueByID[identityID], + let representativeItemID = packRepresentativeItemIDByIdentityID[identityID], + let representativeItem = rawItemsByID[representativeItemID], + let instanceItemIDs = packItemIDsByIdentityID[identityID] + else { + return nil + } + + let metadata = packMetadataByItemID[representativeItemID] + + return LogicalPack( + id: identity, + contentType: identity.type, + displayName: representativeItem.displayName, + uuid: metadata?.uuid, + version: metadata?.version, + representativeItemID: representativeItemID, + instanceItemIDs: instanceItemIDs.sorted { + $0.path.localizedStandardCompare($1.path) == .orderedAscending + }, + isSuspicious: identity.isSuspicious + ) + } + } + + private func refreshTrackedPackIdentity( + for item: MinecraftContentItem, + previousItem: MinecraftContentItem? + ) { + let metadata = PackMetadata( + uuid: item.packUUID, + version: item.packVersion, + identity: PackIdentity( + type: item.contentType, + uuid: item.packUUID, + version: item.packVersion, + fallbackName: item.displayName, + fallbackLocationHint: item.folderURL.path.replacingOccurrences(of: folderURL.path + "/", with: "") + ) + ) + let newIdentityID = metadata.identity.canonicalKey + + packMetadataByItemID[item.id] = metadata + packIdentityValueByID[newIdentityID] = metadata.identity + + if let previousIdentityID = packIdentityByItemID[item.id], previousIdentityID != newIdentityID { + packItemIDsByIdentityID[previousIdentityID]?.remove(item.id) + if packItemIDsByIdentityID[previousIdentityID]?.isEmpty == true { + packItemIDsByIdentityID[previousIdentityID] = nil + packRepresentativeItemIDByIdentityID[previousIdentityID] = nil + } else if packRepresentativeItemIDByIdentityID[previousIdentityID] == item.id { + packRepresentativeItemIDByIdentityID[previousIdentityID] = bestRepresentativeItemID( + within: packItemIDsByIdentityID[previousIdentityID] ?? [], + currentRepresentativeID: nil + ) + } + } + + packIdentityByItemID[item.id] = newIdentityID + packItemIDsByIdentityID[newIdentityID, default: []].insert(item.id) + packRepresentativeItemIDByIdentityID[newIdentityID] = bestRepresentativeItemID( + within: packItemIDsByIdentityID[newIdentityID] ?? [], + currentRepresentativeID: packRepresentativeItemIDByIdentityID[newIdentityID] + ) + + if previousItem == nil, packRepresentativeItemIDByIdentityID[newIdentityID] == nil { + packRepresentativeItemIDByIdentityID[newIdentityID] = item.id + } + } + + private func bestRepresentativeItemID( + within itemIDs: Set, + currentRepresentativeID: URL? + ) -> URL? { + var chosenID = currentRepresentativeID + + for itemID in itemIDs { + guard let candidate = itemsByID[itemID] else { + continue + } + + guard let existingID = chosenID, let existing = itemsByID[existingID] else { + chosenID = itemID + continue + } + + if shouldPreferPackItem(candidate, over: existing) { + chosenID = itemID + } + } + + return chosenID + } + + private func shouldPreferPackItem(_ candidate: MinecraftContentItem, over existing: MinecraftContentItem) -> Bool { + let candidateEmbedded = isEmbeddedWorldPack(candidate) + let existingEmbedded = isEmbeddedWorldPack(existing) + + if candidateEmbedded != existingEmbedded { + return !candidateEmbedded + } + + if candidate.metadataLoaded != existing.metadataLoaded { + return candidate.metadataLoaded + } + + if (candidate.iconURL != nil) != (existing.iconURL != nil) { + return candidate.iconURL != nil + } + + if candidate.previewLoaded != existing.previewLoaded { + return candidate.previewLoaded + } + + if candidate.modifiedDate != existing.modifiedDate { + return (candidate.modifiedDate ?? .distantPast) > (existing.modifiedDate ?? .distantPast) + } + + return candidate.folderURL.path.localizedStandardCompare(existing.folderURL.path) == .orderedAscending + } + + private func isEmbeddedWorldPack(_ item: MinecraftContentItem) -> Bool { + item.folderURL.pathComponents.contains(MinecraftContentType.world.collectionFolderName) + } + + private func hostWorldItemID(for packItem: MinecraftContentItem, in rawWorlds: [MinecraftContentItem]) -> URL? { + rawWorlds.first(where: { world in + packItem.folderURL.path.hasPrefix(world.folderURL.path + "/") + })?.id + } + + private func isLogicalPackType(_ contentType: MinecraftContentType) -> Bool { + contentType == .behaviorPack || contentType == .resourcePack + } +}