diff --git a/Shared/Services/AudioMetadataService.swift b/Shared/Services/AudioMetadataService.swift index 8cc30c6c4..5178eb682 100644 --- a/Shared/Services/AudioMetadataService.swift +++ b/Shared/Services/AudioMetadataService.swift @@ -93,7 +93,11 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { public init() {} public func extractMetadata(from fileURL: URL) async -> AudioMetadata? { - let asset = AVURLAsset(url: fileURL) + // MP3 and Ogg lack global duration headers. We request precise timing to prevent + // inaccurate duration estimates for VBR files by forcing a full stream scan. + let requiresPreciseDuration = ["opus", "ogg", "mp3"].contains(fileURL.pathExtension.lowercased()) + let options: [String: Any]? = requiresPreciseDuration ? [AVURLAssetPreferPreciseDurationAndTimingKey: true] : nil + let asset = AVURLAsset(url: fileURL, options: options) return await extractMetadata(from: asset) } @@ -232,7 +236,11 @@ public class AudioMetadataService: BPLogger, AudioMetadataServiceProtocol { } public func extractManualChapters(from fileURL: URL) async -> [ChapterMetadata]? { - let asset = AVURLAsset(url: fileURL) + // MP3 and Ogg lack global duration headers. We request precise timing to prevent + // inaccurate duration estimates for VBR files by forcing a full stream scan. + let requiresPreciseDuration = ["opus", "ogg", "mp3"].contains(fileURL.pathExtension.lowercased()) + let options: [String: Any]? = requiresPreciseDuration ? [AVURLAssetPreferPreciseDurationAndTimingKey: true] : nil + let asset = AVURLAsset(url: fileURL, options: options) do { let metadata = try await asset.load(.metadata) let duration = CMTimeGetSeconds(try await asset.load(.duration)) diff --git a/Shared/Services/LibraryService+Sync.swift b/Shared/Services/LibraryService+Sync.swift index 7e03fa99d..a119b9d5c 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -424,6 +424,12 @@ extension LibraryService: LibrarySyncProtocol { public func loadChaptersIfNeeded(relativePath: String) async { let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) - await loadChaptersIfNeeded(relativePath: relativePath, asset: AVAsset(url: fileURL)) + // MP3 and Ogg lack global duration headers. We request precise timing to prevent + // inaccurate duration estimates for VBR files by forcing a full stream scan. + let requiresPreciseDuration = ["opus", "ogg", "mp3"].contains(fileURL.pathExtension.lowercased()) + let options: [String: Any]? = requiresPreciseDuration ? [AVURLAssetPreferPreciseDurationAndTimingKey: true] : nil + let asset = AVURLAsset(url: fileURL, options: options) + + await loadChaptersIfNeeded(relativePath: relativePath, asset: asset) } } diff --git a/Shared/Services/Sync/SyncService.swift b/Shared/Services/Sync/SyncService.swift index 5622c8a38..375dbd15b 100644 --- a/Shared/Services/Sync/SyncService.swift +++ b/Shared/Services/Sync/SyncService.swift @@ -816,7 +816,11 @@ extension SyncService { let actualDuration: Double do { - let asset = AVURLAsset(url: fileURL) + // MP3 and Ogg lack global duration headers. We request precise timing to prevent + // inaccurate duration estimates for VBR files by forcing a full stream scan. + let requiresPreciseDuration = ["opus", "ogg", "mp3"].contains(fileURL.pathExtension.lowercased()) + let options: [String: Any]? = requiresPreciseDuration ? [AVURLAssetPreferPreciseDurationAndTimingKey: true] : nil + let asset = AVURLAsset(url: fileURL, options: options) actualDuration = CMTimeGetSeconds(try await asset.load(.duration)) } catch { /// We have a trustworthy synced duration, so this is a format we can play —