diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index cb1ccb859..c85700f33 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -523,7 +523,8 @@ extension AppDelegate { } else { let dataManager = DataManager(coreDataStack: stack) let accountService = makeAccountService(dataManager: dataManager) - let libraryService = makeLibraryService(dataManager: dataManager) + let audioMetadataService = makeAudioMetadataService() + let libraryService = makeLibraryService(dataManager: dataManager, audioMetadataService: audioMetadataService) let syncService = makeSyncService(accountService: accountService, libraryService: libraryService) let playbackService = makePlaybackService(libraryService: libraryService) let playerManager = PlayerManager( @@ -571,9 +572,13 @@ extension AppDelegate { return service } - private func makeLibraryService(dataManager: DataManager) -> LibraryService { + private func makeAudioMetadataService() -> AudioMetadataService { + return AudioMetadataService() + } + + private func makeLibraryService(dataManager: DataManager, audioMetadataService: AudioMetadataServiceProtocol) -> LibraryService { let service = LibraryService() - service.setup(dataManager: dataManager) + service.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) return service } diff --git a/BookPlayer/Coordinators/DataInitializerCoordinator.swift b/BookPlayer/Coordinators/DataInitializerCoordinator.swift index 9d9f1055b..09711e969 100644 --- a/BookPlayer/Coordinators/DataInitializerCoordinator.swift +++ b/BookPlayer/Coordinators/DataInitializerCoordinator.swift @@ -179,7 +179,7 @@ class DataInitializerCoordinator: BPLogger { if shouldRebuildFromFiles { let files = getLibraryFiles() - coreServices.libraryService.insertItems(from: files) + await coreServices.libraryService.insertItems(from: files) } await MainActor.run { diff --git a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift index ca0e59bde..989453d04 100644 --- a/BookPlayer/Library/ItemList/ItemListView+Alerts.swift +++ b/BookPlayer/Library/ItemList/ItemListView+Alerts.swift @@ -68,6 +68,7 @@ extension ItemListView { } Button("existing_playlist_button") { + model.pendingMoveItemIdentifiers = alertParameters.itemIdentifiers model.selectedSetItems = Set(alertParameters.itemIdentifiers) activeSheet = .foldersSelection } diff --git a/BookPlayer/Library/ItemList/ItemListViewModel.swift b/BookPlayer/Library/ItemList/ItemListViewModel.swift index 9e5bfe500..cd9d81e43 100644 --- a/BookPlayer/Library/ItemList/ItemListViewModel.swift +++ b/BookPlayer/Library/ItemList/ItemListViewModel.swift @@ -77,6 +77,9 @@ final class ItemListViewModel: ObservableObject { } } @Published var selectedItems = [SimpleLibraryItem]() + /// Stores item identifiers from import operations to avoid race condition + /// where items may not be loaded in the UI yet when moving to a folder + var pendingMoveItemIdentifiers: [String]? /// Search @Published var scope: ItemListSearchScope = .all @@ -410,7 +413,15 @@ extension ItemListViewModel { } func handleMoveIntoFolder(_ folder: SimpleLibraryItem) { - let fetchedItems = selectedItems.compactMap({ $0.relativePath }) + // Use pendingMoveItemIdentifiers if available (from import operations), + // otherwise fall back to selectedItems (from manual selection) + let fetchedItems: [String] + if let pendingItems = pendingMoveItemIdentifiers { + fetchedItems = pendingItems + pendingMoveItemIdentifiers = nil + } else { + fetchedItems = selectedItems.compactMap({ $0.relativePath }) + } do { try libraryService.moveItems(fetchedItems, inside: folder.relativePath) diff --git a/BookPlayer/Library/ItemList/LibraryRootView.swift b/BookPlayer/Library/ItemList/LibraryRootView.swift index 97f92d189..a3b4720f8 100644 --- a/BookPlayer/Library/ItemList/LibraryRootView.swift +++ b/BookPlayer/Library/ItemList/LibraryRootView.swift @@ -181,7 +181,7 @@ struct LibraryRootView: View { guard !files.isEmpty else { return } Task { @MainActor in - let processedItems = libraryService.insertItems(from: files) + let processedItems = await libraryService.insertItems(from: files) var itemIdentifiers = processedItems.map({ $0.relativePath }) do { await syncService.scheduleUpload(items: processedItems) diff --git a/BookPlayer/Library/ItemList/Views/BookView.swift b/BookPlayer/Library/ItemList/Views/BookView.swift index 949b81a11..397b02a3d 100644 --- a/BookPlayer/Library/ItemList/Views/BookView.swift +++ b/BookPlayer/Library/ItemList/Views/BookView.swift @@ -70,8 +70,9 @@ struct BookView: View { @Previewable var syncService: SyncService = { let syncService = SyncService() let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "")) + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: dataManager) + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) syncService.setup( isActive: true, libraryService: libraryService diff --git a/BookPlayer/Profile/Profile/ProfileListenedSectionView.swift b/BookPlayer/Profile/Profile/ProfileListenedSectionView.swift index 7cdcfb766..198a698e5 100644 --- a/BookPlayer/Profile/Profile/ProfileListenedSectionView.swift +++ b/BookPlayer/Profile/Profile/ProfileListenedSectionView.swift @@ -68,7 +68,8 @@ struct ProfileListenedSectionView: View { @Previewable var libraryService: LibraryService = { let libraryService = LibraryService() let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "")) - libraryService.setup(dataManager: dataManager) + let audioMetadataService = AudioMetadataService() + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) return libraryService }() diff --git a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift index cf33b57a3..9bcd8336f 100644 --- a/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift +++ b/BookPlayer/Profile/Profile/ProfileSyncTasksSectionView.swift @@ -91,8 +91,9 @@ struct ProfileSyncTasksSectionView: View { #Preview { @Previewable var syncService: SyncService = { let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "")) + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: dataManager) + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) let syncService = SyncService() syncService.setup(isActive: true, libraryService: libraryService) diff --git a/BookPlayer/Settings/Storage/StorageViewModel.swift b/BookPlayer/Settings/Storage/StorageViewModel.swift index bbad01b11..bd08c66ea 100644 --- a/BookPlayer/Settings/Storage/StorageViewModel.swift +++ b/BookPlayer/Settings/Storage/StorageViewModel.swift @@ -166,10 +166,10 @@ final class StorageViewModel: StorageViewModelProtocol { publishedFiles.filter({ $0.showWarning }) } - func handleFix(for item: StorageItem, shouldReloadItems: Bool = true) throws { + func handleFix(for item: StorageItem, shouldReloadItems: Bool = true) async throws { guard let fetchedBook = self.libraryService.findBooks(containing: item.fileURL)?.first else { // create a new book - try self.createBook(from: item) + try await self.createBook(from: item) if shouldReloadItems { self.loadItems() } @@ -224,11 +224,15 @@ final class StorageViewModel: StorageViewModelProtocol { } func fixSelectedItem(_ item: StorageItem) { - do { - try handleFix(for: item) - } catch { - storageAlert = .error(errorMessage: error.localizedDescription) - showAlert = true + Task { + do { + try await handleFix(for: item) + } catch { + await MainActor.run { + storageAlert = .error(errorMessage: error.localizedDescription) + showAlert = true + } + } } } @@ -361,8 +365,8 @@ final class StorageViewModel: StorageViewModelProtocol { listState.reloadAll() } - private func createBook(from item: StorageItem) throws { - let book = self.libraryService.createBook(from: item.fileURL) + private func createBook(from item: StorageItem) async throws { + let book = await self.libraryService.createBook(from: item.fileURL) try moveBookFile(from: item, with: book) try libraryService.moveItems([book.relativePath], inside: nil) reloadLibraryItems() @@ -450,7 +454,7 @@ final class StorageViewModel: StorageViewModelProtocol { private func fixItemSafely(_ item: StorageItem) async -> FixResult { do { try await Task.detached(priority: .userInitiated) { - try self.handleFix(for: item, shouldReloadItems: false) + try await self.handleFix(for: item, shouldReloadItems: false) }.value return FixResult(item: item, success: true, error: nil) diff --git a/BookPlayerTests/DataManagerTests.swift b/BookPlayerTests/DataManagerTests.swift index c5861498b..102a32af9 100644 --- a/BookPlayerTests/DataManagerTests.swift +++ b/BookPlayerTests/DataManagerTests.swift @@ -46,8 +46,9 @@ class ProcessFilesTests: DataManagerTests { let expectation = XCTestExpectation(description: "File import notification") + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: self.dataManager) + libraryService.setup(dataManager: self.dataManager, audioMetadataService: audioMetadataService) self.importManager = ImportManager(libraryService: libraryService) self.subscription = self.importManager.observeFiles().sink { files in diff --git a/BookPlayerTests/ImportOperationTests.swift b/BookPlayerTests/ImportOperationTests.swift index 72fbc3d3c..c537d628c 100644 --- a/BookPlayerTests/ImportOperationTests.swift +++ b/BookPlayerTests/ImportOperationTests.swift @@ -31,8 +31,9 @@ class ImportOperationTests: XCTestCase { let promise = XCTestExpectation(description: "Process file") let promiseFile = expectation(forNotification: .processingFile, object: nil) let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: dataManager) + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) let operation = ImportOperation(files: [fileUrl], libraryService: libraryService) diff --git a/BookPlayerTests/PerformanceTests/PlaybackPerformanceTests.swift b/BookPlayerTests/PerformanceTests/PlaybackPerformanceTests.swift index fbaaad775..f7a28996b 100644 --- a/BookPlayerTests/PerformanceTests/PlaybackPerformanceTests.swift +++ b/BookPlayerTests/PerformanceTests/PlaybackPerformanceTests.swift @@ -17,8 +17,9 @@ final class PlaybackPerformanceTests: XCTestCase { override func setUpWithError() throws { DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + let audioMetadataService = AudioMetadataService() self.sut = LibraryService() - self.sut.setup(dataManager: dataManager) + self.sut.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) _ = self.sut.getLibrary() } @@ -26,11 +27,11 @@ final class PlaybackPerformanceTests: XCTestCase { self.sut = nil } - func testFolderProgressUpdatePerformance() throws { + func testFolderProgressUpdatePerformance() async throws { /// Setup a test folder with 3000 ibooks inside it let folder = try self.sut.createFolder(with: "test-folder", inside: nil) let urls = Array(stride(from: 0, to: 3000, by: 1)).map({ URL(string: "test-book-\($0).mp3")! }) - _ = self.sut.insertItems(from: urls, parentPath: folder.relativePath) + _ = await self.sut.insertItems(from: urls, parentPath: folder.relativePath) self.measure(metrics: [XCTCPUMetric()]) { /// This is called by the `PlayerManager` when the currently playing book changes its progress percentage diff --git a/BookPlayerTests/Services/LibraryServiceTests.swift b/BookPlayerTests/Services/LibraryServiceTests.swift index e24ab8cbd..09d8775cf 100644 --- a/BookPlayerTests/Services/LibraryServiceTests.swift +++ b/BookPlayerTests/Services/LibraryServiceTests.swift @@ -20,8 +20,9 @@ class LibraryServiceTests: XCTestCase { override func setUp() { DataTestUtils.clearFolderContents(url: DataManager.getProcessedFolderURL()) let dataManager = DataManager(coreDataStack: CoreDataStack(testPath: "/dev/null")) + let audioMetadataService = AudioMetadataService() self.sut = LibraryService() - self.sut.setup(dataManager: dataManager) + self.sut.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) _ = self.sut.getLibrary() } @@ -71,14 +72,14 @@ class LibraryServiceTests: XCTestCase { XCTAssert(currentTheme?.title == "Default / Dark") } - func testCreateBook() { + func testCreateBook() async { let filename = "test-book.txt" let bookContents = "bookcontents".data(using: .utf8)! let processedFolder = DataManager.getProcessedFolderURL() // Add test file to Processed folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let newBook = self.sut.createBook(from: fileUrl) + let newBook = await self.sut.createBook(from: fileUrl) XCTAssert(newBook.title == "test-book.txt") XCTAssert(newBook.relativePath == "test-book.txt") } @@ -657,7 +658,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(library.items?.count == 0) } - func testInsertOneBookInLibrary() throws { + func testInsertOneBookInLibrary() async throws { let library = self.sut.getLibrary() let filename = "file.txt" @@ -667,13 +668,13 @@ class InsertBooksTests: LibraryServiceTests { // Add test file to Processed folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let processedItems = self.sut.insertItems(from: [fileUrl]) + let processedItems = await self.sut.insertItems(from: [fileUrl]) XCTAssert(library.items?.count == 1) XCTAssert(processedItems.count == 1) } - func testInsertMultipleBooksInLibrary() throws { + func testInsertMultipleBooksInLibrary() async throws { let library = self.sut.getLibrary() let filename1 = "file1.txt" @@ -686,13 +687,13 @@ class InsertBooksTests: LibraryServiceTests { let file1Url = DataTestUtils.generateTestFile(name: filename1, contents: book1Contents, destinationFolder: processedFolder) let file2Url = DataTestUtils.generateTestFile(name: filename2, contents: book2Contents, destinationFolder: processedFolder) - let processedItems = self.sut.insertItems(from: [file1Url, file2Url]) + let processedItems = await self.sut.insertItems(from: [file1Url, file2Url]) XCTAssert(library.items?.count == 2) XCTAssert(processedItems.count == 2) } - func testInsertEmptyBooksIntoPlaylist() throws { + func testInsertEmptyBooksIntoPlaylist() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder", inside: nil) @@ -703,7 +704,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(folder.items?.count == 0) } - func testInsertOneBookIntoPlaylist() throws { + func testInsertOneBookIntoPlaylist() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder", inside: nil) @@ -718,7 +719,7 @@ class InsertBooksTests: LibraryServiceTests { // Add test file to Documents folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let processedItems = sut.insertItems(from: [fileUrl]) + let processedItems = await sut.insertItems(from: [fileUrl]) .map({ $0.relativePath }) try sut.moveItems(processedItems, inside: folder.relativePath) XCTAssert(library.items?.count == 1) @@ -726,7 +727,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(processedItems.count == 1) } - func testInsertMultipleBooksIntoPlaylist() throws { + func testInsertMultipleBooksIntoPlaylist() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder", inside: nil) @@ -744,7 +745,7 @@ class InsertBooksTests: LibraryServiceTests { let file1Url = DataTestUtils.generateTestFile(name: filename1, contents: book1Contents, destinationFolder: processedFolder) let file2Url = DataTestUtils.generateTestFile(name: filename2, contents: book2Contents, destinationFolder: processedFolder) - let processedItems = sut.insertItems(from: [file1Url, file2Url]) + let processedItems = await sut.insertItems(from: [file1Url, file2Url]) .map({ $0.relativePath }) try sut.moveItems(processedItems, inside: folder.relativePath) @@ -753,7 +754,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(processedItems.count == 2) } - func testInsertExistingBookFromLibraryIntoPlaylist() throws { + func testInsertExistingBookFromLibraryIntoPlaylist() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder", inside: nil) @@ -768,7 +769,7 @@ class InsertBooksTests: LibraryServiceTests { // Add test file to Documents folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let processedItems = self.sut.insertItems(from: [fileUrl]) + let processedItems = await self.sut.insertItems(from: [fileUrl]) .map({ $0.relativePath }) XCTAssert(library.items?.count == 2) @@ -780,7 +781,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(folder.items?.count == 1) } - func testInsertExistingBookFromPlaylistIntoLibrary() throws { + func testInsertExistingBookFromPlaylistIntoLibrary() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder", inside: nil) @@ -795,7 +796,7 @@ class InsertBooksTests: LibraryServiceTests { // Add test file to Documents folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let processedItems = self.sut.insertItems(from: [fileUrl]) + let processedItems = await self.sut.insertItems(from: [fileUrl]) .map({ $0.relativePath }) try self.sut.moveItems(processedItems, inside: folder.relativePath) @@ -810,7 +811,7 @@ class InsertBooksTests: LibraryServiceTests { XCTAssert(folder.items?.count == 0) } - func testInsertExistingBookFromPlaylistIntoPlaylist() throws { + func testInsertExistingBookFromPlaylistIntoPlaylist() async throws { let library = self.sut.getLibrary() _ = try self.sut.createFolder(with: "test-folder1", inside: nil) @@ -827,7 +828,7 @@ class InsertBooksTests: LibraryServiceTests { // Add test file to Processed folder let fileUrl = DataTestUtils.generateTestFile(name: filename, contents: bookContents, destinationFolder: processedFolder) - let processedItems = self.sut.insertItems(from: [fileUrl]) + let processedItems = await self.sut.insertItems(from: [fileUrl]) .map({ $0.relativePath }) try self.sut.moveItems(processedItems, inside: folder1.relativePath) diff --git a/BookPlayerTests/Support/StubFactory.swift b/BookPlayerTests/Support/StubFactory.swift index b9502febf..07a60323f 100644 --- a/BookPlayerTests/Support/StubFactory.swift +++ b/BookPlayerTests/Support/StubFactory.swift @@ -59,8 +59,9 @@ class StubFactory { } public class func library(dataManager: DataManager) -> Library { + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: dataManager) + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) return libraryService.getLibrary() } } diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index 7536cffe4..e691ff895 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -67,8 +67,9 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { let dataManager = DataManager(coreDataStack: stack) let accountService = AccountService() accountService.setup(dataManager: dataManager) + let audioMetadataService = AudioMetadataService() let libraryService = LibraryService() - libraryService.setup(dataManager: dataManager) + libraryService.setup(dataManager: dataManager, audioMetadataService: audioMetadataService) let syncService = SyncService() syncService.setup( isActive: accountService.hasSyncEnabled(), diff --git a/Shared/Artwork/AVAudioAssetImageDataProvider.swift b/Shared/Artwork/AVAudioAssetImageDataProvider.swift index 59fbf55f2..709d319f4 100644 --- a/Shared/Artwork/AVAudioAssetImageDataProvider.swift +++ b/Shared/Artwork/AVAudioAssetImageDataProvider.swift @@ -66,21 +66,23 @@ public struct AVAudioAssetImageDataProvider: ImageDataProvider { } private func extractDataFrom(url: URL) async throws -> Data { - let asset = AVAsset(url: url) + let asset = AVURLAsset(url: url) - await asset.loadValues(forKeys: ["metadata"]) + do { + let metadata = try await asset.load(.metadata) - switch asset.statusOfValue(forKey: "metadata", error: nil) { - case .loaded: var imageData: Data? if url.pathExtension == "mp3" { - imageData = self.getDataFromMP3(asset: asset) - } else if let data = AVMetadataItem.metadataItems( - from: asset.commonMetadata, - filteredByIdentifier: .commonIdentifierArtwork - ).first?.dataValue { - imageData = data + imageData = await self.getDataFromMP3(metadata: metadata) + } else { + let commonMetadata = try await asset.load(.commonMetadata) + if let artworkItem = AVMetadataItem.metadataItems( + from: commonMetadata, + filteredByIdentifier: .commonIdentifierArtwork + ).first { + imageData = try? await artworkItem.load(.dataValue) + } } if let imageData { @@ -88,18 +90,23 @@ public struct AVAudioAssetImageDataProvider: ImageDataProvider { } throw ProviderError.missingImage - default: + } catch is CancellationError { + throw ProviderError.metadataFailed + } catch let error as ProviderError { + throw error + } catch { throw ProviderError.metadataFailed } } - private func getDataFromMP3(asset: AVAsset) -> Data? { - for item in asset.metadata { + private func getDataFromMP3(metadata: [AVMetadataItem]) async -> Data? { + for item in metadata { guard let key = item.commonKey?.rawValue, - key == "artwork", - let value = item.value as? Data else { continue } + key == "artwork" else { continue } - return value + if let value = try? await item.load(.dataValue) { + return value + } } return nil diff --git a/Shared/CoreData/Backed-Models/Book+CoreDataClass.swift b/Shared/CoreData/Backed-Models/Book+CoreDataClass.swift index abfce8a84..842f7df76 100644 --- a/Shared/CoreData/Backed-Models/Book+CoreDataClass.swift +++ b/Shared/CoreData/Backed-Models/Book+CoreDataClass.swift @@ -56,140 +56,19 @@ extension CodingUserInfoKey { } extension Book { - public func loadChaptersIfNeeded(from asset: AVAsset, context: NSManagedObjectContext) -> Bool { - guard chapters?.count == 0 else { return false } - - setChapters(from: asset, context: context) - return true - } - - public func setChapters(from asset: AVAsset, context: NSManagedObjectContext) { - if !asset.availableChapterLocales.isEmpty { - setStandardChapters(from: asset, context: context) - } else { - setOverdriveChapters(from: asset, context: context) - } - } - - /// Store chapters that are automatically parsed by the native SDK - private func setStandardChapters(from asset: AVAsset, context: NSManagedObjectContext) { - for locale in asset.availableChapterLocales { - let chaptersMetadata = asset.chapterMetadataGroups( - withTitleLocale: locale, containingItemsWithCommonKeys: [AVMetadataKey.commonKeyArtwork] - ) - - for (index, chapterMetadata) in chaptersMetadata.enumerated() { - let chapterIndex = index + 1 - let chapter = Chapter(context: context) - - chapter.title = AVMetadataItem.metadataItems( - from: chapterMetadata.items, - withKey: AVMetadataKey.commonKeyTitle, - keySpace: AVMetadataKeySpace.common).first?.value?.copy(with: nil) as? String ?? "" - chapter.start = CMTimeGetSeconds(chapterMetadata.timeRange.start) - chapter.duration = CMTimeGetSeconds(chapterMetadata.timeRange.duration) - chapter.index = Int16(chapterIndex) - - self.addToChapters(chapter) - } - } - } - - /// Try to store chapters info from the TXXX tag for mp3s (used by Overdrive) - /// Note: `XMLParser` does not have an async/await API, so I would rather use regex with - /// what was introduced in iOS 16 to parse the info - private func setOverdriveChapters(from asset: AVAsset, context: NSManagedObjectContext) { - guard - let fileURL, - fileURL.pathExtension == "mp3", - let overdriveMetadata = asset.metadata.first(where: { $0.identifier?.rawValue == "id3/TXXX" })?.value as? String - else { return } - - let matches = overdriveMetadata.matches(of: /(.+?)<\/Marker>/) - var chapters = [Chapter]() - - for (index, match) in matches.enumerated() { - let (_, marker) = match.output - - guard let (_, timeMatch) = marker.matches(of: /