diff --git a/BitMatchTests/AdvisoryMetadataBoundsTests.swift b/BitMatchTests/AdvisoryMetadataBoundsTests.swift new file mode 100644 index 00000000..fb978a04 --- /dev/null +++ b/BitMatchTests/AdvisoryMetadataBoundsTests.swift @@ -0,0 +1,217 @@ +import Darwin +import Foundation +import Testing +@testable import BitMatch + +struct AdvisoryMetadataBoundsTests { + @Test func smallAndExactlyLimitedSidecarsAreReadCompletely() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let small = dir.appendingPathComponent("small.xml") + let exact = dir.appendingPathComponent("exact.xml") + let smallData = Data("FX3".utf8) + let exactData = Data(repeating: 0x41, count: AdvisoryMetadataFileReader.maximumBytes) + try smallData.write(to: small) + try exactData.write(to: exact) + + #expect(AdvisoryMetadataFileReader.read(small) == smallData) + #expect(AdvisoryMetadataFileReader.read(exact) == exactData) + } + + @Test func oversizedAndLinkedSidecarsAreSkipped() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let oversized = dir.appendingPathComponent("oversized.xml") + try makeSparseFile(oversized, bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + let target = dir.appendingPathComponent("target.xml") + try write("FX3", to: target) + let linked = dir.appendingPathComponent("linked.xml") + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: target) + let pipe = dir.appendingPathComponent("pipe.xml") + try #require(pipe.path.withCString { mkfifo($0, 0o600) } == 0) + + #expect(AdvisoryMetadataFileReader.read(oversized) == nil) + #expect(AdvisoryMetadataFileReader.read(linked) == nil) + #expect(AdvisoryMetadataFileReader.read(pipe) == nil) + } + + @Test func cancellationDuringAChunkDropsTheWholeHint() async throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("large.xml") + try Data(repeating: 0x41, count: 128 * 1024).write(to: file) + + let read = Task.detached { + AdvisoryMetadataFileReader.read(file, onChunkRead: { + withUnsafeCurrentTask { $0?.cancel() } + }) + } + #expect(await read.value == nil) + } + + @Test func aSidecarTruncatedDuringReadIsNotParsedAsComplete() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let file = dir.appendingPathComponent("changing.xml") + try Data(repeating: 0x41, count: 128 * 1024).write(to: file) + var truncated = false + + let result = AdvisoryMetadataFileReader.read(file, onChunkRead: { + guard !truncated else { return } + truncated = true + if let handle = try? FileHandle(forWritingTo: file) { + try? handle.truncate(atOffset: 1024) + try? handle.close() + } + }) + #expect(truncated) + #expect(result == nil) + } + + @Test func sonySkipsOversizedFirstCandidateAndFindsLaterModel() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let first = dir.appendingPathComponent("PRIVATE/M4ROOT/MEDIAPRO.XML") + let second = dir.appendingPathComponent("XDROOT/MEDIAPRO.XML") + try makeSparseFile(first, bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("", to: second) + + #expect(SonyDetectionService.shared.detectSonyCamera(at: dir) == "Sony FX3") + } + + @Test func sonySkipsLinkedSidecarBeforeALaterRealModel() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let target = dir.appendingPathComponent("outside.xml") + try write("", to: target) + let linked = dir.appendingPathComponent("PRIVATE/M4ROOT/MEDIAPRO.XML") + try FileManager.default.createDirectory(at: linked.deletingLastPathComponent(), + withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: linked, withDestinationURL: target) + try write("", + to: dir.appendingPathComponent("XDROOT/MEDIAPRO.XML")) + + #expect(SonyDetectionService.shared.detectSonyCamera(at: dir) == "Sony FX3") + } + + @Test func anUnknownFirstSidecarDoesNotHideLaterModels() throws { + let sonyDir = try makeDirectory() + let canonDir = try makeDirectory() + let panasonicDir = try makeDirectory() + defer { + try? FileManager.default.removeItem(at: sonyDir) + try? FileManager.default.removeItem(at: canonDir) + try? FileManager.default.removeItem(at: panasonicDir) + } + + try write("", + to: sonyDir.appendingPathComponent("PRIVATE/M4ROOT/MEDIAPRO.XML")) + try write("", + to: sonyDir.appendingPathComponent("XDROOT/MEDIAPRO.XML")) + try write("unknown", to: canonDir.appendingPathComponent("MISC/CANON.INF")) + try write("XF705", to: canonDir.appendingPathComponent("MISC/CANONINF.DAT")) + try write("unknown", to: panasonicDir.appendingPathComponent("PRIVATE/PANASONIC/MISC/DPOF.DAT")) + try write("DC-GH6", to: panasonicDir.appendingPathComponent("MISC/PANASONIC.DAT")) + + #expect(SonyDetectionService.shared.detectSonyCamera(at: sonyDir) == "Sony FX3") + #expect(CanonDetectionService.shared.detectCanonCamera(at: canonDir) == "Canon XF705") + #expect(PanasonicDetectionService.shared.detectPanasonicCamera(at: panasonicDir) == "Panasonic GH6") + } + + @Test func aSingleUnknownSidecarStillYieldsItsGenericBrand() throws { + let sonyDir = try makeDirectory() + let canonDir = try makeDirectory() + let panasonicDir = try makeDirectory() + defer { + try? FileManager.default.removeItem(at: sonyDir) + try? FileManager.default.removeItem(at: canonDir) + try? FileManager.default.removeItem(at: panasonicDir) + } + try write("", + to: sonyDir.appendingPathComponent("PRIVATE/M4ROOT/MEDIAPRO.XML")) + try write("unknown", to: canonDir.appendingPathComponent("MISC/CANON.INF")) + try write("unknown", to: panasonicDir.appendingPathComponent("PRIVATE/PANASONIC/MISC/DPOF.DAT")) + + #expect(SonyDetectionService.shared.detectSonyCamera(at: sonyDir) == "Sony") + #expect(CanonDetectionService.shared.detectCanonCamera(at: canonDir) == "Canon") + #expect(PanasonicDetectionService.shared.detectPanasonicCamera(at: panasonicDir) == "Panasonic") + } + + @Test func canonAndPanasonicSkipOversizedFirstCandidates() throws { + let canonDir = try makeDirectory() + let panasonicDir = try makeDirectory() + defer { + try? FileManager.default.removeItem(at: canonDir) + try? FileManager.default.removeItem(at: panasonicDir) + } + try makeSparseFile(canonDir.appendingPathComponent("MISC/CANON.INF"), + bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("XF705", to: canonDir.appendingPathComponent("MISC/CANONINF.DAT")) + try makeSparseFile(panasonicDir.appendingPathComponent("PRIVATE/PANASONIC/MISC/DPOF.DAT"), + bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("DC-GH6", to: panasonicDir.appendingPathComponent("MISC/PANASONIC.DAT")) + + #expect(CanonDetectionService.shared.detectCanonCamera(at: canonDir) == "Canon XF705") + #expect(PanasonicDetectionService.shared.detectPanasonicCamera(at: panasonicDir) == "Panasonic GH6") + } + + @Test func arriSkipsOversizedAndGenericALEsBeforeAValidModel() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + try makeSparseFile(dir.appendingPathComponent("A-oversized.ale"), + bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("ARRI", to: dir.appendingPathComponent("B-generic.ale")) + try write("ALEXA Mini LF", to: dir.appendingPathComponent("C-model.ale")) + + #expect(ARRIDetectionService.shared.detectARRICamera(at: dir) == "ARRI Alexa Mini LF") + } + + @Test func largeRootAriDoesNotHideAValidFingerprintSidecar() throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + try makeSparseFile(dir.appendingPathComponent("A0001.ARI"), + bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("C70", + to: dir.appendingPathComponent("B0001.XMP")) + + let fingerprint = CameraMemoryService.shared.getCameraFingerprint(at: dir) + #expect(fingerprint?.manufacturer == "Canon") + #expect(fingerprint?.model == "C70") + #expect(fingerprint?.serialNumber == "12345") + } + + @Test func sharedCameraParserSkipsOversizeAndPreservesSmallXML() async throws { + let dir = try makeDirectory() + defer { try? FileManager.default.removeItem(at: dir) } + let oversized = dir.appendingPathComponent("oversized.xml") + let small = dir.appendingPathComponent("small.xml") + try makeSparseFile(oversized, bytes: AdvisoryMetadataFileReader.maximumBytes + 1) + try write("FX3", to: small) + let detector = SharedCameraDetectionService() + + let skipped = try await detector.parseXMLMetadata(from: oversized) + let parsed = try await detector.parseXMLMetadata(from: small) + #expect(skipped.isEmpty) + #expect(parsed["camera"] as? String == "FX3") + } + + private func makeDirectory() throws -> URL { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func write(_ text: String, to file: URL) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(text.utf8).write(to: file) + } + + private func makeSparseFile(_ file: URL, bytes: Int) throws { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + let created = FileManager.default.createFile(atPath: file.path, contents: Data()) + try #require(created) + let handle = try FileHandle(forWritingTo: file) + try handle.truncate(atOffset: UInt64(bytes)) + try handle.close() + } +} diff --git a/Shared/Core/Services/Camera/ARRIDetectionService.swift b/Shared/Core/Services/Camera/ARRIDetectionService.swift index 5722369c..a42e7962 100644 --- a/Shared/Core/Services/Camera/ARRIDetectionService.swift +++ b/Shared/Core/Services/Camera/ARRIDetectionService.swift @@ -16,27 +16,37 @@ final class ARRIDetectionService: Sendable { private func checkForALEFile(at url: URL) -> String? { let fm = FileManager.default + var foundGenericALE = false // Look for .ale files in the directory tree if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) { for case let fileURL as URL in enumerator { guard !Task.isCancelled else { return nil } if fileURL.pathExtension.lowercased() == "ale" { - return extractCameraFromALE(aleFile: fileURL) + if let camera = extractCameraFromALE(aleFile: fileURL) { + if camera == "ARRI" { + foundGenericALE = true + continue + } + return camera + } } } } - return nil + return Task.isCancelled ? nil : (foundGenericALE ? "ARRI" : nil) } private func extractCameraFromALE(aleFile: URL) -> String? { - do { - let aleContent = try String(contentsOf: aleFile, encoding: .utf8) + return autoreleasepool { + guard let data = AdvisoryMetadataFileReader.read(aleFile), + let aleContent = String(data: data, encoding: .utf8), + !Task.isCancelled else { return nil } let lines = aleContent.components(separatedBy: .newlines) // Look for camera information in ALE metadata for line in lines { + guard !Task.isCancelled else { return nil } let trimmedLine = line.trimmingCharacters(in: .whitespaces) // Check for ARRI camera models in metadata. ALE files mix @@ -74,9 +84,6 @@ final class ARRIDetectionService: Sendable { // If we found an ALE file but no specific camera model, assume ARRI return "ARRI" - - } catch { - return nil } } @@ -91,4 +98,4 @@ final class ARRIDetectionService: Sendable { return nil } -} \ No newline at end of file +} diff --git a/Shared/Core/Services/Camera/AdvisoryMetadataFileReader.swift b/Shared/Core/Services/Camera/AdvisoryMetadataFileReader.swift new file mode 100644 index 00000000..69c4293a --- /dev/null +++ b/Shared/Core/Services/Camera/AdvisoryMetadataFileReader.swift @@ -0,0 +1,58 @@ +import Darwin +import Foundation + +/// Small camera sidecars are hints for a label, never transfer evidence. +/// Skip oversized files and leaf symlinks rather than interpreting a prefix. +/// Ancestor directories are not pinned; this is advisory labeling, not evidence. +enum AdvisoryMetadataFileReader { + static let maximumBytes = 4 * 1024 * 1024 + private static let chunkBytes = 64 * 1024 + + /// `onChunkRead` is an invocation-specific test hook; production leaves it nil. + static func read(_ url: URL, onChunkRead: (() -> Void)? = nil) -> Data? { + guard !Task.isCancelled else { return nil } + + let fd = url.path.withCString { + Darwin.open($0, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC) + } + guard fd >= 0 else { return nil } + defer { _ = Darwin.close(fd) } + + var info = stat() + guard fstat(fd, &info) == 0, + (info.st_mode & S_IFMT) == S_IFREG, + info.st_size >= 0, + info.st_size <= off_t(maximumBytes) else { return nil } + guard !Task.isCancelled else { return nil } + + var data = Data() + data.reserveCapacity(Int(info.st_size)) + var buffer = [UInt8](repeating: 0, count: chunkBytes) + + // Read one byte beyond the cap so growth after fstat cannot turn a + // partial sidecar into an apparently complete camera hint. + while data.count <= maximumBytes { + guard !Task.isCancelled else { return nil } + let count = min(buffer.count, maximumBytes + 1 - data.count) + let bytesRead = buffer.withUnsafeMutableBytes { bytes in + Darwin.read(fd, bytes.baseAddress, count) + } + if bytesRead < 0 { + if errno == EINTR { continue } + return nil + } + if bytesRead == 0 { + var finalInfo = stat() + guard !Task.isCancelled, + data.count == Int(info.st_size), + fstat(fd, &finalInfo) == 0, + finalInfo.st_size == info.st_size else { return nil } + return data + } + data.append(contentsOf: buffer[.. String? { - let fm = FileManager.default - // Check for Canon-specific metadata files let canonMetadataFiles = [ "MISC/CANON.INF", @@ -32,16 +30,21 @@ final class CanonDetectionService: Sendable { "DCIM/CANONMSC/CANON.DAT" ] + var foundReadableSidecar = false for metadataFile in canonMetadataFiles { + guard !Task.isCancelled else { return nil } let metadataPath = url.appendingPathComponent(metadataFile) - if fm.fileExists(atPath: metadataPath.path) { - if let cameraModel = extractCanonModelFromMetadata(metadataPath) { + if let metadataData = AdvisoryMetadataFileReader.read(metadataPath) { + guard !Task.isCancelled else { return nil } + foundReadableSidecar = true + if let cameraModel = extractCanonModelFromMetadata(metadataData) { return "Canon \(cameraModel)" } - return "Canon" } } + + if foundReadableSidecar { return "Canon" } // Check CR2/CR3 files for metadata return checkCanonRAWFiles(at: url) @@ -68,46 +71,40 @@ final class CanonDetectionService: Sendable { // MARK: - Metadata Extraction - private func extractCanonModelFromMetadata(_ metadataPath: URL) -> String? { - do { - let metadataString = try String(contentsOf: metadataPath, encoding: .ascii) - - let canonModels = [ - "EOS R5": "R5", - "EOS R6 Mark II": "R6 Mark II", - "EOS R6": "R6", - "EOS R8": "R8", - "EOS R": "R", - "EOS R10": "R10", - "EOS R50": "R50", - "5D Mark IV": "5D Mark IV", - "5D4": "5D Mark IV", - "1DX Mark III": "1DX Mark III", - "90D": "90D", - "80D": "80D", - "M50 Mark II": "M50 Mark II", - "M50": "M50", - // Cinema/XF series - "EOS C70": "C70", - "C70": "C70", - "EOS C300 Mark III": "C300 Mark III", - "C300 Mark III": "C300 Mark III", - "EOS C500 Mark II": "C500 Mark II", - "C500 Mark II": "C500 Mark II", - "XF605": "XF605", - "XF705": "XF705" - ] - - for (pattern, model) in canonModels { - if metadataString.contains(pattern) { - return model - } - } + private func extractCanonModelFromMetadata(_ metadataData: Data) -> String? { + guard let metadataString = String(data: metadataData, encoding: .ascii) else { return nil } + let canonModels = [ + "EOS R5": "R5", + "EOS R6 Mark II": "R6 Mark II", + "EOS R6": "R6", + "EOS R8": "R8", + "EOS R": "R", + "EOS R10": "R10", + "EOS R50": "R50", + "5D Mark IV": "5D Mark IV", + "5D4": "5D Mark IV", + "1DX Mark III": "1DX Mark III", + "90D": "90D", + "80D": "80D", + "M50 Mark II": "M50 Mark II", + "M50": "M50", + // Cinema/XF series + "EOS C70": "C70", + "C70": "C70", + "EOS C300 Mark III": "C300 Mark III", + "C300 Mark III": "C300 Mark III", + "EOS C500 Mark II": "C500 Mark II", + "C500 Mark II": "C500 Mark II", + "XF605": "XF605", + "XF705": "XF705" + ] - } catch { - SharedLogger.debug("Could not read Canon metadata file: \(error.localizedDescription)", category: .transfer) + for (pattern, model) in canonModels { + if metadataString.contains(pattern) { + return model + } } - + return nil } diff --git a/Shared/Core/Services/Camera/PanasonicDetectionService.swift b/Shared/Core/Services/Camera/PanasonicDetectionService.swift index 118b02aa..8e2e2cc5 100644 --- a/Shared/Core/Services/Camera/PanasonicDetectionService.swift +++ b/Shared/Core/Services/Camera/PanasonicDetectionService.swift @@ -23,8 +23,6 @@ final class PanasonicDetectionService: Sendable { // MARK: - Panasonic Metadata Detection private func checkPanasonicMetadata(at url: URL) -> String? { - let fm = FileManager.default - // Check for Panasonic-specific metadata files let panasonicMetadataFiles = [ "PRIVATE/PANASONIC/MISC/DPOF.DAT", @@ -32,16 +30,21 @@ final class PanasonicDetectionService: Sendable { "PRIVATE/MISC/INFO.DAT" ] + var foundReadableSidecar = false for metadataFile in panasonicMetadataFiles { + guard !Task.isCancelled else { return nil } let metadataPath = url.appendingPathComponent(metadataFile) - if fm.fileExists(atPath: metadataPath.path) { - if let cameraModel = extractPanasonicModelFromMetadata(metadataPath) { + if let metadataData = AdvisoryMetadataFileReader.read(metadataPath) { + guard !Task.isCancelled else { return nil } + foundReadableSidecar = true + if let cameraModel = extractPanasonicModelFromMetadata(metadataData) { return "Panasonic \(cameraModel)" } - return "Panasonic" } } + + if foundReadableSidecar { return "Panasonic" } // Check RW2/RAF files for metadata return checkPanasonicRAWFiles(at: url) @@ -68,38 +71,31 @@ final class PanasonicDetectionService: Sendable { // MARK: - Metadata Extraction - private func extractPanasonicModelFromMetadata(_ metadataPath: URL) -> String? { - do { - let metadataData = try Data(contentsOf: metadataPath) - guard let metadataString = String(data: metadataData, encoding: .ascii) ?? String(data: metadataData, encoding: .utf8) else { return nil } - - let panasonicModels = [ - "DC-GH6": "GH6", - "DC-GH5S": "GH5S", - "DC-GH5": "GH5", - "DC-G9": "G9", - "DC-S1H": "S1H", - "DC-S1R": "S1R", - "DC-S1": "S1", - "DC-S5": "S5", - "DC-FZ2500": "FZ2500", - "DC-FZ1000": "FZ1000", - "DMC-GH4": "GH4", - "DMC-GH3": "GH3", - "DMC-G7": "G7", - "DMC-G85": "G85" - ] - - for (pattern, model) in panasonicModels { - if metadataString.contains(pattern) { - return model - } - } + private func extractPanasonicModelFromMetadata(_ metadataData: Data) -> String? { + guard let metadataString = String(data: metadataData, encoding: .ascii) ?? String(data: metadataData, encoding: .utf8) else { return nil } + let panasonicModels = [ + "DC-GH6": "GH6", + "DC-GH5S": "GH5S", + "DC-GH5": "GH5", + "DC-G9": "G9", + "DC-S1H": "S1H", + "DC-S1R": "S1R", + "DC-S1": "S1", + "DC-S5": "S5", + "DC-FZ2500": "FZ2500", + "DC-FZ1000": "FZ1000", + "DMC-GH4": "GH4", + "DMC-GH3": "GH3", + "DMC-G7": "G7", + "DMC-G85": "G85" + ] - } catch { - SharedLogger.debug("Could not read Panasonic metadata file: \(error.localizedDescription)", category: .transfer) + for (pattern, model) in panasonicModels { + if metadataString.contains(pattern) { + return model + } } - + return nil } diff --git a/Shared/Core/Services/Camera/SonyDetectionService.swift b/Shared/Core/Services/Camera/SonyDetectionService.swift index 91edc5d2..6d8d0466 100644 --- a/Shared/Core/Services/Camera/SonyDetectionService.swift +++ b/Shared/Core/Services/Camera/SonyDetectionService.swift @@ -22,10 +22,14 @@ final class SonyDetectionService: Sendable { // and XDCAM EX (BPAV). VENICE firmware 3.0+ can rename XDROOT to // Cam ID + Reel, so any root folder's MEDIAPRO.XML also counts. let fm = FileManager.default - let rootFolders = ((try? fm.contentsOfDirectory(atPath: url.path)) ?? []) + guard !Task.isCancelled else { return nil } + let rootNames = (try? fm.contentsOfDirectory(atPath: url.path)) ?? [] + guard !Task.isCancelled else { return nil } + let rootFolders = rootNames .filter { !$0.hasPrefix(".") } .sorted() .map { "\($0)/MEDIAPRO.XML" } + guard !Task.isCancelled else { return nil } let candidatePaths = [ "PRIVATE/M4ROOT/MEDIAPRO.XML", "XDROOT/MEDIAPRO.XML", @@ -33,41 +37,46 @@ final class SonyDetectionService: Sendable { "BPAV/MEDIAPRO.XML" ] + rootFolders - guard let mediaProPath = candidatePaths - .map({ url.appendingPathComponent($0) }) - .first(where: { fm.fileExists(atPath: $0.path) }) else { return nil } - - do { - let xmlString = try String(contentsOf: mediaProPath, encoding: .utf8) - - if let systemIdRange = xmlString.range(of: #"systemId="([^"]+)""#, options: .regularExpression) { - let systemId = String(xmlString[systemIdRange]) - .replacingOccurrences(of: #"systemId=""#, with: "") - .replacingOccurrences(of: "\"", with: "") - - if let cameraModel = mapSonySystemId(systemId) { - return "Sony \(cameraModel)" + var foundReadableSidecar = false + for candidate in candidatePaths { + guard !Task.isCancelled else { return nil } + let mediaProPath = url.appendingPathComponent(candidate) + let model: String? = autoreleasepool { + guard let xmlData = AdvisoryMetadataFileReader.read(mediaProPath), + let xmlString = String(data: xmlData, encoding: .utf8), + !Task.isCancelled else { return nil } + foundReadableSidecar = true + + if let systemIdRange = xmlString.range(of: #"systemId="([^"]+)""#, options: .regularExpression) { + let systemId = String(xmlString[systemIdRange]) + .replacingOccurrences(of: #"systemId=""#, with: "") + .replacingOccurrences(of: "\"", with: "") + + if let cameraModel = mapSonySystemId(systemId) { + return "Sony \(cameraModel)" + } } - } - - if let systemKindRange = xmlString.range(of: #"systemKind="([^"]+)""#, options: .regularExpression) { - // Extract attribute value - var systemKind = String(xmlString[systemKindRange]) - systemKind = systemKind.replacingOccurrences(of: #"systemKind=""#, with: "") - .replacingOccurrences(of: "\"", with: "") - .trimmingCharacters(in: .whitespacesAndNewlines) - - if let cameraModel = mapSonySystemKind(systemKind) { - SharedLogger.info("Resolved model: \(cameraModel) from systemKind=\(systemKind) at \(mediaProPath.path)", category: .transfer) - return "Sony \(cameraModel)" + + guard !Task.isCancelled else { return nil } + if let systemKindRange = xmlString.range(of: #"systemKind="([^"]+)""#, options: .regularExpression) { + // Extract attribute value + var systemKind = String(xmlString[systemKindRange]) + systemKind = systemKind.replacingOccurrences(of: #"systemKind=""#, with: "") + .replacingOccurrences(of: "\"", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if let cameraModel = mapSonySystemKind(systemKind) { + SharedLogger.info("Resolved model: \(cameraModel) from systemKind=\(systemKind) at \(mediaProPath.path)", category: .transfer) + return "Sony \(cameraModel)" + } } + return nil } - - return "Sony" - - } catch { - return nil + guard !Task.isCancelled else { return nil } + if let model { return model } } + + return Task.isCancelled ? nil : (foundReadableSidecar ? "Sony" : nil) } // MARK: - System ID/Kind Mapping diff --git a/Shared/Core/Services/CameraMemoryService.swift b/Shared/Core/Services/CameraMemoryService.swift index cad8dd6e..a85057b1 100644 --- a/Shared/Core/Services/CameraMemoryService.swift +++ b/Shared/Core/Services/CameraMemoryService.swift @@ -51,7 +51,7 @@ final class CameraMemoryService: @unchecked Sendable { let mediaProPath = url.appendingPathComponent("PRIVATE/M4ROOT/MEDIAPRO.XML") guard FileManager.default.fileExists(atPath: mediaProPath.path) else { return nil } - guard let xmlData = try? Data(contentsOf: mediaProPath), + guard let xmlData = AdvisoryMetadataFileReader.read(mediaProPath), let xmlString = String(data: xmlData, encoding: .utf8) else { return nil } // Extract systemId and systemKind @@ -69,6 +69,7 @@ final class CameraMemoryService: @unchecked Sendable { } guard let id = systemId, let kind = systemKind else { return nil } + guard !Task.isCancelled else { return nil } // Create or retrieve existing fingerprint let uniqueID = "Sony-\(kind)-\(id)" @@ -114,20 +115,24 @@ final class CameraMemoryService: @unchecked Sendable { /// Get or create a fingerprint for a camera at the given URL func getCameraFingerprint(at url: URL) -> CameraFingerprint? { + guard !Task.isCancelled else { return nil } // Try Sony systemId first (most reliable for Sony cameras) if let sonyFingerprint = getSonySystemFingerprint(at: url) { return sonyFingerprint } // Try multiple detection methods + guard !Task.isCancelled else { return nil } if let fingerprint = detectFromMediaFiles(at: url) { return fingerprint } + guard !Task.isCancelled else { return nil } if let fingerprint = detectFromSidecarFiles(at: url) { return fingerprint } + guard !Task.isCancelled else { return nil } if let fingerprint = detectFromFolderStructure(at: url) { return fingerprint } @@ -217,28 +222,38 @@ final class CameraMemoryService: @unchecked Sendable { // Check for XML/metadata files for file in contents { - let name = file.lastPathComponent.uppercased() - - // Sony XML files - if name.contains("M01.XML") || name.contains("MEDIAPRO.XML") { - if let xmlContent = try? String(contentsOf: file, encoding: .utf8) { - return parseSonyXML(xmlContent) + guard !Task.isCancelled else { return nil } + let fingerprint: CameraFingerprint? = autoreleasepool { + let name = file.lastPathComponent.uppercased() + + // Sony XML files + if name.contains("M01.XML") || name.contains("MEDIAPRO.XML") { + if let data = AdvisoryMetadataFileReader.read(file), + let xmlContent = String(data: data, encoding: .utf8) { + return parseSonyXML(xmlContent) + } } - } - - // Canon XMP files - if file.pathExtension.uppercased() == "XMP" { - if let xmpContent = try? String(contentsOf: file, encoding: .utf8) { - return parseCanonXMP(xmpContent) + + // Canon XMP files + if file.pathExtension.uppercased() == "XMP" { + if let data = AdvisoryMetadataFileReader.read(file), + let xmpContent = String(data: data, encoding: .utf8) { + return parseCanonXMP(xmpContent) + } } - } - - // ARRI metadata - if name.contains(".ARI") || name.contains("METADATA") { - if let arriContent = try? String(contentsOf: file, encoding: .utf8) { - return parseARRIMetadata(arriContent) + + // ARRI metadata: .ARI can also be a large media file. + if name.contains(".ARI") || name.contains("METADATA") { + if let data = AdvisoryMetadataFileReader.read(file), + let arriContent = String(data: data, encoding: .utf8) { + return parseARRIMetadata(arriContent) + } } + + return nil } + guard !Task.isCancelled else { return nil } + if let fingerprint { return fingerprint } } return nil @@ -409,7 +424,8 @@ final class CameraMemoryService: @unchecked Sendable { private func detectSonyFromStructure(at url: URL) -> CameraFingerprint? { // Check for MEDIAPRO.XML in Sony folder structure let mediaproURL = url.appendingPathComponent("MEDIAPRO.XML") - if let content = try? String(contentsOf: mediaproURL, encoding: .utf8) { + if let data = AdvisoryMetadataFileReader.read(mediaproURL), + let content = String(data: data, encoding: .utf8) { return parseSonyXML(content) } return nil diff --git a/Shared/Core/Services/SharedCameraDetectionService.swift b/Shared/Core/Services/SharedCameraDetectionService.swift index 36dc28ef..1a4d3218 100644 --- a/Shared/Core/Services/SharedCameraDetectionService.swift +++ b/Shared/Core/Services/SharedCameraDetectionService.swift @@ -107,6 +107,7 @@ final class SharedCameraDetectionService: CameraDetectionService, Sendable { if confidence < 0.8 { let xmlURLs = fileURLs.filter { $0.pathExtension.lowercased() == "xml" } for xmlURL in xmlURLs.prefix(3) { // Only check first 3 XML files + guard !Task.isCancelled else { break } do { let xmlMetadata = try await parseXMLMetadata(from: xmlURL) let (_, _, xmlConfidence) = extractCameraFromXMLMetadata(xmlMetadata) @@ -115,6 +116,8 @@ final class SharedCameraDetectionService: CameraDetectionService, Sendable { detectionMethod = "xml_metadata" metadata.merge(xmlMetadata) { _, new in new } } + } catch is CancellationError { + break } catch { // XML parsing failed, continue } @@ -267,7 +270,12 @@ final class SharedCameraDetectionService: CameraDetectionService, Sendable { } func parseXMLMetadata(from fileURL: URL) async throws -> [String: Any] { - let data = try Data(contentsOf: fileURL) + try Task.checkCancellation() + guard let data = AdvisoryMetadataFileReader.read(fileURL) else { + try Task.checkCancellation() + return [:] + } + try Task.checkCancellation() var metadata: [String: Any] = [:] // Simple XML parsing for common camera metadata patterns @@ -283,6 +291,7 @@ final class SharedCameraDetectionService: CameraDetectionService, Sendable { ] for (key, pattern) in patterns { + try Task.checkCancellation() if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { let range = NSRange(xmlString.startIndex..., in: xmlString) if let match = regex.firstMatch(in: xmlString, range: range), @@ -292,7 +301,8 @@ final class SharedCameraDetectionService: CameraDetectionService, Sendable { } } } - + + try Task.checkCancellation() return metadata }