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
}