From b3522dfc2e63e4fc5448c0937bc71ec4909cdc10 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 5 Aug 2025 11:40:07 -0400 Subject: [PATCH 01/32] Close to working streamer --- .../UI/BookDetail/BookDetailViewModel.swift | 249 +++++++++++++++++- Palace/MyBooks/MyBooksDownloadCenter.swift | 49 +++- 2 files changed, 291 insertions(+), 7 deletions(-) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 6758fcf5c..f5527ffc3 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -2,6 +2,11 @@ import Combine import SwiftUI import PalaceAudiobookToolkit +#if LCP +import ReadiumShared +import ReadiumStreamer +#endif + struct BookLane { let title: String let books: [TPPBook] @@ -60,6 +65,29 @@ final class BookDetailViewModel: ObservableObject { return .managingHold } +#if LCP + // For LCP audiobooks, show as ready if they can be opened (license can be fulfilled) + if LCPAudiobooks.canOpenBook(book) { + switch bookState { + case .downloadNeeded: + // LCP audiobooks can be "opened" to start license fulfillment + return .downloadSuccessful + case .downloading: + // Show as downloading while license fulfillment is in progress + return BookButtonMapper.map( + registryState: bookState, + availability: avail, + isProcessingDownload: isDownloading + ) + case .downloadSuccessful, .used: + // Already downloaded/fulfilled + return .downloadSuccessful + default: + break + } + } +#endif + return BookButtonMapper.map( registryState: bookState, availability: avail, @@ -352,13 +380,222 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - guard let url = downloadCenter.fileUrl(for: book.identifier) else { - presentCorruptedItemError() + // Check if we have a local file already +// if let url = downloadCenter.fileUrl(for: book.identifier) { +// // File exists, proceed with normal flow +// openAudiobookWithLocalFile(book: book, url: url, completion: completion) +// return +// } + + // No local file - for LCP audiobooks, check for license-based streaming +#if LCP + if LCPAudiobooks.canOpenBook(book) { + // Check if we have license file for streaming + if let licenseUrl = getLCPLicenseURL(for: book) { + // Have license, open in streaming mode using swifttoolkit 2.1.0 + Log.info(#file, "Opening LCP audiobook in streaming mode: \(book.identifier)") + openAudiobookWithStreaming(book: book, licenseUrl: licenseUrl, completion: completion) + return + } + + // License not found yet - check if fulfillment is in progress + if bookState == .downloadSuccessful { + // Book is marked as ready but license not found - fulfillment may be in progress + Log.info(#file, "License fulfillment may be in progress, waiting for completion: \(book.identifier)") + waitForLicenseFulfillment(book: book, completion: completion) + return + } + + // No license yet, start fulfillment + Log.info(#file, "No local file for LCP audiobook, starting license fulfillment: \(book.identifier)") + Log.info(#file, "Expected LCP MIME type: application/vnd.readium.lcp.license.v1.0+json") + if let acqURL = book.defaultAcquisition?.hrefURL { + Log.info(#file, "Downloading LCP license from: \(acqURL.absoluteString)") + } + downloadCenter.startDownload(for: book) + // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment starts completion?() return } +#endif + + presentCorruptedItemError() + completion?() + } + + private func getLCPLicenseURL(for book: TPPBook) -> URL? { +#if LCP + // Check for license file in the same location as content files + // License is stored as {hashedIdentifier}.lcpl in the content directory + guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { + return nil + } + + // License has same path but .lcpl extension + let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") + + if FileManager.default.fileExists(atPath: licenseURL.path) { + Log.debug(#file, "Found LCP license at: \(licenseURL.path)") + return licenseURL + } + + Log.debug(#file, "No LCP license found at: \(licenseURL.path)") + return nil +#else + return nil +#endif + } + + private func waitForLicenseFulfillment(book: TPPBook, completion: (() -> Void)? = nil, attempt: Int = 0) { + let maxAttempts = 10 // Wait up to 10 seconds + let retryDelay: TimeInterval = 1.0 + + // Check if license file is now available + if let licenseUrl = getLCPLicenseURL(for: book) { + Log.info(#file, "License fulfillment completed, opening in streaming mode: \(book.identifier)") + openAudiobookWithStreaming(book: book, licenseUrl: licenseUrl, completion: completion) + return + } + + // If we've exceeded max attempts, show error + if attempt >= maxAttempts { + Log.error(#file, "Timeout waiting for license fulfillment after \(attempt) attempts") + presentUnsupportedItemError() + completion?() + return + } + + // Wait and retry + Log.debug(#file, "License not ready yet, waiting... (attempt \(attempt + 1)/\(maxAttempts))") + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in + self?.waitForLicenseFulfillment(book: book, completion: completion, attempt: attempt + 1) + } + } + + private func openAudiobookWithStreaming(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { +#if LCP + Log.info(#file, "Opening LCP audiobook for streaming using Readium toolkit: \(book.identifier)") + Log.debug(#file, "License available at: \(licenseUrl.absoluteString)") + + // Use Readium LCP service directly to get publication manifest for streaming + // This avoids the need for a local .lcpa file that LCPAudiobooks expects + getPublicationManifestForStreaming(licenseUrl: licenseUrl) { [weak self] result in + DispatchQueue.main.async { + guard let self = self else { return } + + switch result { + case .success(let manifestDict): + var jsonDict = manifestDict + jsonDict["id"] = book.identifier + jsonDict["streamingEnabled"] = true // Enable swift-toolkit 2.1.0 streaming + + // For streaming, create an LCPAudiobooks instance using the publication URL from the manifest + // The LCPAudiobooks class can handle HTTP URLs for streaming + if let publicationUrl = self.getPublicationUrlFromManifest(manifestDict), + let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) { + + Log.info(#file, "Opening LCP audiobook for streaming with PalaceAudiobookToolkit: \(book.identifier)") + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } else { + Log.error(#file, "Failed to create LCPAudiobooks for streaming decryption") + self.presentUnsupportedItemError() + completion?() + } + + case .failure(let error): + Log.error(#file, "Failed to get publication manifest for streaming: \(error)") + self.presentUnsupportedItemError() + completion?() + } + } + } +#endif + } + + /// Gets the publication manifest for streaming using Readium LCP service directly + /// This bypasses the need for a local .lcpa file by using just the license + private func getPublicationManifestForStreaming(licenseUrl: URL, completion: @escaping (Result<[String: Any], Error>) -> Void) { + #if LCP + let lcpService = LCPLibraryService() + + Task { + do { + // 1️⃣ Parse the license + guard let license = TPPLCPLicense(url: licenseUrl) else { + throw NSError(domain: "LCPStreaming", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Failed to parse license file" ]) + } - #if LCP + // 2️⃣ Extract the publication URL + guard let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href) else { + throw NSError(domain: "LCPStreaming", code: 2, userInfo: [ NSLocalizedDescriptionKey: "No publication URL found in license" ]) + } + Log.debug(#file, "Found publication URL for streaming: \(publicationUrl)") + + // Cache the publication URL for later use + self.cachedPublicationUrl = publicationUrl + + // 3️⃣ Prepare Readium + guard let contentProtection = lcpService.contentProtection else { + throw NSError(domain: "LCPStreaming", code: 3, userInfo: [ NSLocalizedDescriptionKey: "LCP content protection not available" ]) + } + let httpClient = DefaultHTTPClient() + let assetRetriever = AssetRetriever(httpClient: httpClient) + let parser = DefaultPublicationParser(httpClient: httpClient, + assetRetriever: assetRetriever, + pdfFactory: DefaultPDFDocumentFactory()) + let publicationOpener = PublicationOpener(parser: parser, + contentProtections: [contentProtection]) + + // 4️⃣ Retrieve the asset (unwrap the Result) + let retrieveResult = await assetRetriever.retrieve(url: publicationUrl.absoluteURL!) + let asset: Asset + switch retrieveResult { + case .success(let a): + asset = a + case .failure(let retrievalError): + completion(.failure(retrievalError)) + return + } + + // 5️⃣ Open the publication + let openResult = await publicationOpener.open(asset: asset, + allowUserInteraction: false, + sender: nil) + + switch openResult { + case .success(let publication): + guard let jsonManifestString = publication.jsonManifest, + let jsonData = jsonManifestString.data(using: .utf8), + let manifestDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw NSError(domain: "LCPStreaming", code: 4, userInfo: [ NSLocalizedDescriptionKey: "No manifest or failed JSON parse" ]) + } + Log.debug(#file, "Successfully retrieved publication manifest for streaming") + completion(.success(manifestDict)) + + case .failure(let error): + completion(.failure(error)) + } + + } catch { + completion(.failure(error)) + } + } + #endif + } + + /// Extracts the publication URL from the license for use with LCPAudiobooks + /// Since we already have the publication URL from parsing the license, we can store and reuse it + private var cachedPublicationUrl: URL? + + private func getPublicationUrlFromManifest(_ manifest: [String: Any]) -> URL? { + // Return the cached publication URL that we got from the license + return cachedPublicationUrl + } + + private func openAudiobookWithLocalFile(book: TPPBook, url: URL, completion: (() -> Void)?) { +#if LCP if LCPAudiobooks.canOpenBook(book) { let lcpAudiobooks = LCPAudiobooks(for: url) lcpAudiobooks?.contentDictionary { [weak self] dict, error in @@ -383,7 +620,7 @@ final class BookDetailViewModel: ObservableObject { } return } - #endif +#endif do { let data = try Data(contentsOf: url) @@ -393,14 +630,14 @@ final class BookDetailViewModel: ObservableObject { return } - #if FEATURE_OVERDRIVE +#if FEATURE_OVERDRIVE if book.distributor == OverdriveDistributorKey { var overdriveJson = json overdriveJson["id"] = book.identifier openAudiobook(with: book, json: overdriveJson, drmDecryptor: nil, completion: completion) return } - #endif +#endif openAudiobook(with: book, json: json, drmDecryptor: nil, completion: completion) } catch { diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index d36b63771..0240aa195 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -606,7 +606,12 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { } if bytesWritten == totalBytesWritten { - guard let mimeType = downloadTask.response?.mimeType else { return } + guard let mimeType = downloadTask.response?.mimeType else { + Log.error(#file, "No MIME type in response for book: \(book.identifier)") + return + } + + Log.info(#file, "Download MIME type detected for \(book.identifier): \(mimeType)") switch mimeType { case ContentTypeAdobeAdept: @@ -663,6 +668,8 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { var problemDoc: TPPProblemDocument? let rights = downloadInfo(forBookIdentifier: book.identifier)?.rightsManagement ?? .unknown + Log.info(#file, "Download completed for \(book.identifier) with rights: \(rights)") + if let response = downloadTask.response, response.isProblemDocument() { do { let problemDocData = try Data(contentsOf: location) @@ -687,6 +694,7 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { switch rights { case .unknown: + Log.error(#file, "❌ Rights management is unknown for book: \(book.identifier) - LCP fulfillment will NOT be called") logBookDownloadFailure(book, reason: "Unknown rights management", downloadTask: downloadTask, metadata: nil) failureRequiringAlert = true case .adobe: @@ -704,6 +712,7 @@ extension MyBooksDownloadCenter: URLSessionDownloadDelegate { } #endif case .lcp: + Log.info(#file, "✅ Calling fulfillLCPLicense for book: \(book.identifier)") fulfillLCPLicense(fileUrl: location, forBook: book, downloadTask: downloadTask) case .simplifiedBearerTokenJSON: if let data = try? Data(contentsOf: location) { @@ -943,6 +952,11 @@ extension MyBooksDownloadCenter { } self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) + // For audiobooks, also copy license to content directory for streaming support + if book.defaultBookContentType == .audiobook { + self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) + } + Task { if book.defaultBookContentType == .pdf, let bookURL = self.fileUrl(for: book.identifier) { @@ -954,12 +968,45 @@ extension MyBooksDownloadCenter { } let fulfillmentDownloadTask = lcpService.fulfill(licenseUrl, progress: lcpProgress, completion: lcpCompletion) + if book.defaultBookContentType == .audiobook { + bookRegistry.setState(.downloadSuccessful, for: book.identifier) + } + if let fulfillmentDownloadTask = fulfillmentDownloadTask { self.bookIdentifierToDownloadInfo[book.identifier] = MyBooksDownloadInfo(downloadProgress: 0.0, downloadTask: fulfillmentDownloadTask, rightsManagement: .none) } #endif } + /// Copies the LCP license file to the content directory for streaming support + /// while preserving the existing fulfillment flow + private func copyLicenseForStreaming(book: TPPBook, sourceLicenseUrl: URL) { +#if LCP + guard let finalContentURL = self.fileUrl(for: book.identifier) else { + Log.error(#file, "Unable to determine final content URL for streaming license copy") + return + } + + let streamingLicenseUrl = finalContentURL.deletingPathExtension().appendingPathExtension("lcpl") + + do { + // Remove any existing license file first + try? FileManager.default.removeItem(at: streamingLicenseUrl) + + // Copy license to content directory for streaming + try FileManager.default.copyItem(at: sourceLicenseUrl, to: streamingLicenseUrl) + Log.debug(#file, "Copied LCP license for streaming: \(streamingLicenseUrl.path)") + } catch { + Log.error(#file, "Failed to copy LCP license for streaming: \(error.localizedDescription)") + TPPErrorLogger.logError(error, summary: "Failed to copy LCP license for streaming", metadata: [ + "book": book.loggableDictionary, + "sourceLicenseUrl": sourceLicenseUrl.absoluteString, + "targetLicenseUrl": streamingLicenseUrl.absoluteString + ]) + } +#endif + } + func failDownloadWithAlert(for book: TPPBook, withMessage message: String? = nil) { let location = bookRegistry.location(forIdentifier: book.identifier) From 20b75b7e447b2745b96132def13a2770086b8fe6 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Thu, 7 Aug 2025 14:12:32 -0400 Subject: [PATCH 02/32] working streaming --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 45 ++++ .../UI/BookDetail/BookDetailViewModel.swift | 216 ++++++++++++++---- Palace/MyBooks/MyBooksDownloadCenter.swift | 63 ++++- 3 files changed, 264 insertions(+), 60 deletions(-) diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index 99e86effe..b553ccf9f 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -134,6 +134,51 @@ import PalaceAudiobookToolkit ///// DRM Decryptor for LCP audiobooks extension LCPAudiobooks: DRMDecryptor { + /// Get streamable resource URL for AVPlayer (for true streaming without local files) + /// - Parameters: + /// - trackPath: internal track path from manifest (e.g., "track1.mp3") + /// - completion: callback with streamable URL or error + @objc func getStreamableURL(for trackPath: String, completion: @escaping (URL?, Error?) -> Void) { + Task { + let result = await self.assetRetriever.retrieve(url: audiobookUrl) + switch result { + case .success(let asset): + let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) + switch publicationResult { + case .success(let publication): + if let resource = publication.getResource(at: trackPath) { + // For LCP streaming, we need to create a custom URL that can be handled by Readium + // This would ideally be a streamable URL that AVPlayer can use + // For now, let's construct the HTTP URL manually based on the publication URL + if let httpUrl = constructStreamingURL(for: trackPath) { + completion(httpUrl, nil) + } else { + completion(nil, NSError(domain: "LCPStreaming", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct streaming URL"])) + } + } else { + completion(nil, NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found"])) + } + case .failure(let error): + completion(nil, error) + } + case .failure(let error): + completion(nil, error) + } + } + } + + /// Construct HTTP streaming URL for a track path + private func constructStreamingURL(for trackPath: String) -> URL? { + // The audiobookUrl should be the HTTP publication URL when streaming + guard let httpUrl = audiobookUrl as? HTTPURL else { + return nil + } + + // Construct the full HTTP URL for the track + let baseUrl = httpUrl.url + return URL(string: trackPath, relativeTo: baseUrl) + } + /// Decrypt protected file /// - Parameters: /// - url: encrypted file URL. diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index f5527ffc3..72ad10714 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -71,9 +71,11 @@ final class BookDetailViewModel: ObservableObject { switch bookState { case .downloadNeeded: // LCP audiobooks can be "opened" to start license fulfillment + // Log.debug(#file, "🎵 LCP audiobook downloadNeeded → showing downloadSuccessful") return .downloadSuccessful case .downloading: // Show as downloading while license fulfillment is in progress + // Log.debug(#file, "🎵 LCP audiobook downloading → showing downloadInProgress") return BookButtonMapper.map( registryState: bookState, availability: avail, @@ -81,18 +83,22 @@ final class BookDetailViewModel: ObservableObject { ) case .downloadSuccessful, .used: // Already downloaded/fulfilled + // Log.debug(#file, "🎵 LCP audiobook downloadSuccessful → showing LISTEN button") return .downloadSuccessful default: + Log.debug(#file, "🎵 LCP audiobook other state: \(bookState)") break } } #endif - return BookButtonMapper.map( + let mappedState = BookButtonMapper.map( registryState: bookState, availability: avail, isProcessingDownload: isDownloading ) + Log.debug(#file, "🎵 Default button mapping: \(bookState) → \(mappedState)") + return mappedState } // MARK: - Initializer @@ -124,8 +130,14 @@ final class BookDetailViewModel: ObservableObject { .sink { [weak self] newState in guard let self else { return } let updatedBook = registry.book(forIdentifier: book.identifier) ?? book + let currentState = self.bookState + let registryState = registry.state(for: book.identifier) + + Log.info(#file, "🎵 Publisher state change for \(book.identifier): \(currentState) → \(registryState)") + Log.info(#file, "🎵 New button state will be: \(buttonState)") + self.book = updatedBook - self.bookState = registry.state(for: book.identifier) + self.bookState = registryState } .store(in: &cancellables) } @@ -156,8 +168,13 @@ final class BookDetailViewModel: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self else { return } let updatedBook = registry.book(forIdentifier: book.identifier) ?? book + let newState = registry.state(for: book.identifier) + + Log.info(#file, "🎵 Registry state change for \(book.identifier): \(bookState) → \(newState)") + Log.info(#file, "🎵 Button state will be: \(buttonState)") + self.book = updatedBook - self.bookState = registry.state(for: book.identifier) + self.bookState = newState } } @@ -380,12 +397,14 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - // Check if we have a local file already -// if let url = downloadCenter.fileUrl(for: book.identifier) { -// // File exists, proceed with normal flow -// openAudiobookWithLocalFile(book: book, url: url, completion: completion) -// return -// } + // Check if we have a local file already - verify file actually exists + if let url = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: url.path) { + // File exists, proceed with normal flow + Log.info(#file, "Opening LCP audiobook with local file: \(book.identifier)") + openAudiobookWithLocalFile(book: book, url: url, completion: completion) + return + } // No local file - for LCP audiobooks, check for license-based streaming #if LCP @@ -394,7 +413,7 @@ final class BookDetailViewModel: ObservableObject { if let licenseUrl = getLCPLicenseURL(for: book) { // Have license, open in streaming mode using swifttoolkit 2.1.0 Log.info(#file, "Opening LCP audiobook in streaming mode: \(book.identifier)") - openAudiobookWithStreaming(book: book, licenseUrl: licenseUrl, completion: completion) + openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) return } @@ -413,7 +432,7 @@ final class BookDetailViewModel: ObservableObject { Log.info(#file, "Downloading LCP license from: \(acqURL.absoluteString)") } downloadCenter.startDownload(for: book) - // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment starts + // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment completes completion?() return } @@ -453,7 +472,7 @@ final class BookDetailViewModel: ObservableObject { // Check if license file is now available if let licenseUrl = getLCPLicenseURL(for: book) { Log.info(#file, "License fulfillment completed, opening in streaming mode: \(book.identifier)") - openAudiobookWithStreaming(book: book, licenseUrl: licenseUrl, completion: completion) + openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) return } @@ -472,41 +491,57 @@ final class BookDetailViewModel: ObservableObject { } } - private func openAudiobookWithStreaming(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { + private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { #if LCP - Log.info(#file, "Opening LCP audiobook for streaming using Readium toolkit: \(book.identifier)") + Log.info(#file, "Opening LCP audiobook for streaming: \(book.identifier)") Log.debug(#file, "License available at: \(licenseUrl.absoluteString)") - // Use Readium LCP service directly to get publication manifest for streaming - // This avoids the need for a local .lcpa file that LCPAudiobooks expects - getPublicationManifestForStreaming(licenseUrl: licenseUrl) { [weak self] result in + // Get the publication URL from the license + guard let license = TPPLCPLicense(url: licenseUrl), + let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href) else { + Log.error(#file, "Failed to extract publication URL from license") + self.presentUnsupportedItemError() + completion?() + return + } + + Log.info(#file, "Using publication URL for streaming: \(publicationUrl.absoluteString)") + + // Create LCPAudiobooks with the HTTP publication URL (this works since LCPAudiobooks supports HTTP URLs) + guard let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { + Log.error(#file, "Failed to create LCPAudiobooks for streaming URL") + self.presentUnsupportedItemError() + completion?() + return + } + + // Use the same contentDictionary pattern as local files - this is the proven path! + lcpAudiobooks.contentDictionary { [weak self] dict, error in DispatchQueue.main.async { guard let self = self else { return } - switch result { - case .success(let manifestDict): - var jsonDict = manifestDict - jsonDict["id"] = book.identifier - jsonDict["streamingEnabled"] = true // Enable swift-toolkit 2.1.0 streaming - - // For streaming, create an LCPAudiobooks instance using the publication URL from the manifest - // The LCPAudiobooks class can handle HTTP URLs for streaming - if let publicationUrl = self.getPublicationUrlFromManifest(manifestDict), - let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) { - - Log.info(#file, "Opening LCP audiobook for streaming with PalaceAudiobookToolkit: \(book.identifier)") - self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) - } else { - Log.error(#file, "Failed to create LCPAudiobooks for streaming decryption") - self.presentUnsupportedItemError() - completion?() - } - - case .failure(let error): - Log.error(#file, "Failed to get publication manifest for streaming: \(error)") + if let error { + Log.error(#file, "Failed to get content dictionary for streaming: \(error)") self.presentUnsupportedItemError() completion?() + return + } + + guard let dict else { + Log.error(#file, "No content dictionary returned for streaming") + self.presentCorruptedItemError() + completion?() + return } + + // Use the exact same pattern as openAudiobookWithLocalFile + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + + Log.info(#file, "✅ Got content dictionary for streaming, opening with AudiobookFactory") + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) } } #endif @@ -648,7 +683,7 @@ final class BookDetailViewModel: ObservableObject { func openAudiobook(with book: TPPBook, json: [String: Any], drmDecryptor: DRMDecryptor?, completion: (() -> Void)?) { AudioBookVendorsHelper.updateVendorKey(book: json) { [weak self] error in DispatchQueue.main.async { - guard let self = self else { return } + guard let self else { return } if let error { self.presentDRMKeyError(error) @@ -657,19 +692,38 @@ final class BookDetailViewModel: ObservableObject { } let manifestDecoder = Manifest.customDecoder() - guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []), - let manifest = try? manifestDecoder.decode(Manifest.self, from: jsonData), - let audiobook = AudiobookFactory.audiobook( - for: manifest, - bookIdentifier: book.identifier, - decryptor: drmDecryptor, - token: book.bearerToken - ) else { + guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else { + Log.error(#file, "❌ Failed to serialize manifest JSON") + self.presentUnsupportedItemError() + completion?() + return + } + + guard let manifest = try? manifestDecoder.decode(Manifest.self, from: jsonData) else { + Log.error(#file, "❌ Failed to decode manifest from JSON") + self.presentUnsupportedItemError() + completion?() + return + } + + Log.info(#file, "✅ Manifest decoded successfully, creating audiobook with AudiobookFactory") + + // Use the original Readium manifest - no enhancement needed for proper Readium streaming + + guard let audiobook = AudiobookFactory.audiobook( + for: manifest, + bookIdentifier: book.identifier, + decryptor: drmDecryptor, + token: book.bearerToken + ) else { + Log.error(#file, "❌ AudiobookFactory failed to create audiobook") self.presentUnsupportedItemError() completion?() return } + Log.info(#file, "✅ Audiobook created successfully, launching player") + self.launchAudiobook(book: book, audiobook: audiobook) completion?() } @@ -677,6 +731,9 @@ final class BookDetailViewModel: ObservableObject { } @MainActor private func launchAudiobook(book: TPPBook, audiobook: Audiobook) { + Log.info(#file, "🎵 Launching audiobook player for: \(book.identifier)") + Log.info(#file, "🎵 Audiobook has \(audiobook.tableOfContents.allTracks.count) tracks") + var timeTracker: AudiobookTimeTracker? if let libraryId = AccountsManager.shared.currentAccount?.uuid, let timeTrackingURL = book.timeTrackingURL { timeTracker = AudiobookTimeTracker(libraryId: libraryId, bookId: book.identifier, timeTrackingUrl: timeTrackingURL) @@ -691,15 +748,21 @@ final class BookDetailViewModel: ObservableObject { playbackTrackerDelegate: timeTracker ) - guard let audiobookManager else { return } + guard let audiobookManager else { + Log.error(#file, "❌ Failed to create audiobook manager") + return + } + + Log.info(#file, "✅ AudiobookManager created successfully") audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) - + Log.info(#file, "✅ AudiobookPlayer created, presenting view controller") TPPRootTabBarController.shared().pushViewController(audiobookPlayer!, animated: true) + Log.info(#file, "🎵 Syncing audiobook location and starting playback") syncAudiobookLocation(for: book) scheduleTimer() } @@ -737,6 +800,8 @@ final class BookDetailViewModel: ObservableObject { toc: manager.audiobook.tableOfContents.toc, tracks: manager.audiobook.tableOfContents.tracks ) else { + // No saved location - start playing from the beginning + startPlaybackFromBeginning() return } @@ -762,6 +827,21 @@ final class BookDetailViewModel: ObservableObject { } } } + + /// Starts audiobook playback from the beginning (first track, position 0) + private func startPlaybackFromBeginning() { + guard let manager = audiobookManager, + let firstTrack = manager.audiobook.tableOfContents.tracks.first else { + Log.error(#file, "Cannot start playback: no audiobook manager or tracks") + return + } + + // Create position for start of first track + let startPosition = TrackPosition(track: firstTrack, timestamp: 0.0, tracks: manager.audiobook.tableOfContents.tracks) + + Log.info(#file, "Starting audiobook playback from beginning") + audiobookManager?.audiobook.player.play(at: startPosition, completion: nil) + } // MARK: - Samples @@ -948,4 +1028,42 @@ extension BookDetailViewModel: BookButtonProvider { } } +// MARK: - LCP Streaming Enhancement + +private extension BookDetailViewModel { + /// For LCP audiobooks, Readium 2.1.0 already provides proper streaming support + /// No enhancement needed - just use the manifest as-is from Readium + func enhanceManifestForLCPStreaming(manifest: PalaceAudiobookToolkit.Manifest, drmDecryptor: DRMDecryptor?) -> PalaceAudiobookToolkit.Manifest { + Log.debug(#file, "🔍 enhanceManifestForLCPStreaming called with drmDecryptor: \(type(of: drmDecryptor))") + + // For LCP audiobooks, Readium already handles streaming correctly via DRMDecryptor.decrypt + if drmDecryptor is LCPAudiobooks { + Log.info(#file, "✅ LCP audiobook detected - using Readium 2.1.0 streaming (no enhancement needed)") + Log.info(#file, "✅ Manifest has \(manifest.readingOrder?.count ?? 0) tracks, will use Readium streaming") + return manifest // Use as-is - Readium handles streaming via decrypt() calls + } + + Log.debug(#file, "Not an LCP audiobook, using original manifest") + return manifest + } + + /// Extract publication URL from LCPAudiobooks instance + func getPublicationUrl(from lcpAudiobooks: LCPAudiobooks) -> URL? { + // For now, we need to reconstruct the publication URL + // Since we know this came from openAudiobookUnified, we can get it from the book's license + + guard let licenseUrl = getLCPLicenseURL(for: book), + let license = TPPLCPLicense(url: licenseUrl), + let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href) else { + Log.error(#file, "Failed to extract publication URL from license for streaming enhancement") + return nil + } + + Log.debug(#file, "📥 Extracted publication URL for streaming: \(publicationUrl.absoluteString)") + return publicationUrl + } +} + extension BookDetailViewModel: HalfSheetProvider {} diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index 0240aa195..f58d93b68 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -943,18 +943,40 @@ extension MyBooksDownloadCenter { return } guard let localUrl = localUrl, - let license = TPPLCPLicense(url: licenseUrl), - self.replaceBook(book, withFileAtURL: localUrl, forDownloadTask: downloadTask) + let license = TPPLCPLicense(url: licenseUrl) else { - let errorMessage = "Error replacing license file with file \(localUrl?.absoluteString ?? "")" + let errorMessage = "Error with LCP license fulfillment: \(localUrl?.absoluteString ?? "")" self.failDownloadWithAlert(for: book, withMessage: errorMessage) return } self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) - // For audiobooks, also copy license to content directory for streaming support - if book.defaultBookContentType == .audiobook { - self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) +// // For audiobooks: License is ready, mark as downloadSuccessful immediately for streaming +// // Content download continues in background for offline use +// if book.defaultBookContentType == .audiobook { +// Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") +// self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) +// self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) +// +// // Broadcast immediately so UI updates +// DispatchQueue.main.async { +// self.broadcastUpdate() +// } +// } + + // For all content types: Continue with content storage (background for audiobooks, required for others) + if !self.replaceBook(book, withFileAtURL: localUrl, forDownloadTask: downloadTask) { + if book.defaultBookContentType == .audiobook { + Log.warn(#file, "Content storage failed for audiobook, but streaming still available") + } else { + let errorMessage = "Error replacing content file with file \(localUrl.absoluteString)" + self.failDownloadWithAlert(for: book, withMessage: errorMessage) + return + } + } else { + if book.defaultBookContentType == .audiobook { + Log.info(#file, "Audiobook content stored successfully, offline playback now available") + } } Task { @@ -968,8 +990,15 @@ extension MyBooksDownloadCenter { } let fulfillmentDownloadTask = lcpService.fulfill(licenseUrl, progress: lcpProgress, completion: lcpCompletion) + if book.defaultBookContentType == .audiobook { - bookRegistry.setState(.downloadSuccessful, for: book.identifier) + Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") + self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) + self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) + + DispatchQueue.main.async { + self.broadcastUpdate() + } } if let fulfillmentDownloadTask = fulfillmentDownloadTask { @@ -982,12 +1011,16 @@ extension MyBooksDownloadCenter { /// while preserving the existing fulfillment flow private func copyLicenseForStreaming(book: TPPBook, sourceLicenseUrl: URL) { #if LCP + Log.info(#file, "🎵 Starting license copy for streaming: \(book.identifier)") + guard let finalContentURL = self.fileUrl(for: book.identifier) else { - Log.error(#file, "Unable to determine final content URL for streaming license copy") + Log.error(#file, "🎵 ❌ Unable to determine final content URL for streaming license copy") return } let streamingLicenseUrl = finalContentURL.deletingPathExtension().appendingPathExtension("lcpl") + Log.info(#file, "🎵 Copying license FROM: \(sourceLicenseUrl.path)") + Log.info(#file, "🎵 Copying license TO: \(streamingLicenseUrl.path)") do { // Remove any existing license file first @@ -995,9 +1028,13 @@ extension MyBooksDownloadCenter { // Copy license to content directory for streaming try FileManager.default.copyItem(at: sourceLicenseUrl, to: streamingLicenseUrl) - Log.debug(#file, "Copied LCP license for streaming: \(streamingLicenseUrl.path)") + + // Verify the copy was successful + let fileExists = FileManager.default.fileExists(atPath: streamingLicenseUrl.path) + Log.info(#file, "🎵 ✅ License copy successful, file exists: \(fileExists)") + Log.info(#file, "🎵 ✅ License ready for streaming at: \(streamingLicenseUrl.path)") } catch { - Log.error(#file, "Failed to copy LCP license for streaming: \(error.localizedDescription)") + Log.error(#file, "🎵 ❌ Failed to copy LCP license for streaming: \(error.localizedDescription)") TPPErrorLogger.logError(error, summary: "Failed to copy LCP license for streaming", metadata: [ "book": book.loggableDictionary, "sourceLicenseUrl": sourceLicenseUrl.absoluteString, @@ -1089,7 +1126,11 @@ extension MyBooksDownloadCenter { guard let destURL = fileUrl(for: book.identifier) else { return false } do { let _ = try FileManager.default.replaceItemAt(destURL, withItemAt: sourceLocation, options: .usingNewMetadataOnly) - bookRegistry.setState(.downloadSuccessful, for: book.identifier) + // Note: For LCP audiobooks, state is set in fulfillLCPLicense after license is ready + // For other content types, set state here after content is successfully stored + if book.defaultBookContentType != .audiobook { + bookRegistry.setState(.downloadSuccessful, for: book.identifier) + } return true } catch { logBookDownloadFailure(book, From c2a86c6bcee87db658dfee7f839e9465f5138313 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Tue, 12 Aug 2025 15:04:04 -0400 Subject: [PATCH 03/32] working implementation --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 187 ++- .../UI/BookDetail/BookDetailViewModel.swift | 1091 ++++++++--------- .../UI/TPPBookCellDelegate+Extensions.swift | 2 +- Palace/MyBooks/MyBooksDownloadCenter.swift | 12 - tmp.swift | 204 +++ 5 files changed, 875 insertions(+), 621 deletions(-) create mode 100644 tmp.swift diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index b553ccf9f..ae082c438 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -26,6 +26,9 @@ import PalaceAudiobookToolkit private let lcpService = LCPLibraryService() private let assetRetriever: AssetRetriever private let publicationOpener: PublicationOpener + + private var cachedPublication: Publication? + private let publicationCacheLock = NSLock() /// Initialize for an LCP audiobook /// - Parameter audiobookUrl: must be a file with `.lcpa` extension @@ -77,6 +80,10 @@ import PalaceAudiobookToolkit switch result { case .success(let publication): + publicationCacheLock.lock() + cachedPublication = publication + publicationCacheLock.unlock() + guard let jsonManifestString = publication.jsonManifest else { TPPErrorLogger.logError(nil, summary: "No resource found for audiobook.", metadata: [self.audiobookUrlKey: self.audiobookUrl]) completion(nil, nil) @@ -131,7 +138,49 @@ import PalaceAudiobookToolkit } } -///// DRM Decryptor for LCP audiobooks +extension LCPAudiobooks: LCPStreamingProvider { + + public func getPublication() -> Publication? { + publicationCacheLock.lock() + defer { publicationCacheLock.unlock() } + return cachedPublication + } + + public func supportsStreaming() -> Bool { + return true + } + + public func setupStreamingFor(_ player: Any) -> Bool { + guard let streamingPlayer = player as? StreamingCapablePlayer else { + ATLog(.error, "🎵 [LCPAudiobooks] Player does not support streaming") + return false + } + + publicationCacheLock.lock() + let hasPublication = cachedPublication != nil + publicationCacheLock.unlock() + + if !hasPublication { + let semaphore = DispatchSemaphore(value: 0) + var loadSuccess = false + + loadContentDictionary { json, error in + loadSuccess = (json != nil && error == nil) + semaphore.signal() + } + + semaphore.wait() + + if !loadSuccess { + return false + } + } + + streamingPlayer.setStreamingProvider(self) + return true + } +} + extension LCPAudiobooks: DRMDecryptor { /// Get streamable resource URL for AVPlayer (for true streaming without local files) @@ -139,27 +188,25 @@ extension LCPAudiobooks: DRMDecryptor { /// - trackPath: internal track path from manifest (e.g., "track1.mp3") /// - completion: callback with streamable URL or error @objc func getStreamableURL(for trackPath: String, completion: @escaping (URL?, Error?) -> Void) { + // Use fast URL construction first (avoids expensive license processing) + if let streamingUrl = constructStreamingURL(for: trackPath) { + completion(streamingUrl, nil) + return + } + Task { - let result = await self.assetRetriever.retrieve(url: audiobookUrl) - switch result { - case .success(let asset): - let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) - switch publicationResult { - case .success(let publication): - if let resource = publication.getResource(at: trackPath) { - // For LCP streaming, we need to create a custom URL that can be handled by Readium - // This would ideally be a streamable URL that AVPlayer can use - // For now, let's construct the HTTP URL manually based on the publication URL - if let httpUrl = constructStreamingURL(for: trackPath) { - completion(httpUrl, nil) - } else { - completion(nil, NSError(domain: "LCPStreaming", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct streaming URL"])) - } + let publication = await getCachedPublication() + + switch publication { + case .success(let pub): + if let resource = pub.getResource(at: trackPath) { + if let httpUrl = constructStreamingURL(for: trackPath) { + completion(httpUrl, nil) } else { - completion(nil, NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found"])) + completion(nil, NSError(domain: "LCPStreaming", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct streaming URL"])) } - case .failure(let error): - completion(nil, error) + } else { + completion(nil, NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found"])) } case .failure(let error): completion(nil, error) @@ -167,16 +214,102 @@ extension LCPAudiobooks: DRMDecryptor { } } - /// Construct HTTP streaming URL for a track path + func getPublication(completion: @escaping (Publication?, Error?) -> Void) { + Task { + let result = await getCachedPublication() + switch result { + case .success(let publication): + completion(publication, nil) + case .failure(let error): + completion(nil, error) + } + } + } + + /// Get cached publication or load it if not cached + private func getCachedPublication() async -> Result { + publicationCacheLock.lock() + defer { publicationCacheLock.unlock() } + + if let cached = cachedPublication { + return .success(cached) + } + + let result = await self.assetRetriever.retrieve(url: audiobookUrl) + switch result { + case .success(let asset): + let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) + switch publicationResult { + case .success(let publication): + cachedPublication = publication + return .success(publication) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + } + private func constructStreamingURL(for trackPath: String) -> URL? { - // The audiobookUrl should be the HTTP publication URL when streaming - guard let httpUrl = audiobookUrl as? HTTPURL else { + if let httpUrl = audiobookUrl as? HTTPURL { + return URL(string: trackPath, relativeTo: httpUrl.url) + } + + guard let fileUrl = audiobookUrl as? FileURL else { return nil } - - // Construct the full HTTP URL for the track - let baseUrl = httpUrl.url - return URL(string: trackPath, relativeTo: baseUrl) + + var licenseURL = fileUrl.url + if licenseURL.pathExtension.lowercased() != "lcpl" { + let sibling = licenseURL.deletingPathExtension().appendingPathExtension("lcpl") + if FileManager.default.fileExists(atPath: sibling.path) { + licenseURL = sibling + } else { + let dir = licenseURL.deletingLastPathComponent() + if let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil), + let found = contents.first(where: { $0.pathExtension.lowercased() == "lcpl" }) { + licenseURL = found + } else { + TPPErrorLogger.logError(nil, summary: "LCP streaming: license file not found near content", metadata: [ + "contentURL": licenseURL.absoluteString + ]) + return nil + } + } + } + + do { + let licenseData = try Data(contentsOf: licenseURL) + guard let licenseJson = try JSONSerialization.jsonObject(with: licenseData) as? [String: Any] else { + TPPErrorLogger.logError(nil, summary: "LCP streaming: license is not valid JSON", metadata: [ + "licenseURL": licenseURL.absoluteString + ]) + return nil + } + + if let links = licenseJson["links"] as? [[String: Any]] { + + for link in links { + if let rel = link["rel"] as? String, + rel == "publication", + let href = link["href"] as? String, + let publicationUrl = URL(string: href) { + return URL(string: trackPath, relativeTo: publicationUrl) + } + } + } + + TPPErrorLogger.logError(nil, summary: "LCP streaming: publication link not found in license", metadata: [ + "licenseURL": licenseURL.absoluteString + ]) + } catch { + TPPErrorLogger.logError(error, summary: "Failed to read/parse license file for streaming URL construction", metadata: [ + "licenseURL": licenseURL.absoluteString + ]) + } + + return nil } /// Decrypt protected file @@ -215,10 +348,8 @@ extension LCPAudiobooks: DRMDecryptor { private extension Publication { func getResource(at path: String) -> Resource? { - // Directly pass the path without prepending "/" let resource = get(Link(href: path)) guard type(of: resource) != FailureResource.self else { - // Attempt again with prepending "/" return get(Link(href: "/" + path)) } diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 72ad10714..b8c7f40f8 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -15,48 +15,48 @@ struct BookLane { final class BookDetailViewModel: ObservableObject { @Published var book: TPPBook - + /// The registry state, e.g. `unregistered`, `downloading`, `downloadSuccessful`, etc. @Published var bookState: TPPBookState - + @Published var bookmarks: [TPPReadiumBookmark] = [] @Published var showSampleToolbar = false @Published var downloadProgress: Double = 0.0 - + @Published var relatedBooksByLane: [String: BookLane] = [:] @Published var isLoadingRelatedBooks = false @Published var isLoadingDescription = false @Published var selectedBookURL: URL? = nil @Published var isManagingHold: Bool = false @Published var showHalfSheet = false - + var isFullSize: Bool { UIDevice.current.isIpad } - + @Published var processingButtons: Set = [] { didSet { isProcessing = processingButtons.count > 0 } } @Published var isProcessing: Bool = false - + var isShowingSample = false var isProcessingSample = false - + // MARK: - Dependencies - + let registry: TPPBookRegistry let downloadCenter = MyBooksDownloadCenter.shared private var cancellables = Set() - + private var audiobookViewController: UIViewController? private var audiobookManager: DefaultAudiobookManager? private var audiobookPlayer: AudiobookPlayer? private var audiobookBookmarkBusinessLogic: AudiobookBookmarkBusinessLogic? private var timer: DispatchSourceTimer? private var previousPlayheadOffset: TimeInterval = 0 - + // MARK: – Computed Button State - + var buttonState: BookButtonState { let isDownloading = (bookState == .downloading) let avail = book.defaultAcquisition?.availability @@ -100,27 +100,27 @@ final class BookDetailViewModel: ObservableObject { Log.debug(#file, "🎵 Default button mapping: \(bookState) → \(mappedState)") return mappedState } - + // MARK: - Initializer - + @objc init(book: TPPBook) { self.book = book self.registry = TPPBookRegistry.shared self.bookState = registry.state(for: book.identifier) - + bindRegistryState() setupObservers() self.downloadProgress = downloadCenter.downloadProgress(for: book.identifier) } - + deinit { timer?.cancel() timer = nil NotificationCenter.default.removeObserver(self) } - + // MARK: - Book State Binding - + private func bindRegistryState() { registry .bookStatePublisher @@ -141,22 +141,22 @@ final class BookDetailViewModel: ObservableObject { } .store(in: &cancellables) } - + private func setupObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleBookRegistryChange(_:)), - name: .TPPBookRegistryDidChange, - object: nil - ) - + NotificationCenter.default.addObserver( + self, + selector: #selector(handleBookRegistryChange(_:)), + name: .TPPBookRegistryDidChange, + object: nil + ) + NotificationCenter.default.addObserver( self, selector: #selector(handleDownloadStateDidChange(_:)), name: .TPPMyBooksDownloadCenterDidChange, object: nil ) - + downloadCenter.downloadProgressPublisher .filter { $0.0 == self.book.identifier } .map { $0.1 } @@ -177,17 +177,17 @@ final class BookDetailViewModel: ObservableObject { self.bookState = newState } } - + func selectRelatedBook(_ newBook: TPPBook) { guard newBook.identifier != book.identifier else { return } book = newBook bookState = registry.state(for: newBook.identifier) fetchRelatedBooks() } - + // MARK: - Notifications - - + + @objc func handleDownloadStateDidChange(_ notification: Notification) { DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -200,18 +200,18 @@ final class BookDetailViewModel: ObservableObject { } } } - + // MARK: - Related Books - + func fetchRelatedBooks() { guard let url = book.relatedWorksURL else { return } - + isLoadingRelatedBooks = true relatedBooksByLane = [:] - + TPPOPDSFeed.withURL(url, shouldResetCache: false, useTokenIfAvailable: TPPUserAccount.sharedAccount().hasAdobeToken()) { [weak self] feed, _ in guard let self else { return } - + DispatchQueue.main.async { if feed?.type == .acquisitionGrouped { let groupedFeed = TPPCatalogGroupedFeed(opdsFeed: feed) @@ -222,15 +222,15 @@ final class BookDetailViewModel: ObservableObject { } } } - + private func createRelatedBooksCells(_ groupedFeed: TPPCatalogGroupedFeed?) { guard let feed = groupedFeed else { self.isLoadingRelatedBooks = false return } - + var groupedBooks = [String: BookLane]() - + for lane in feed.lanes as! [TPPCatalogLane] { if let books = lane.books as? [TPPBook] { let laneTitle = lane.title ?? "Unknown Lane" @@ -239,7 +239,7 @@ final class BookDetailViewModel: ObservableObject { groupedBooks[laneTitle] = bookLane } } - + if let author = book.authors, !author.isEmpty { if let authorLane = groupedBooks.first(where: { $0.value.books.contains(where: { $0.authors?.contains(author) ?? false }) }) { groupedBooks.removeValue(forKey: authorLane.key) @@ -249,26 +249,26 @@ final class BookDetailViewModel: ObservableObject { groupedBooks = reorderedBooks } } - + DispatchQueue.main.async { self.relatedBooksByLane = groupedBooks self.isLoadingRelatedBooks = false } } - + func showMoreBooksForLane(laneTitle: String) { guard let lane = relatedBooksByLane[laneTitle] else { return } if let subsectionURL = lane.subsectionURL { self.selectedBookURL = subsectionURL } } - + // MARK: - Button Actions - + func handleAction(for button: BookButtonType) { guard !isProcessing(for: button) else { return } processingButtons.insert(button) - + switch button { case .reserve: downloadCenter.startDownload(for: book) @@ -286,25 +286,25 @@ final class BookDetailViewModel: ObservableObject { self.bookState = .unregistered self.isManagingHold = false } - + case .download, .get, .retry: didSelectDownload(for: book) removeProcessingButton(button) - + case .read, .listen: didSelectRead(for: book) { self.removeProcessingButton(button) } - + case .cancel: didSelectCancel() removeProcessingButton(button) - + case .sample, .audiobookSample: didSelectPlaySample(for: book) { self.removeProcessingButton(button) } - + case .close: break @@ -314,38 +314,38 @@ final class BookDetailViewModel: ObservableObject { break } } - + private func removeProcessingButton(_ button: BookButtonType) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { self.processingButtons.remove(button) } } - + func isProcessing(for button: BookButtonType) -> Bool { processingButtons.contains(button) } - + // MARK: - Download/Return/Cancel - + func didSelectDownload(for book: TPPBook) { downloadCenter.startDownload(for: book) } - + func didSelectCancel() { downloadCenter.cancelDownload(for: book.identifier) self.downloadProgress = 0 } - + func didSelectReturn(for book: TPPBook, completion: (() -> Void)?) { downloadCenter.returnBook(withIdentifier: book.identifier, completion: completion) } - + // MARK: - Reading - + func didSelectRead(for book: TPPBook, completion: (() -> Void)?) { #if FEATURE_DRM_CONNECTOR let user = TPPUserAccount.sharedAccount() - + if user.hasCredentials() { if user.hasAuthToken() { openBook(book, completion: completion) @@ -364,11 +364,11 @@ final class BookDetailViewModel: ObservableObject { #endif openBook(book, completion: completion) } - + func openBook(_ book: TPPBook, completion: (() -> Void)?) { TPPCirculationAnalytics.postEvent("open_book", withBook: book) processingButtons.removeAll() - + switch book.defaultBookContentType { case .epub: presentEPUB(book) @@ -380,306 +380,237 @@ final class BookDetailViewModel: ObservableObject { presentUnsupportedItemError() } } - + private func presentEPUB(_ book: TPPBook) { - TPPRootTabBarController.shared().presentBook(book) - } - - private func presentPDF(_ book: TPPBook) { - guard let bookUrl = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { return } - let data = try? Data(contentsOf: bookUrl) - let metadata = TPPPDFDocumentMetadata(with: book) - let document = TPPPDFDocument(data: data ?? Data()) - let pdfViewController = TPPPDFViewController.create(document: document, metadata: metadata) - TPPRootTabBarController.shared().pushViewController(pdfViewController, animated: true) + TPPRootTabBarController.shared().presentBook(book) + } + + private func presentPDF(_ book: TPPBook) { + guard let bookUrl = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { return } + let data = try? Data(contentsOf: bookUrl) + let metadata = TPPPDFDocumentMetadata(with: book) + let document = TPPPDFDocument(data: data ?? Data()) + let pdfViewController = TPPPDFViewController.create(document: document, metadata: metadata) + TPPRootTabBarController.shared().pushViewController(pdfViewController, animated: true) + } + + // MARK: - Audiobook Opening + + func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { + // First priority: Check if we have a local file already - verify file actually exists + if let url = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: url.path) { + // File exists, proceed with normal flow + Log.info(#file, "Opening LCP audiobook with local file: \(book.identifier)") + openAudiobookWithLocalFile(book: book, url: url, completion: completion) + return } - - // MARK: - Audiobook Opening - - func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - // Check if we have a local file already - verify file actually exists - if let url = downloadCenter.fileUrl(for: book.identifier), - FileManager.default.fileExists(atPath: url.path) { - // File exists, proceed with normal flow - Log.info(#file, "Opening LCP audiobook with local file: \(book.identifier)") - openAudiobookWithLocalFile(book: book, url: url, completion: completion) + + // No local file - for LCP audiobooks, check for license-based streaming +#if LCP + if LCPAudiobooks.canOpenBook(book) { + // Check if we have license file for streaming + if let licenseUrl = getLCPLicenseURL(for: book) { + // Have license, open in streaming mode as fallback + Log.info(#file, "Opening LCP audiobook in streaming mode: \(book.identifier)") + openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) return } - // No local file - for LCP audiobooks, check for license-based streaming -#if LCP - if LCPAudiobooks.canOpenBook(book) { - // Check if we have license file for streaming - if let licenseUrl = getLCPLicenseURL(for: book) { - // Have license, open in streaming mode using swifttoolkit 2.1.0 - Log.info(#file, "Opening LCP audiobook in streaming mode: \(book.identifier)") - openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) - return - } - - // License not found yet - check if fulfillment is in progress - if bookState == .downloadSuccessful { - // Book is marked as ready but license not found - fulfillment may be in progress - Log.info(#file, "License fulfillment may be in progress, waiting for completion: \(book.identifier)") - waitForLicenseFulfillment(book: book, completion: completion) - return - } - - // No license yet, start fulfillment - Log.info(#file, "No local file for LCP audiobook, starting license fulfillment: \(book.identifier)") - Log.info(#file, "Expected LCP MIME type: application/vnd.readium.lcp.license.v1.0+json") - if let acqURL = book.defaultAcquisition?.hrefURL { - Log.info(#file, "Downloading LCP license from: \(acqURL.absoluteString)") - } - downloadCenter.startDownload(for: book) - // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment completes - completion?() + // License not found yet - check if fulfillment is in progress + if bookState == .downloadSuccessful { + // Book is marked as ready but license not found - fulfillment may be in progress + Log.info(#file, "License fulfillment may be in progress, waiting for completion: \(book.identifier)") + waitForLicenseFulfillment(book: book, completion: completion) return } -#endif - presentCorruptedItemError() + // No license yet, start fulfillment + Log.info(#file, "No local file for LCP audiobook, starting license fulfillment: \(book.identifier)") + Log.info(#file, "Expected LCP MIME type: application/vnd.readium.lcp.license.v1.0+json") + if let acqURL = book.defaultAcquisition?.hrefURL { + Log.info(#file, "Downloading LCP license from: \(acqURL.absoluteString)") + } + downloadCenter.startDownload(for: book) + // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment completes completion?() + return } +#endif - private func getLCPLicenseURL(for book: TPPBook) -> URL? { + presentCorruptedItemError() + completion?() + } + + private func getLCPLicenseURL(for book: TPPBook) -> URL? { #if LCP - // Check for license file in the same location as content files - // License is stored as {hashedIdentifier}.lcpl in the content directory - guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { - return nil - } - - // License has same path but .lcpl extension - let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") - - if FileManager.default.fileExists(atPath: licenseURL.path) { - Log.debug(#file, "Found LCP license at: \(licenseURL.path)") - return licenseURL - } - - Log.debug(#file, "No LCP license found at: \(licenseURL.path)") + // Check for license file in the same location as content files + // License is stored as {hashedIdentifier}.lcpl in the content directory + guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { return nil + } + + // License has same path but .lcpl extension + let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") + + if FileManager.default.fileExists(atPath: licenseURL.path) { + Log.debug(#file, "Found LCP license at: \(licenseURL.path)") + return licenseURL + } + + Log.debug(#file, "No LCP license found at: \(licenseURL.path)") + return nil #else - return nil + return nil #endif + } + + private func waitForLicenseFulfillment(book: TPPBook, completion: (() -> Void)? = nil, attempt: Int = 0) { + let maxAttempts = 10 // Wait up to 10 seconds + let retryDelay: TimeInterval = 1.0 + + // Check if license file is now available + if let licenseUrl = getLCPLicenseURL(for: book) { + Log.info(#file, "License fulfillment completed, opening in streaming mode: \(book.identifier)") + openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) + return } - private func waitForLicenseFulfillment(book: TPPBook, completion: (() -> Void)? = nil, attempt: Int = 0) { - let maxAttempts = 10 // Wait up to 10 seconds - let retryDelay: TimeInterval = 1.0 - - // Check if license file is now available - if let licenseUrl = getLCPLicenseURL(for: book) { - Log.info(#file, "License fulfillment completed, opening in streaming mode: \(book.identifier)") - openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) - return - } - - // If we've exceeded max attempts, show error - if attempt >= maxAttempts { - Log.error(#file, "Timeout waiting for license fulfillment after \(attempt) attempts") - presentUnsupportedItemError() - completion?() - return - } - - // Wait and retry - Log.debug(#file, "License not ready yet, waiting... (attempt \(attempt + 1)/\(maxAttempts))") - DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in - self?.waitForLicenseFulfillment(book: book, completion: completion, attempt: attempt + 1) - } + // If we've exceeded max attempts, show error + if attempt >= maxAttempts { + Log.error(#file, "Timeout waiting for license fulfillment after \(attempt) attempts") + presentUnsupportedItemError() + completion?() + return } - private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { + // Wait and retry + Log.debug(#file, "License not ready yet, waiting... (attempt \(attempt + 1)/\(maxAttempts))") + DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in + self?.waitForLicenseFulfillment(book: book, completion: completion, attempt: attempt + 1) + } + } + + private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { #if LCP - Log.info(#file, "Opening LCP audiobook for streaming: \(book.identifier)") - Log.debug(#file, "License available at: \(licenseUrl.absoluteString)") - - // Get the publication URL from the license - guard let license = TPPLCPLicense(url: licenseUrl), - let publicationLink = license.firstLink(withRel: .publication), - let href = publicationLink.href, - let publicationUrl = URL(string: href) else { - Log.error(#file, "Failed to extract publication URL from license") - self.presentUnsupportedItemError() - completion?() - return - } - - Log.info(#file, "Using publication URL for streaming: \(publicationUrl.absoluteString)") - - // Create LCPAudiobooks with the HTTP publication URL (this works since LCPAudiobooks supports HTTP URLs) - guard let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { - Log.error(#file, "Failed to create LCPAudiobooks for streaming URL") - self.presentUnsupportedItemError() - completion?() - return + Log.info(#file, "Opening LCP audiobook for streaming: \(book.identifier)") + Log.debug(#file, "License available at: \(licenseUrl.absoluteString)") + + // Get the publication URL from the license + guard let license = TPPLCPLicense(url: licenseUrl), + let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href) else { + Log.error(#file, "Failed to extract publication URL from license") + self.presentUnsupportedItemError() + completion?() + return + } + + Log.info(#file, "Using publication URL for streaming: \(publicationUrl.absoluteString)") + + // Create LCPAudiobooks with the HTTP publication URL (this works since LCPAudiobooks supports HTTP URLs) + guard let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { + Log.error(#file, "Failed to create LCPAudiobooks for streaming URL") + self.presentUnsupportedItemError() + completion?() + return + } + + // Use the same contentDictionary pattern as local files - this is the proven path! + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + + if let error { + Log.error(#file, "Failed to get content dictionary for streaming: \(error)") + self.presentUnsupportedItemError() + completion?() + return + } + + guard let dict else { + Log.error(#file, "No content dictionary returned for streaming") + self.presentCorruptedItemError() + completion?() + return + } + + // Use the exact same pattern as openAudiobookWithLocalFile + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + + Log.info(#file, "✅ Got content dictionary for streaming, opening with AudiobookFactory") + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) } - - // Use the same contentDictionary pattern as local files - this is the proven path! - lcpAudiobooks.contentDictionary { [weak self] dict, error in + } +#endif + } + + /// Gets the publication manifest for streaming using Readium LCP service directly + + + /// Extracts the publication URL from the license for use with LCPAudiobooks + /// Since we already have the publication URL from parsing the license, we can store and reuse it + private var cachedPublicationUrl: URL? + + private func getPublicationUrlFromManifest(_ manifest: [String: Any]) -> URL? { + // Return the cached publication URL that we got from the license + return cachedPublicationUrl + } + + private func openAudiobookWithLocalFile(book: TPPBook, url: URL, completion: (() -> Void)?) { +#if LCP + if LCPAudiobooks.canOpenBook(book) { + let lcpAudiobooks = LCPAudiobooks(for: url) + lcpAudiobooks?.contentDictionary { [weak self] dict, error in DispatchQueue.main.async { guard let self = self else { return } - if let error { - Log.error(#file, "Failed to get content dictionary for streaming: \(error)") self.presentUnsupportedItemError() completion?() return } guard let dict else { - Log.error(#file, "No content dictionary returned for streaming") self.presentCorruptedItemError() completion?() return } - // Use the exact same pattern as openAudiobookWithLocalFile var jsonDict = dict as? [String: Any] ?? [:] jsonDict["id"] = book.identifier - - Log.info(#file, "✅ Got content dictionary for streaming, opening with AudiobookFactory") self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) } } -#endif - } - - /// Gets the publication manifest for streaming using Readium LCP service directly - /// This bypasses the need for a local .lcpa file by using just the license - private func getPublicationManifestForStreaming(licenseUrl: URL, completion: @escaping (Result<[String: Any], Error>) -> Void) { - #if LCP - let lcpService = LCPLibraryService() - - Task { - do { - // 1️⃣ Parse the license - guard let license = TPPLCPLicense(url: licenseUrl) else { - throw NSError(domain: "LCPStreaming", code: 1, userInfo: [ NSLocalizedDescriptionKey: "Failed to parse license file" ]) - } - - // 2️⃣ Extract the publication URL - guard let publicationLink = license.firstLink(withRel: .publication), - let href = publicationLink.href, - let publicationUrl = URL(string: href) else { - throw NSError(domain: "LCPStreaming", code: 2, userInfo: [ NSLocalizedDescriptionKey: "No publication URL found in license" ]) - } - Log.debug(#file, "Found publication URL for streaming: \(publicationUrl)") - - // Cache the publication URL for later use - self.cachedPublicationUrl = publicationUrl - - // 3️⃣ Prepare Readium - guard let contentProtection = lcpService.contentProtection else { - throw NSError(domain: "LCPStreaming", code: 3, userInfo: [ NSLocalizedDescriptionKey: "LCP content protection not available" ]) - } - let httpClient = DefaultHTTPClient() - let assetRetriever = AssetRetriever(httpClient: httpClient) - let parser = DefaultPublicationParser(httpClient: httpClient, - assetRetriever: assetRetriever, - pdfFactory: DefaultPDFDocumentFactory()) - let publicationOpener = PublicationOpener(parser: parser, - contentProtections: [contentProtection]) - - // 4️⃣ Retrieve the asset (unwrap the Result) - let retrieveResult = await assetRetriever.retrieve(url: publicationUrl.absoluteURL!) - let asset: Asset - switch retrieveResult { - case .success(let a): - asset = a - case .failure(let retrievalError): - completion(.failure(retrievalError)) - return - } - - // 5️⃣ Open the publication - let openResult = await publicationOpener.open(asset: asset, - allowUserInteraction: false, - sender: nil) - - switch openResult { - case .success(let publication): - guard let jsonManifestString = publication.jsonManifest, - let jsonData = jsonManifestString.data(using: .utf8), - let manifestDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { - throw NSError(domain: "LCPStreaming", code: 4, userInfo: [ NSLocalizedDescriptionKey: "No manifest or failed JSON parse" ]) - } - Log.debug(#file, "Successfully retrieved publication manifest for streaming") - completion(.success(manifestDict)) - - case .failure(let error): - completion(.failure(error)) - } - - } catch { - completion(.failure(error)) - } - } - #endif - } - - /// Extracts the publication URL from the license for use with LCPAudiobooks - /// Since we already have the publication URL from parsing the license, we can store and reuse it - private var cachedPublicationUrl: URL? - - private func getPublicationUrlFromManifest(_ manifest: [String: Any]) -> URL? { - // Return the cached publication URL that we got from the license - return cachedPublicationUrl + return } +#endif - private func openAudiobookWithLocalFile(book: TPPBook, url: URL, completion: (() -> Void)?) { -#if LCP - if LCPAudiobooks.canOpenBook(book) { - let lcpAudiobooks = LCPAudiobooks(for: url) - lcpAudiobooks?.contentDictionary { [weak self] dict, error in - DispatchQueue.main.async { - guard let self = self else { return } - if let error { - self.presentUnsupportedItemError() - completion?() - return - } - - guard let dict else { - self.presentCorruptedItemError() - completion?() - return - } - - var jsonDict = dict as? [String: Any] ?? [:] - jsonDict["id"] = book.identifier - self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) - } - } + do { + let data = try Data(contentsOf: url) + guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + presentUnsupportedItemError() + completion?() return } -#endif - - do { - let data = try Data(contentsOf: url) - guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { - presentUnsupportedItemError() - completion?() - return - } - + #if FEATURE_OVERDRIVE - if book.distributor == OverdriveDistributorKey { - var overdriveJson = json - overdriveJson["id"] = book.identifier - openAudiobook(with: book, json: overdriveJson, drmDecryptor: nil, completion: completion) - return - } -#endif - - openAudiobook(with: book, json: json, drmDecryptor: nil, completion: completion) - } catch { - presentCorruptedItemError() - completion?() + if book.distributor == OverdriveDistributorKey { + var overdriveJson = json + overdriveJson["id"] = book.identifier + openAudiobook(with: book, json: overdriveJson, drmDecryptor: nil, completion: completion) + return } +#endif + + openAudiobook(with: book, json: json, drmDecryptor: nil, completion: completion) + } catch { + presentCorruptedItemError() + completion?() } + } + func openAudiobook(with book: TPPBook, json: [String: Any], drmDecryptor: DRMDecryptor?, completion: (() -> Void)?) { AudioBookVendorsHelper.updateVendorKey(book: json) { [weak self] error in DispatchQueue.main.async { @@ -706,7 +637,14 @@ final class BookDetailViewModel: ObservableObject { return } - Log.info(#file, "✅ Manifest decoded successfully, creating audiobook with AudiobookFactory") + // DEBUG: Log which manifest we're actually using + Log.info(#file, "✅ Manifest decoded successfully - contains \(manifest.readingOrder?.count ?? 0) reading order items") + if let readingOrder = manifest.readingOrder { + Log.debug(#file, "🔍 First 5 tracks: \(readingOrder.prefix(5).compactMap { $0.title })") + Log.debug(#file, "🔍 Last 5 tracks: \(readingOrder.suffix(5).compactMap { $0.title })") + } + + Log.info(#file, "✅ Creating audiobook with AudiobookFactory") // Use the original Readium manifest - no enhancement needed for proper Readium streaming @@ -722,304 +660,293 @@ final class BookDetailViewModel: ObservableObject { return } - Log.info(#file, "✅ Audiobook created successfully, launching player") + Log.info(#file, "✅ Audiobook created successfully") - self.launchAudiobook(book: book, audiobook: audiobook) + // Streaming resources are now handled automatically by LCPPlayer - no early population needed + + Log.info(#file, "✅ Launching audiobook player") + + self.launchAudiobook(book: book, audiobook: audiobook, drmDecryptor: drmDecryptor) completion?() } } } - - @MainActor private func launchAudiobook(book: TPPBook, audiobook: Audiobook) { - Log.info(#file, "🎵 Launching audiobook player for: \(book.identifier)") - Log.info(#file, "🎵 Audiobook has \(audiobook.tableOfContents.allTracks.count) tracks") + + @MainActor private func launchAudiobook(book: TPPBook, audiobook: Audiobook, drmDecryptor: DRMDecryptor?) { + Log.info(#file, "🎵 Launching audiobook player for: \(book.identifier)") + Log.info(#file, "🎵 Audiobook has \(audiobook.tableOfContents.allTracks.count) tracks") + + var timeTracker: AudiobookTimeTracker? + if let libraryId = AccountsManager.shared.currentAccount?.uuid, let timeTrackingURL = book.timeTrackingURL { + timeTracker = AudiobookTimeTracker(libraryId: libraryId, bookId: book.identifier, timeTrackingUrl: timeTrackingURL) + } + + let metadata = AudiobookMetadata(title: book.title, authors: [book.authors ?? ""]) + + audiobookManager = DefaultAudiobookManager( + metadata: metadata, + audiobook: audiobook, + networkService: DefaultAudiobookNetworkService(tracks: audiobook.tableOfContents.allTracks, decryptor: drmDecryptor), + playbackTrackerDelegate: timeTracker + ) + + guard let audiobookManager else { + Log.error(#file, "❌ Failed to create audiobook manager") + return + } + + Log.info(#file, "✅ AudiobookManager created successfully") + + audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) + audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic + audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) + + Log.info(#file, "✅ AudiobookPlayer created, presenting view controller") + TPPRootTabBarController.shared().pushViewController(audiobookPlayer!, animated: true) + + Log.info(#file, "🎵 Syncing audiobook location and starting playback") + syncAudiobookLocation(for: book) + scheduleTimer() + } + + /// Syncs audiobook playback position from local or remote bookmarks + private func syncAudiobookLocation(for book: TPPBook) { + let localLocation = TPPBookRegistry.shared.location(forIdentifier: book.identifier) + + guard let dictionary = localLocation?.locationStringDictionary(), + let localBookmark = AudioBookmark.create(locatorData: dictionary), + let manager = audiobookManager, + let localPosition = TrackPosition( + audioBookmark: localBookmark, + toc: manager.audiobook.tableOfContents.toc, + tracks: manager.audiobook.tableOfContents.tracks + ) else { + // No saved location - start playing from the beginning + startPlaybackFromBeginning() + return + } + + // Streaming resources are now handled automatically by LCPPlayer + + audiobookManager?.audiobook.player.play(at: localPosition, completion: nil) + + TPPBookRegistry.shared.syncLocation(for: book) { [weak self] remoteBookmark in + guard let remoteBookmark, let self, let audiobookManager else { return } - var timeTracker: AudiobookTimeTracker? - if let libraryId = AccountsManager.shared.currentAccount?.uuid, let timeTrackingURL = book.timeTrackingURL { - timeTracker = AudiobookTimeTracker(libraryId: libraryId, bookId: book.identifier, timeTrackingUrl: timeTrackingURL) - } - - let metadata = AudiobookMetadata(title: book.title, authors: [book.authors ?? ""]) - - audiobookManager = DefaultAudiobookManager( - metadata: metadata, - audiobook: audiobook, - networkService: DefaultAudiobookNetworkService(tracks: audiobook.tableOfContents.allTracks), - playbackTrackerDelegate: timeTracker + let remotePosition = TrackPosition( + audioBookmark: remoteBookmark, + toc: audiobookManager.audiobook.tableOfContents.toc, + tracks: audiobookManager.audiobook.tableOfContents.tracks ) - - guard let audiobookManager else { - Log.error(#file, "❌ Failed to create audiobook manager") - return - } - Log.info(#file, "✅ AudiobookManager created successfully") - - audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) - audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic - audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) - - Log.info(#file, "✅ AudiobookPlayer created, presenting view controller") - TPPRootTabBarController.shared().pushViewController(audiobookPlayer!, animated: true) - - Log.info(#file, "🎵 Syncing audiobook location and starting playback") - syncAudiobookLocation(for: book) - scheduleTimer() - } - - private func setupAudiobookPlayback(book: TPPBook, audiobook: Audiobook) { - let metadata = AudiobookMetadata(title: book.title, authors: [book.authors ?? ""]) - audiobookManager = DefaultAudiobookManager( - metadata: metadata, - audiobook: audiobook, - networkService: DefaultAudiobookNetworkService(tracks: audiobook.tableOfContents.allTracks) - ) - - guard let audiobookManager else { return } - - audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) - audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic - - let audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) - TPPRootTabBarController.shared().pushViewController(audiobookPlayer, animated: true) - - syncAudiobookLocation(for: book) - - scheduleTimer() - } - - /// Syncs audiobook playback position from local or remote bookmarks - private func syncAudiobookLocation(for book: TPPBook) { - let localLocation = TPPBookRegistry.shared.location(forIdentifier: book.identifier) - - guard let dictionary = localLocation?.locationStringDictionary(), - let localBookmark = AudioBookmark.create(locatorData: dictionary), - let manager = audiobookManager, - let localPosition = TrackPosition( - audioBookmark: localBookmark, - toc: manager.audiobook.tableOfContents.toc, - tracks: manager.audiobook.tableOfContents.tracks - ) else { - // No saved location - start playing from the beginning - startPlaybackFromBeginning() - return - } - - audiobookManager?.audiobook.player.play(at: localPosition, completion: nil) - - TPPBookRegistry.shared.syncLocation(for: book) { [weak self] remoteBookmark in - guard let remoteBookmark, let self, let audiobookManager else { return } - - let remotePosition = TrackPosition( - audioBookmark: remoteBookmark, - toc: audiobookManager.audiobook.tableOfContents.toc, - tracks: audiobookManager.audiobook.tableOfContents.tracks - ) - - self.chooseLocalLocation( - localPosition: localPosition, - remotePosition: remotePosition, - serverUpdateDelay: 300 - ) { position in - DispatchQueue.main.async { - self.audiobookManager?.audiobook.player.play(at: position, completion: nil) - } + self.chooseLocalLocation( + localPosition: localPosition, + remotePosition: remotePosition, + serverUpdateDelay: 300 + ) { position in + DispatchQueue.main.async { + // Streaming resources are now handled automatically by LCPPlayer + + self.audiobookManager?.audiobook.player.play(at: position, completion: nil) } } } + } + + /// Starts audiobook playback from the beginning (first track, position 0) + private func startPlaybackFromBeginning() { + guard let manager = audiobookManager, + let firstTrack = manager.audiobook.tableOfContents.tracks.first else { + Log.error(#file, "Cannot start playback: no audiobook manager or tracks") + return + } - /// Starts audiobook playback from the beginning (first track, position 0) - private func startPlaybackFromBeginning() { - guard let manager = audiobookManager, - let firstTrack = manager.audiobook.tableOfContents.tracks.first else { - Log.error(#file, "Cannot start playback: no audiobook manager or tracks") - return + // Create position for start of first track + let startPosition = TrackPosition(track: firstTrack, timestamp: 0.0, tracks: manager.audiobook.tableOfContents.tracks) + + // Streaming resources are now handled automatically by LCPPlayer + + Log.info(#file, "Starting audiobook playbook from beginning") + audiobookManager?.audiobook.player.play(at: startPosition, completion: nil) + } + + // MARK: - Samples + + func didSelectPlaySample(for book: TPPBook, completion: (() -> Void)?) { + guard !isProcessingSample else { return } + isProcessingSample = true + + if book.defaultBookContentType == .audiobook { + if book.sampleAcquisition?.type == "text/html" { + presentWebView(book.sampleAcquisition?.hrefURL) + isProcessingSample = false + completion?() + } else if !isShowingSample { + isShowingSample = true + showSampleToolbar = true + isProcessingSample = false + completion?() } - - // Create position for start of first track - let startPosition = TrackPosition(track: firstTrack, timestamp: 0.0, tracks: manager.audiobook.tableOfContents.tracks) - - Log.info(#file, "Starting audiobook playback from beginning") - audiobookManager?.audiobook.player.play(at: startPosition, completion: nil) - } - - // MARK: - Samples - - func didSelectPlaySample(for book: TPPBook, completion: (() -> Void)?) { - guard !isProcessingSample else { return } - isProcessingSample = true - - if book.defaultBookContentType == .audiobook { - if book.sampleAcquisition?.type == "text/html" { - presentWebView(book.sampleAcquisition?.hrefURL) - isProcessingSample = false - completion?() - } else if !isShowingSample { - isShowingSample = true - showSampleToolbar = true - isProcessingSample = false - completion?() - } - NotificationCenter.default.post(name: Notification.Name("ToggleSampleNotification"), object: self) - } else { - EpubSampleFactory.createSample(book: book) { sampleURL, error in - DispatchQueue.main.async { - if let error = error { - Log.debug("Sample generation error for \(book.title): \(error.localizedDescription)", "") - } else if let sampleWebURL = sampleURL as? EpubSampleWebURL { - self.presentWebView(sampleWebURL.url) - } else if let sampleURL = sampleURL?.url { - TPPRootTabBarController.shared().presentSample(book, url: sampleURL) - } - self.isProcessingSample = false - completion?() + NotificationCenter.default.post(name: Notification.Name("ToggleSampleNotification"), object: self) + } else { + EpubSampleFactory.createSample(book: book) { sampleURL, error in + DispatchQueue.main.async { + if let error = error { + Log.debug("Sample generation error for \(book.title): \(error.localizedDescription)", "") + } else if let sampleWebURL = sampleURL as? EpubSampleWebURL { + self.presentWebView(sampleWebURL.url) + } else if let sampleURL = sampleURL?.url { + TPPRootTabBarController.shared().presentSample(book, url: sampleURL) } + self.isProcessingSample = false + completion?() } } } + } + + private func presentWebView(_ url: URL?) { + guard let url = url else { return } + let webController = BundledHTMLViewController( + fileURL: url, + title: AccountsManager.shared.currentAccount?.name ?? "" + ) + + let root = TPPRootTabBarController.shared() + let top = root?.topMostViewController + top?.present(webController, animated: true) + } + + // MARK: - Error Alerts + + private func presentCorruptedItemError() { + let alert = UIAlertController( + title: NSLocalizedString("Corrupted Audiobook", comment: ""), + message: NSLocalizedString("The audiobook you are trying to open appears to be corrupted. Try downloading it again.", comment: ""), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + } + + private func presentUnsupportedItemError() { + let alert = UIAlertController( + title: NSLocalizedString("Unsupported Item", comment: ""), + message: NSLocalizedString("This item format is not supported.", comment: ""), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + } + + private func presentDRMKeyError(_ error: Error) { + let alert = UIAlertController(title: "DRM Error", message: error.localizedDescription, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) + } +} - private func presentWebView(_ url: URL?) { - guard let url = url else { return } - let webController = BundledHTMLViewController( - fileURL: url, - title: AccountsManager.shared.currentAccount?.name ?? "" - ) - - let root = TPPRootTabBarController.shared() - let top = root?.topMostViewController - top?.present(webController, animated: true) - } - - // MARK: - Error Alerts - - private func presentCorruptedItemError() { - let alert = UIAlertController( - title: NSLocalizedString("Corrupted Audiobook", comment: ""), - message: NSLocalizedString("The audiobook you are trying to open appears to be corrupted. Try downloading it again.", comment: ""), - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) - } - - private func presentUnsupportedItemError() { - let alert = UIAlertController( - title: NSLocalizedString("Unsupported Item", comment: ""), - message: NSLocalizedString("This item format is not supported.", comment: ""), - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) - } - - private func presentDRMKeyError(_ error: Error) { - let alert = UIAlertController(title: "DRM Error", message: error.localizedDescription, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alert, viewController: nil, animated: true, completion: nil) +extension BookDetailViewModel { + public func scheduleTimer() { + timer?.cancel() + timer = nil + + let queue = DispatchQueue(label: "com.palace.pollAudiobookLocation", qos: .background, attributes: .concurrent) + timer = DispatchSource.makeTimerSource(queue: queue) + + timer?.schedule(deadline: .now() + kTimerInterval, repeating: kTimerInterval) + + timer?.setEventHandler { [weak self] in + self?.pollAudiobookReadingLocation() } + + timer?.resume() } - - extension BookDetailViewModel { - public func scheduleTimer() { + + @objc public func pollAudiobookReadingLocation() { + + guard let _ = self.audiobookViewController else { timer?.cancel() timer = nil - - let queue = DispatchQueue(label: "com.palace.pollAudiobookLocation", qos: .background, attributes: .concurrent) - timer = DispatchSource.makeTimerSource(queue: queue) - - timer?.schedule(deadline: .now() + kTimerInterval, repeating: kTimerInterval) - - timer?.setEventHandler { [weak self] in - self?.pollAudiobookReadingLocation() - } - - timer?.resume() + self.audiobookManager = nil + return } - - @objc public func pollAudiobookReadingLocation() { - - guard let _ = self.audiobookViewController else { - timer?.cancel() - timer = nil - self.audiobookManager = nil - return - } - - guard let currentTrackPosition = self.audiobookManager?.audiobook.player.currentTrackPosition else { - return - } - - let playheadOffset = currentTrackPosition.timestamp - if self.previousPlayheadOffset != playheadOffset && playheadOffset > 0 { - self.previousPlayheadOffset = playheadOffset - - DispatchQueue.global(qos: .background).async { [weak self] in - guard let self = self else { return } - - let locationData = try? JSONEncoder().encode(currentTrackPosition.toAudioBookmark()) - let locationString = String(data: locationData ?? Data(), encoding: .utf8) ?? "" - - DispatchQueue.main.async { - TPPBookRegistry.shared.setLocation( - TPPBookLocation(locationString: locationString, renderer: "PalaceAudiobookToolkit"), - forIdentifier: self.book.identifier - ) - - latestAudiobookLocation = (book: self.book.identifier, location: locationString) - } + + guard let currentTrackPosition = self.audiobookManager?.audiobook.player.currentTrackPosition else { + return + } + + let playheadOffset = currentTrackPosition.timestamp + if self.previousPlayheadOffset != playheadOffset && playheadOffset > 0 { + self.previousPlayheadOffset = playheadOffset + + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + let locationData = try? JSONEncoder().encode(currentTrackPosition.toAudioBookmark()) + let locationString = String(data: locationData ?? Data(), encoding: .utf8) ?? "" + + DispatchQueue.main.async { + TPPBookRegistry.shared.setLocation( + TPPBookLocation(locationString: locationString, renderer: "PalaceAudiobookToolkit"), + forIdentifier: self.book.identifier + ) + + latestAudiobookLocation = (book: self.book.identifier, location: locationString) } } } } +} - extension BookDetailViewModel { - func chooseLocalLocation(localPosition: TrackPosition?, remotePosition: TrackPosition?, serverUpdateDelay: TimeInterval, operation: @escaping (TrackPosition) -> Void) { - let remoteLocationIsNewer: Bool - - if let localPosition = localPosition, let remotePosition = remotePosition { - remoteLocationIsNewer = String.isDate(remotePosition.lastSavedTimeStamp, moreRecentThan: localPosition.lastSavedTimeStamp, with: serverUpdateDelay) - } else { - remoteLocationIsNewer = localPosition == nil && remotePosition != nil - } - - if let remotePosition = remotePosition, - remotePosition.description != localPosition?.description, - remoteLocationIsNewer { - requestSyncWithCompletion { shouldSync in - let location = shouldSync ? remotePosition : (localPosition ?? remotePosition) - operation(location) - } - } else if let localPosition = localPosition { - operation(localPosition) - } else if let remotePosition = remotePosition { - operation(remotePosition) - } +extension BookDetailViewModel { + func chooseLocalLocation(localPosition: TrackPosition?, remotePosition: TrackPosition?, serverUpdateDelay: TimeInterval, operation: @escaping (TrackPosition) -> Void) { + let remoteLocationIsNewer: Bool + + if let localPosition = localPosition, let remotePosition = remotePosition { + remoteLocationIsNewer = String.isDate(remotePosition.lastSavedTimeStamp, moreRecentThan: localPosition.lastSavedTimeStamp, with: serverUpdateDelay) + } else { + remoteLocationIsNewer = localPosition == nil && remotePosition != nil } - - func requestSyncWithCompletion(completion: @escaping (Bool) -> Void) { - DispatchQueue.main.async { - let title = LocalizedStrings.syncListeningPositionAlertTitle - let message = LocalizedStrings.syncListeningPositionAlertBody - let moveTitle = LocalizedStrings.move - let stayTitle = LocalizedStrings.stay - - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - - let moveAction = UIAlertAction(title: moveTitle, style: .default) { _ in - completion(true) - } - - let stayAction = UIAlertAction(title: stayTitle, style: .cancel) { _ in - completion(false) - } - - alertController.addAction(moveAction) - alertController.addAction(stayAction) - - TPPAlertUtils.presentFromViewControllerOrNil(alertController: alertController, viewController: nil, animated: true, completion: nil) + + if let remotePosition = remotePosition, + remotePosition.description != localPosition?.description, + remoteLocationIsNewer { + requestSyncWithCompletion { shouldSync in + let location = shouldSync ? remotePosition : (localPosition ?? remotePosition) + operation(location) + } + } else if let localPosition = localPosition { + operation(localPosition) + } else if let remotePosition = remotePosition { + operation(remotePosition) + } + } + + func requestSyncWithCompletion(completion: @escaping (Bool) -> Void) { + DispatchQueue.main.async { + let title = LocalizedStrings.syncListeningPositionAlertTitle + let message = LocalizedStrings.syncListeningPositionAlertBody + let moveTitle = LocalizedStrings.move + let stayTitle = LocalizedStrings.stay + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + + let moveAction = UIAlertAction(title: moveTitle, style: .default) { _ in + completion(true) + } + + let stayAction = UIAlertAction(title: stayTitle, style: .cancel) { _ in + completion(false) } + + alertController.addAction(moveAction) + alertController.addAction(stayAction) + + TPPAlertUtils.presentFromViewControllerOrNil(alertController: alertController, viewController: nil, animated: true, completion: nil) } } +} // MARK: – BookButtonProvider extension BookDetailViewModel: BookButtonProvider { @@ -1064,6 +991,10 @@ private extension BookDetailViewModel { Log.debug(#file, "📥 Extracted publication URL for streaming: \(publicationUrl.absoluteString)") return publicationUrl } + + + + } extension BookDetailViewModel: HalfSheetProvider {} diff --git a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift index 7ab452aa3..5b8e8d265 100644 --- a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift +++ b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift @@ -86,7 +86,7 @@ extension TPPBookCellDelegate { let audiobookManager = DefaultAudiobookManager( metadata: metadata, audiobook: audiobook, - networkService: DefaultAudiobookNetworkService(tracks: audiobook.tableOfContents.allTracks), + networkService: DefaultAudiobookNetworkService(tracks: audiobook.tableOfContents.allTracks, decryptor: drmDecryptor), playbackTrackerDelegate: timeTracker ) diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index f58d93b68..c4a213f22 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -951,18 +951,6 @@ extension MyBooksDownloadCenter { } self.bookRegistry.setFulfillmentId(license.identifier, for: book.identifier) -// // For audiobooks: License is ready, mark as downloadSuccessful immediately for streaming -// // Content download continues in background for offline use -// if book.defaultBookContentType == .audiobook { -// Log.info(#file, "LCP audiobook license fulfilled, ready for streaming: \(book.identifier)") -// self.copyLicenseForStreaming(book: book, sourceLicenseUrl: licenseUrl) -// self.bookRegistry.setState(.downloadSuccessful, for: book.identifier) -// -// // Broadcast immediately so UI updates -// DispatchQueue.main.async { -// self.broadcastUpdate() -// } -// } // For all content types: Continue with content storage (background for audiobooks, required for others) if !self.replaceBook(book, withFileAtURL: localUrl, forDownloadTask: downloadTask) { diff --git a/tmp.swift b/tmp.swift new file mode 100644 index 000000000..b6c6e7eba --- /dev/null +++ b/tmp.swift @@ -0,0 +1,204 @@ +import Foundation +import AVFoundation +import ReadiumShared +import UniformTypeIdentifiers + +final class LCPResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { + + var publication: Publication? + private let httpRangeRetriever = HTTPRangeRetriever() + + init(publication: Publication? = nil) { + self.publication = publication + super.init() + ATLog(.debug, "🎵 [LCPResourceLoader] ✅ Delegate initialized with publication: \(publication != nil)") + } + + func resourceLoader( + _ resourceLoader: AVAssetResourceLoader, + shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest + ) -> Bool { + + guard + let pub = publication, + let url = loadingRequest.request.url, + url.scheme == "fake", + url.host == "lcp-streaming" + else { + loadingRequest.finishLoading(with: NSError( + domain: "LCPResourceLoader", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid URL or missing publication"] + )) + return false + } + + let comps = url.pathComponents // ["/", "track", "{index}"] + let index = (comps.count >= 3 && comps[1] == "track") ? Int(comps[2]) ?? 0 : 0 + guard (0.. = start.. Resource? { + // Try exact + if let res = publication.get(Link(href: href)), type(of: res) != FailureResource.self { + return res + } + // Try leading slash + if let res = publication.get(Link(href: "/" + href)), type(of: res) != FailureResource.self { + return res + } + // Try resolving against baseURL if available + if let base = publication.linkWithRel(.self)?.href, let absolute = URL(string: href, relativeTo: URL(string: base)!)?.absoluteString { + if let res = publication.get(Link(href: absolute)), type(of: res) != FailureResource.self { + return res + } + } + return nil + } + static func utiIdentifier(forHref href: String, fallbackMime: String?) -> String { + let ext = URL(fileURLWithPath: href).pathExtension.lowercased() + + // Try modern UTType first + if !ext.isEmpty, let type = UTType(filenameExtension: ext) { + return type.identifier + } + + // Minimal manual mapping for common audio types + switch ext { + case "mp3": + return "public.mp3" // MP3 + case "m4a": + return "com.apple.m4a-audio" // M4A + case "mp4": + return "public.mpeg-4" // MP4 container (audio) + default: + break + } + + // Fallback to MIME-derived guesses + if let mime = fallbackMime?.lowercased() { + if mime.contains("mpeg") || mime.contains("mp3") { return "public.mp3" } + if mime.contains("m4a") || mime.contains("mp4") { return "com.apple.m4a-audio" } + } + + // Last resort + return "public.audio" + } +} From 7a51d602d004a389fab8ef7f71c51bda6a186610 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Wed, 13 Aug 2025 16:56:09 -0400 Subject: [PATCH 04/32] fixed streaming --- .../TPPUserNotifications.swift | 13 -- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 56 +++++- .../UI/BookDetail/BookDetailViewModel.swift | 164 ++++++++---------- Palace/MyBooks/MyBooksDownloadCenter.swift | 3 - 4 files changed, 132 insertions(+), 104 deletions(-) diff --git a/Palace/AppInfrastructure/TPPUserNotifications.swift b/Palace/AppInfrastructure/TPPUserNotifications.swift index 54d7c48ac..44f20e1bf 100644 --- a/Palace/AppInfrastructure/TPPUserNotifications.swift +++ b/Palace/AppInfrastructure/TPPUserNotifications.swift @@ -18,7 +18,6 @@ let DefaultActionIdentifier = "UNNotificationDefaultActionIdentifier" unCenter.delegate = self unCenter.getNotificationSettings { (settings) in if settings.authorizationStatus == .notDetermined { - Log.info(#file, "Deferring first-time UN Auth to a later time.") } else { self.registerNotificationCategories() TPPUserNotifications.requestAuthorization() @@ -153,17 +152,14 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate completionHandler() } else if response.actionIdentifier == CheckOutActionIdentifier { - Log.debug(#file, "'Check Out' Notification Action.") let userInfo = response.notification.request.content.userInfo let downloadCenter = MyBooksDownloadCenter.shared guard let bookID = userInfo["bookID"] as? String else { - Log.error(#file, "Bad user info in Local Notification. UserInfo: \n\(userInfo)") completionHandler() return } guard let book = TPPBookRegistry.shared.book(forIdentifier: bookID) else { - Log.error(#file, "Problem creating book. BookID: \(bookID)") completionHandler() return } @@ -171,7 +167,6 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate borrow(book, inBackgroundFrom: downloadCenter, completion: completionHandler) } else { - Log.warn(#file, "Unknown action identifier: \(response.actionIdentifier)") completionHandler() } } @@ -179,7 +174,6 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate private func borrow(_ book: TPPBook, inBackgroundFrom downloadCenter: MyBooksDownloadCenter, completion: @escaping () -> Void) { - // Asynchronous network task in the background app state. var bgTask: UIBackgroundTaskIdentifier = .invalid bgTask = UIApplication.shared.beginBackgroundTask { if bgTask != .invalid { @@ -190,19 +184,12 @@ extension TPPUserNotifications: UNUserNotificationCenterDelegate } } - Log.debug(#file, "Beginning background borrow task \(bgTask.rawValue)") - if bgTask == .invalid { - Log.debug(#file, "Unable to run borrow task in background") - } - - // bg task body downloadCenter.startBorrow(for: book, attemptDownload: false) { completion() guard bgTask != .invalid else { return } - Log.info(#file, "Finishing up background borrow task \(bgTask.rawValue)") UIApplication.shared.endBackgroundTask(bgTask) bgTask = .invalid } diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index ae082c438..828de6ac9 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -29,6 +29,7 @@ import PalaceAudiobookToolkit private var cachedPublication: Publication? private let publicationCacheLock = NSLock() + private var currentPrefetchTask: Task? /// Initialize for an LCP audiobook /// - Parameter audiobookUrl: must be a file with `.lcpa` extension @@ -73,13 +74,23 @@ import PalaceAudiobookToolkit } private func loadContentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> ()) { - Task { + // Cancel any prior prefetch task to avoid duplicate work + publicationCacheLock.lock() + currentPrefetchTask?.cancel() + publicationCacheLock.unlock() + + let task = Task { [weak self] in + guard let self else { return } + if Task.isCancelled { return } + switch await assetRetriever.retrieve(url: audiobookUrl) { case .success(let asset): + if Task.isCancelled { return } let result = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) switch result { case .success(let publication): + if Task.isCancelled { return } publicationCacheLock.lock() cachedPublication = publication publicationCacheLock.unlock() @@ -116,7 +127,15 @@ import PalaceAudiobookToolkit TPPErrorLogger.logError(error, summary: "Failed to retrieve audiobook asset", metadata: [self.audiobookUrlKey: self.audiobookUrl]) completion(nil, LCPAudiobooks.nsError(for: error)) } + + self.publicationCacheLock.lock() + if self.currentPrefetchTask?.isCancelled == true { self.currentPrefetchTask = nil } + self.publicationCacheLock.unlock() } + + publicationCacheLock.lock() + currentPrefetchTask = task + publicationCacheLock.unlock() } /// Check if the book is LCP audiobook @@ -181,6 +200,41 @@ extension LCPAudiobooks: LCPStreamingProvider { } } +// MARK: - Cached manifest access +extension LCPAudiobooks { + /// Returns the cached content dictionary if the publication has already been opened. + /// This avoids re-opening the asset and enables immediate UI presentation. + public func cachedContentDictionary() -> NSDictionary? { + publicationCacheLock.lock() + let publication = cachedPublication + publicationCacheLock.unlock() + + guard let publication, let jsonManifestString = publication.jsonManifest, + let jsonData = jsonManifestString.data(using: .utf8) else { + return nil + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary { + return jsonObject + } + return nil + } + + /// Start a cancellable background prefetch of the publication/manifest + public func startPrefetch() { + // Kick off a background load; completion is ignored + self.contentDictionary { _, _ in } + } + + /// Cancel any in-flight prefetch task + public func cancelPrefetch() { + publicationCacheLock.lock() + currentPrefetchTask?.cancel() + currentPrefetchTask = nil + publicationCacheLock.unlock() + } +} + extension LCPAudiobooks: DRMDecryptor { /// Get streamable resource URL for AVPlayer (for true streaming without local files) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index b8c7f40f8..9d91bb203 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -54,6 +54,7 @@ final class BookDetailViewModel: ObservableObject { private var audiobookBookmarkBusinessLogic: AudiobookBookmarkBusinessLogic? private var timer: DispatchSourceTimer? private var previousPlayheadOffset: TimeInterval = 0 + private var didPrefetchLCPStreaming = false // MARK: – Computed Button State @@ -66,27 +67,19 @@ final class BookDetailViewModel: ObservableObject { } #if LCP - // For LCP audiobooks, show as ready if they can be opened (license can be fulfilled) if LCPAudiobooks.canOpenBook(book) { switch bookState { case .downloadNeeded: - // LCP audiobooks can be "opened" to start license fulfillment - // Log.debug(#file, "🎵 LCP audiobook downloadNeeded → showing downloadSuccessful") return .downloadSuccessful case .downloading: - // Show as downloading while license fulfillment is in progress - // Log.debug(#file, "🎵 LCP audiobook downloading → showing downloadInProgress") return BookButtonMapper.map( registryState: bookState, availability: avail, isProcessingDownload: isDownloading ) case .downloadSuccessful, .used: - // Already downloaded/fulfilled - // Log.debug(#file, "🎵 LCP audiobook downloadSuccessful → showing LISTEN button") return .downloadSuccessful default: - Log.debug(#file, "🎵 LCP audiobook other state: \(bookState)") break } } @@ -97,7 +90,6 @@ final class BookDetailViewModel: ObservableObject { availability: avail, isProcessingDownload: isDownloading ) - Log.debug(#file, "🎵 Default button mapping: \(bookState) → \(mappedState)") return mappedState } @@ -111,12 +103,27 @@ final class BookDetailViewModel: ObservableObject { bindRegistryState() setupObservers() self.downloadProgress = downloadCenter.downloadProgress(for: book.identifier) +#if LCP + // Warm LCP streaming as early as possible to minimize UI blocking when opening + self.prefetchLCPStreamingIfPossible() +#endif } deinit { timer?.cancel() timer = nil NotificationCenter.default.removeObserver(self) +#if LCP + // Cancel any in-flight LCP prefetch when leaving the detail view + if let licenseUrl = getLCPLicenseURL(for: book), + let license = TPPLCPLicense(url: licenseUrl), + let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href), + let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) { + lcpAudiobooks.cancelPrefetch() + } +#endif } // MARK: - Book State Binding @@ -132,10 +139,7 @@ final class BookDetailViewModel: ObservableObject { let updatedBook = registry.book(forIdentifier: book.identifier) ?? book let currentState = self.bookState let registryState = registry.state(for: book.identifier) - - Log.info(#file, "🎵 Publisher state change for \(book.identifier): \(currentState) → \(registryState)") - Log.info(#file, "🎵 New button state will be: \(buttonState)") - + self.book = updatedBook self.bookState = registryState } @@ -169,10 +173,7 @@ final class BookDetailViewModel: ObservableObject { guard let self else { return } let updatedBook = registry.book(forIdentifier: book.identifier) ?? book let newState = registry.state(for: book.identifier) - - Log.info(#file, "🎵 Registry state change for \(book.identifier): \(bookState) → \(newState)") - Log.info(#file, "🎵 Button state will be: \(buttonState)") - + self.book = updatedBook self.bookState = newState } @@ -197,6 +198,10 @@ final class BookDetailViewModel: ObservableObject { if bookState != .downloading && bookState != .downloadSuccessful { self.bookState = registry.state(for: book.identifier) } + #if LCP + // Prefetch LCP streaming manifest in background once license is around + self.prefetchLCPStreamingIfPossible() + #endif } } } @@ -397,42 +402,26 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - // First priority: Check if we have a local file already - verify file actually exists if let url = downloadCenter.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: url.path) { - // File exists, proceed with normal flow - Log.info(#file, "Opening LCP audiobook with local file: \(book.identifier)") openAudiobookWithLocalFile(book: book, url: url, completion: completion) return } - // No local file - for LCP audiobooks, check for license-based streaming #if LCP if LCPAudiobooks.canOpenBook(book) { - // Check if we have license file for streaming if let licenseUrl = getLCPLicenseURL(for: book) { - // Have license, open in streaming mode as fallback - Log.info(#file, "Opening LCP audiobook in streaming mode: \(book.identifier)") openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) + downloadCenter.startDownload(for: book) return } - // License not found yet - check if fulfillment is in progress if bookState == .downloadSuccessful { - // Book is marked as ready but license not found - fulfillment may be in progress - Log.info(#file, "License fulfillment may be in progress, waiting for completion: \(book.identifier)") waitForLicenseFulfillment(book: book, completion: completion) return } - // No license yet, start fulfillment - Log.info(#file, "No local file for LCP audiobook, starting license fulfillment: \(book.identifier)") - Log.info(#file, "Expected LCP MIME type: application/vnd.readium.lcp.license.v1.0+json") - if let acqURL = book.defaultAcquisition?.hrefURL { - Log.info(#file, "Downloading LCP license from: \(acqURL.absoluteString)") - } downloadCenter.startDownload(for: book) - // Note: MyBooksDownloadCenter will set bookState to .downloadSuccessful when fulfillment completes completion?() return } @@ -444,21 +433,16 @@ final class BookDetailViewModel: ObservableObject { private func getLCPLicenseURL(for book: TPPBook) -> URL? { #if LCP - // Check for license file in the same location as content files - // License is stored as {hashedIdentifier}.lcpl in the content directory guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { return nil } - // License has same path but .lcpl extension let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") if FileManager.default.fileExists(atPath: licenseURL.path) { - Log.debug(#file, "Found LCP license at: \(licenseURL.path)") return licenseURL } - Log.debug(#file, "No LCP license found at: \(licenseURL.path)") return nil #else return nil @@ -466,26 +450,22 @@ final class BookDetailViewModel: ObservableObject { } private func waitForLicenseFulfillment(book: TPPBook, completion: (() -> Void)? = nil, attempt: Int = 0) { - let maxAttempts = 10 // Wait up to 10 seconds + let maxAttempts = 10 let retryDelay: TimeInterval = 1.0 - // Check if license file is now available if let licenseUrl = getLCPLicenseURL(for: book) { - Log.info(#file, "License fulfillment completed, opening in streaming mode: \(book.identifier)") openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) + // Start background download once license is available + downloadCenter.startDownload(for: book) return } - // If we've exceeded max attempts, show error if attempt >= maxAttempts { - Log.error(#file, "Timeout waiting for license fulfillment after \(attempt) attempts") presentUnsupportedItemError() completion?() return } - // Wait and retry - Log.debug(#file, "License not ready yet, waiting... (attempt \(attempt + 1)/\(maxAttempts))") DispatchQueue.main.asyncAfter(deadline: .now() + retryDelay) { [weak self] in self?.waitForLicenseFulfillment(book: book, completion: completion, attempt: attempt + 1) } @@ -493,10 +473,7 @@ final class BookDetailViewModel: ObservableObject { private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { #if LCP - Log.info(#file, "Opening LCP audiobook for streaming: \(book.identifier)") - Log.debug(#file, "License available at: \(licenseUrl.absoluteString)") - // Get the publication URL from the license guard let license = TPPLCPLicense(url: licenseUrl), let publicationLink = license.firstLink(withRel: .publication), let href = publicationLink.href, @@ -507,49 +484,85 @@ final class BookDetailViewModel: ObservableObject { return } - Log.info(#file, "Using publication URL for streaming: \(publicationUrl.absoluteString)") - // Create LCPAudiobooks with the HTTP publication URL (this works since LCPAudiobooks supports HTTP URLs) guard let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { - Log.error(#file, "Failed to create LCPAudiobooks for streaming URL") self.presentUnsupportedItemError() completion?() return } - // Use the same contentDictionary pattern as local files - this is the proven path! + // If the publication is already cached (prefetched), open immediately without waiting + if let cachedDict = lcpAudiobooks.cachedContentDictionary() { + var jsonDict = cachedDict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + return + } + + // Fallback to asynchronous content retrieval (still opens quickly after prefetch warms cache) lcpAudiobooks.contentDictionary { [weak self] dict, error in DispatchQueue.main.async { guard let self = self else { return } - - if let error { - Log.error(#file, "Failed to get content dictionary for streaming: \(error)") + if let _ = error { self.presentUnsupportedItemError() completion?() return } - guard let dict else { - Log.error(#file, "No content dictionary returned for streaming") self.presentCorruptedItemError() completion?() return } - - // Use the exact same pattern as openAudiobookWithLocalFile var jsonDict = dict as? [String: Any] ?? [:] jsonDict["id"] = book.identifier - - Log.info(#file, "✅ Got content dictionary for streaming, opening with AudiobookFactory") self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) } } #endif } + +#if LCP + private func prefetchLCPStreamingIfPossible() { + guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book), let licenseUrl = getLCPLicenseURL(for: book) else { return } + + // Skip prefetch if the audiobook file is fully downloaded locally + if let localURL = downloadCenter.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: localURL.path) { + return + } + guard let license = TPPLCPLicense(url: licenseUrl), + let publicationLink = license.firstLink(withRel: .publication), + let href = publicationLink.href, + let publicationUrl = URL(string: href), + let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { return } + + didPrefetchLCPStreaming = true + lcpAudiobooks.startPrefetch() + } +#endif /// Gets the publication manifest for streaming using Readium LCP service directly + // Lightweight manifest fetch to reduce startup latency for streaming + private func fetchStreamingManifest(from url: URL, completion: @escaping (NSDictionary?, NSError?) -> Void) { + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/audiobook+json, application/json;q=0.9, */*;q=0.1", forHTTPHeaderField: "Accept") + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { completion(nil, error as NSError); return } + guard let data = data else { completion(nil, NSError(domain: "StreamingManifest", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data"])) ; return } + do { + if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary { + completion(jsonObject, nil) + } else { + completion(nil, NSError(domain: "StreamingManifest", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"])) + } + } catch { + completion(nil, error as NSError) + } + } + task.resume() + } /// Extracts the publication URL from the license for use with LCPAudiobooks /// Since we already have the publication URL from parsing the license, we can store and reuse it private var cachedPublicationUrl: URL? @@ -624,48 +637,28 @@ final class BookDetailViewModel: ObservableObject { let manifestDecoder = Manifest.customDecoder() guard let jsonData = try? JSONSerialization.data(withJSONObject: json, options: []) else { - Log.error(#file, "❌ Failed to serialize manifest JSON") self.presentUnsupportedItemError() completion?() return } guard let manifest = try? manifestDecoder.decode(Manifest.self, from: jsonData) else { - Log.error(#file, "❌ Failed to decode manifest from JSON") self.presentUnsupportedItemError() completion?() return } - - // DEBUG: Log which manifest we're actually using - Log.info(#file, "✅ Manifest decoded successfully - contains \(manifest.readingOrder?.count ?? 0) reading order items") - if let readingOrder = manifest.readingOrder { - Log.debug(#file, "🔍 First 5 tracks: \(readingOrder.prefix(5).compactMap { $0.title })") - Log.debug(#file, "🔍 Last 5 tracks: \(readingOrder.suffix(5).compactMap { $0.title })") - } - - Log.info(#file, "✅ Creating audiobook with AudiobookFactory") - - // Use the original Readium manifest - no enhancement needed for proper Readium streaming - + guard let audiobook = AudiobookFactory.audiobook( for: manifest, bookIdentifier: book.identifier, decryptor: drmDecryptor, token: book.bearerToken ) else { - Log.error(#file, "❌ AudiobookFactory failed to create audiobook") self.presentUnsupportedItemError() completion?() return } - - Log.info(#file, "✅ Audiobook created successfully") - - // Streaming resources are now handled automatically by LCPPlayer - no early population needed - - Log.info(#file, "✅ Launching audiobook player") - + self.launchAudiobook(book: book, audiobook: audiobook, drmDecryptor: drmDecryptor) completion?() } @@ -673,9 +666,6 @@ final class BookDetailViewModel: ObservableObject { } @MainActor private func launchAudiobook(book: TPPBook, audiobook: Audiobook, drmDecryptor: DRMDecryptor?) { - Log.info(#file, "🎵 Launching audiobook player for: \(book.identifier)") - Log.info(#file, "🎵 Audiobook has \(audiobook.tableOfContents.allTracks.count) tracks") - var timeTracker: AudiobookTimeTracker? if let libraryId = AccountsManager.shared.currentAccount?.uuid, let timeTrackingURL = book.timeTrackingURL { timeTracker = AudiobookTimeTracker(libraryId: libraryId, bookId: book.identifier, timeTrackingUrl: timeTrackingURL) diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index c4a213f22..d7c13feab 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -1019,10 +1019,7 @@ extension MyBooksDownloadCenter { // Verify the copy was successful let fileExists = FileManager.default.fileExists(atPath: streamingLicenseUrl.path) - Log.info(#file, "🎵 ✅ License copy successful, file exists: \(fileExists)") - Log.info(#file, "🎵 ✅ License ready for streaming at: \(streamingLicenseUrl.path)") } catch { - Log.error(#file, "🎵 ❌ Failed to copy LCP license for streaming: \(error.localizedDescription)") TPPErrorLogger.logError(error, summary: "Failed to copy LCP license for streaming", metadata: [ "book": book.loggableDictionary, "sourceLicenseUrl": sourceLicenseUrl.absoluteString, From d6427da87efeb5403465b8a2fda9235f2b6e2392 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 10:22:00 -0400 Subject: [PATCH 05/32] Working streaming mode --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 208 +++++++++++------- .../UI/BookDetail/BookDetailViewModel.swift | 106 ++++----- .../UI/TPPBookCellDelegate+Extensions.swift | 15 ++ Palace/Book/UI/TPPBookCellDelegate.m | 2 +- Palace/Logging/TPPCirculationAnalytics.swift | 32 ++- .../AdobeDRM/AdobeDRMContentProtection.swift | 2 +- .../LCPPassphraseAuthenticationService.swift | 2 +- ios-audiobooktoolkit | 2 +- 8 files changed, 222 insertions(+), 147 deletions(-) diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index 828de6ac9..c4e19f9d0 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -23,17 +23,21 @@ import PalaceAudiobookToolkit private static let expectedAcquisitionType = "application/vnd.readium.lcp.license.v1.0+json" private let audiobookUrl: AbsoluteURL - private let lcpService = LCPLibraryService() + private let licenseUrl: URL? + private let lcpLibraryService = LCPLibraryService() private let assetRetriever: AssetRetriever private let publicationOpener: PublicationOpener + private let httpClient: DefaultHTTPClient private var cachedPublication: Publication? private let publicationCacheLock = NSLock() private var currentPrefetchTask: Task? + private var containerURL: URL? /// Initialize for an LCP audiobook - /// - Parameter audiobookUrl: must be a file with `.lcpa` extension - @objc init?(for audiobookUrl: URL) { + /// - Parameter audiobookUrl: can be a local `.lcpa` package URL OR an `.lcpl` license URL for streaming + /// - Parameter licenseUrl: optional license URL for streaming authentication (deprecated, use audiobookUrl) + @objc init?(for audiobookUrl: URL, licenseUrl: URL? = nil) { if let fileUrl = FileURL(url: audiobookUrl) { self.audiobookUrl = fileUrl @@ -43,15 +47,18 @@ import PalaceAudiobookToolkit return nil } - self.assetRetriever = AssetRetriever(httpClient: DefaultHTTPClient()) + self.licenseUrl = licenseUrl ?? (audiobookUrl.pathExtension.lowercased() == "lcpl" ? audiobookUrl : nil) + + let httpClient = DefaultHTTPClient() + self.httpClient = httpClient + self.assetRetriever = AssetRetriever(httpClient: httpClient) guard let contentProtection = lcpService.contentProtection else { - TPPErrorLogger.logError(nil, summary: "Uninitialized contentProtection in LCPAudiobooks") return nil } let parser = DefaultPublicationParser( - httpClient: DefaultHTTPClient(), + httpClient: httpClient, assetRetriever: assetRetriever, pdfFactory: DefaultPDFDocumentFactory() ) @@ -62,7 +69,6 @@ import PalaceAudiobookToolkit ) } - /// Content dictionary for `AudiobookFactory` @objc func contentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> ()) { DispatchQueue.global(qos: .userInitiated).async { self.loadContentDictionary { json, error in @@ -74,7 +80,6 @@ import PalaceAudiobookToolkit } private func loadContentDictionary(completion: @escaping (_ json: NSDictionary?, _ error: NSError?) -> ()) { - // Cancel any prior prefetch task to avoid duplicate work publicationCacheLock.lock() currentPrefetchTask?.cancel() publicationCacheLock.unlock() @@ -83,48 +88,67 @@ import PalaceAudiobookToolkit guard let self else { return } if Task.isCancelled { return } - switch await assetRetriever.retrieve(url: audiobookUrl) { + var urlToOpen: AbsoluteURL = audiobookUrl + if let licenseUrl { + if let fileUrl = FileURL(url: licenseUrl) { + urlToOpen = fileUrl + } else if let httpUrl = HTTPURL(url: licenseUrl) { + urlToOpen = httpUrl + } else { + completion(nil, NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) + return + } + } + + let result = await assetRetriever.retrieve(url: urlToOpen) + + switch result { case .success(let asset): if Task.isCancelled { return } - let result = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) + + let hostVC = TPPRootTabBarController.shared() + + var credentials: String? = nil + if let licenseUrl = licenseUrl, licenseUrl.isFileURL { + credentials = try? String(contentsOf: licenseUrl) + } + + let result = await publicationOpener.open(asset: asset, allowUserInteraction: true, credentials: credentials, sender: hostVC) switch result { case .success(let publication): + if Task.isCancelled { return } publicationCacheLock.lock() cachedPublication = publication publicationCacheLock.unlock() - guard let jsonManifestString = publication.jsonManifest else { - TPPErrorLogger.logError(nil, summary: "No resource found for audiobook.", metadata: [self.audiobookUrlKey: self.audiobookUrl]) - completion(nil, nil) - return - } - - guard let jsonData = jsonManifestString.data(using: .utf8) else { - TPPErrorLogger.logError(nil, summary: "Failed to convert manifest string to data.", metadata: [self.audiobookUrlKey: self.audiobookUrl]) - completion(nil, nil) - return - } - - do { - if let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary { - completion(jsonObject, nil) - } else { - TPPErrorLogger.logError(nil, summary: "Failed to convert manifest data to JSON object.", metadata: [self.audiobookUrlKey: self.audiobookUrl]) - completion(nil, nil) + if let jsonManifestString = publication.jsonManifest, let jsonData = jsonManifestString.data(using: .utf8), + let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? NSDictionary { + completion(jsonObject, nil) + } else { + let links = publication.readingOrder.map { link in + let hrefString = String(describing: link.href) + let typeString = link.mediaType.map { String(describing: $0) } ?? "audio/mpeg" + return [ + "href": hrefString, + "type": typeString + ] } - } catch { - TPPErrorLogger.logError(error, summary: "Error parsing JSON manifest.", metadata: [self.audiobookUrlKey: self.audiobookUrl]) - completion(nil, LCPAudiobooks.nsError(for: error)) + let minimal: [String: Any] = [ + "metadata": [ + "identifier": UUID().uuidString, + "title": String(describing: publication.metadata.title) + ], + "readingOrder": links + ] + completion(minimal as NSDictionary, nil) } case .failure(let error): - TPPErrorLogger.logError(error, summary: "Failed to open LCP audiobook", metadata: [self.audiobookUrlKey: self.audiobookUrl]) completion(nil, LCPAudiobooks.nsError(for: error)) } case .failure(let error): - TPPErrorLogger.logError(error, summary: "Failed to retrieve audiobook asset", metadata: [self.audiobookUrlKey: self.audiobookUrl]) completion(nil, LCPAudiobooks.nsError(for: error)) } @@ -150,7 +174,7 @@ import PalaceAudiobookToolkit /// - Parameter error: Error object /// - Returns: NSError object private static func nsError(for error: Error) -> NSError { - return NSError(domain: "SimplyE.LCPAudiobooks", code: 0, userInfo: [ + return NSError(domain: "Palace.LCPAudiobooks", code: 0, userInfo: [ NSLocalizedDescriptionKey: error.localizedDescription, "Error": error ]) @@ -162,7 +186,11 @@ extension LCPAudiobooks: LCPStreamingProvider { public func getPublication() -> Publication? { publicationCacheLock.lock() defer { publicationCacheLock.unlock() } - return cachedPublication + if let publication = cachedPublication { + return publication + } else { + return nil + } } public func supportsStreaming() -> Bool { @@ -171,33 +199,26 @@ extension LCPAudiobooks: LCPStreamingProvider { public func setupStreamingFor(_ player: Any) -> Bool { guard let streamingPlayer = player as? StreamingCapablePlayer else { - ATLog(.error, "🎵 [LCPAudiobooks] Player does not support streaming") return false } + streamingPlayer.setStreamingProvider(self) publicationCacheLock.lock() let hasPublication = cachedPublication != nil publicationCacheLock.unlock() if !hasPublication { - let semaphore = DispatchSemaphore(value: 0) - var loadSuccess = false - - loadContentDictionary { json, error in - loadSuccess = (json != nil && error == nil) - semaphore.signal() - } - - semaphore.wait() - - if !loadSuccess { - return false + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.loadContentDictionary { _, _ in /* ignore; loader will retry if needed */ } } } - streamingPlayer.setStreamingProvider(self) return true } + + public func getContainerURL() -> URL? { + return containerURL + } } // MARK: - Cached manifest access @@ -242,7 +263,6 @@ extension LCPAudiobooks: DRMDecryptor { /// - trackPath: internal track path from manifest (e.g., "track1.mp3") /// - completion: callback with streamable URL or error @objc func getStreamableURL(for trackPath: String, completion: @escaping (URL?, Error?) -> Void) { - // Use fast URL construction first (avoids expensive license processing) if let streamingUrl = constructStreamingURL(for: trackPath) { completion(streamingUrl, nil) return @@ -289,7 +309,21 @@ extension LCPAudiobooks: DRMDecryptor { return .success(cached) } - let result = await self.assetRetriever.retrieve(url: audiobookUrl) + // Use license URL if available, otherwise use audiobook URL + let urlToOpen: AbsoluteURL + if let licenseUrl = licenseUrl { + if let fileUrl = FileURL(url: licenseUrl) { + urlToOpen = fileUrl + } else if let httpUrl = HTTPURL(url: licenseUrl) { + urlToOpen = httpUrl + } else { + return .failure(NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) + } + } else { + urlToOpen = audiobookUrl + } + + let result = await self.assetRetriever.retrieve(url: urlToOpen) switch result { case .success(let asset): let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) @@ -306,38 +340,35 @@ extension LCPAudiobooks: DRMDecryptor { } private func constructStreamingURL(for trackPath: String) -> URL? { - if let httpUrl = audiobookUrl as? HTTPURL { - return URL(string: trackPath, relativeTo: httpUrl.url) - } - - guard let fileUrl = audiobookUrl as? FileURL else { - return nil - } - - var licenseURL = fileUrl.url - if licenseURL.pathExtension.lowercased() != "lcpl" { - let sibling = licenseURL.deletingPathExtension().appendingPathExtension("lcpl") - if FileManager.default.fileExists(atPath: sibling.path) { - licenseURL = sibling + let trackIndex: Int + + publicationCacheLock.lock() + defer { publicationCacheLock.unlock() } + + if let publication = cachedPublication { + if let index = publication.readingOrder.firstIndex(where: { link in + link.href.contains(trackPath) || link.href.hasSuffix(trackPath) + }) { + trackIndex = index } else { - let dir = licenseURL.deletingLastPathComponent() - if let contents = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil), - let found = contents.first(where: { $0.pathExtension.lowercased() == "lcpl" }) { - licenseURL = found - } else { - TPPErrorLogger.logError(nil, summary: "LCP streaming: license file not found near content", metadata: [ - "contentURL": licenseURL.absoluteString - ]) - return nil - } + let numbers = trackPath.compactMap { Int(String($0)) } + trackIndex = numbers.first ?? 0 } + } else { + let numbers = trackPath.compactMap { Int(String($0)) } + trackIndex = numbers.first ?? 0 } + + let fakeUrl = URL(string: "fake://lcp-streaming/track/\(trackIndex)") + return fakeUrl + } + private func publicationURLFromLocalLicense(_ fileUrl: FileURL) -> URL? { do { - let licenseData = try Data(contentsOf: licenseURL) + let licenseData = try Data(contentsOf: fileUrl.url) guard let licenseJson = try JSONSerialization.jsonObject(with: licenseData) as? [String: Any] else { TPPErrorLogger.logError(nil, summary: "LCP streaming: license is not valid JSON", metadata: [ - "licenseURL": licenseURL.absoluteString + "licenseURL": fileUrl.url.absoluteString ]) return nil } @@ -349,17 +380,17 @@ extension LCPAudiobooks: DRMDecryptor { rel == "publication", let href = link["href"] as? String, let publicationUrl = URL(string: href) { - return URL(string: trackPath, relativeTo: publicationUrl) + return publicationUrl } } } TPPErrorLogger.logError(nil, summary: "LCP streaming: publication link not found in license", metadata: [ - "licenseURL": licenseURL.absoluteString + "licenseURL": fileUrl.url.absoluteString ]) } catch { TPPErrorLogger.logError(error, summary: "Failed to read/parse license file for streaming URL construction", metadata: [ - "licenseURL": licenseURL.absoluteString + "licenseURL": fileUrl.url.absoluteString ]) } @@ -373,7 +404,22 @@ extension LCPAudiobooks: DRMDecryptor { /// - completion: decryptor callback with optional `Error`. func decrypt(url: URL, to resultUrl: URL, completion: @escaping (Error?) -> Void) { Task { - let result = await self.assetRetriever.retrieve(url: audiobookUrl) + // Use license URL if available, otherwise use audiobook URL + let urlToOpen: AbsoluteURL + if let licenseUrl = licenseUrl { + if let fileUrl = FileURL(url: licenseUrl) { + urlToOpen = fileUrl + } else if let httpUrl = HTTPURL(url: licenseUrl) { + urlToOpen = httpUrl + } else { + completion(NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) + return + } + } else { + urlToOpen = audiobookUrl + } + + let result = await self.assetRetriever.retrieve(url: urlToOpen) switch result { case .success(let asset): let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) @@ -400,6 +446,7 @@ extension LCPAudiobooks: DRMDecryptor { } } + private extension Publication { func getResource(at path: String) -> Resource? { let resource = get(Link(href: path)) @@ -411,3 +458,4 @@ private extension Publication { } } #endif + diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 9d91bb203..f041fc5ee 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -114,14 +114,17 @@ final class BookDetailViewModel: ObservableObject { timer = nil NotificationCenter.default.removeObserver(self) #if LCP - // Cancel any in-flight LCP prefetch when leaving the detail view - if let licenseUrl = getLCPLicenseURL(for: book), - let license = TPPLCPLicense(url: licenseUrl), - let publicationLink = license.firstLink(withRel: .publication), - let href = publicationLink.href, - let publicationUrl = URL(string: href), - let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) { - lcpAudiobooks.cancelPrefetch() + if let licenseUrl = getLCPLicenseURL(for: book) { + var lcpAudiobooks: LCPAudiobooks? + + if let localURL = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) { + lcpAudiobooks = LCPAudiobooks(for: localURL) + } else { + lcpAudiobooks = LCPAudiobooks(for: licenseUrl) + } + + lcpAudiobooks?.cancelPrefetch() } #endif } @@ -402,11 +405,11 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - if let url = downloadCenter.fileUrl(for: book.identifier), - FileManager.default.fileExists(atPath: url.path) { - openAudiobookWithLocalFile(book: book, url: url, completion: completion) - return - } +// if let url = downloadCenter.fileUrl(for: book.identifier), +// FileManager.default.fileExists(atPath: url.path) { +// openAudiobookWithLocalFile(book: book, url: url, completion: completion) +// return +// } #if LCP if LCPAudiobooks.canOpenBook(book) { @@ -415,6 +418,12 @@ final class BookDetailViewModel: ObservableObject { downloadCenter.startDownload(for: book) return } + // Fallback: open via publication URL directly (Readium will retrieve LCPL and stream) + if let publicationURL = book.defaultAcquisition?.hrefURL { + openAudiobookUnified(book: book, licenseUrl: publicationURL, completion: completion) + downloadCenter.startDownload(for: book) + return + } if bookState == .downloadSuccessful { waitForLicenseFulfillment(book: book, completion: completion) @@ -471,21 +480,42 @@ final class BookDetailViewModel: ObservableObject { } } + + private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { #if LCP - guard let license = TPPLCPLicense(url: licenseUrl), - let publicationLink = license.firstLink(withRel: .publication), - let href = publicationLink.href, - let publicationUrl = URL(string: href) else { - Log.error(#file, "Failed to extract publication URL from license") - self.presentUnsupportedItemError() - completion?() + if let localURL = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) { + guard let lcpAudiobooks = LCPAudiobooks(for: localURL) else { + self.presentUnsupportedItemError() + completion?() + return + } + + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + if let _ = error { + self.presentUnsupportedItemError() + completion?() + return + } + guard let dict else { + self.presentCorruptedItemError() + completion?() + return + } + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } + } return } - - guard let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { + // Use the license directly for streaming (Readium approach) + guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { self.presentUnsupportedItemError() completion?() return @@ -499,7 +529,6 @@ final class BookDetailViewModel: ObservableObject { return } - // Fallback to asynchronous content retrieval (still opens quickly after prefetch warms cache) lcpAudiobooks.contentDictionary { [weak self] dict, error in DispatchQueue.main.async { guard let self = self else { return } @@ -524,16 +553,11 @@ final class BookDetailViewModel: ObservableObject { #if LCP private func prefetchLCPStreamingIfPossible() { guard !didPrefetchLCPStreaming, LCPAudiobooks.canOpenBook(book), let licenseUrl = getLCPLicenseURL(for: book) else { return } - - // Skip prefetch if the audiobook file is fully downloaded locally if let localURL = downloadCenter.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: localURL.path) { return } - guard let license = TPPLCPLicense(url: licenseUrl), - let publicationLink = license.firstLink(withRel: .publication), - let href = publicationLink.href, - let publicationUrl = URL(string: href), - let lcpAudiobooks = LCPAudiobooks(for: publicationUrl) else { return } + + guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { return } didPrefetchLCPStreaming = true lcpAudiobooks.startPrefetch() @@ -543,7 +567,6 @@ final class BookDetailViewModel: ObservableObject { /// Gets the publication manifest for streaming using Readium LCP service directly - // Lightweight manifest fetch to reduce startup latency for streaming private func fetchStreamingManifest(from url: URL, completion: @escaping (NSDictionary?, NSError?) -> Void) { var request = URLRequest(url: url) request.httpMethod = "GET" @@ -568,7 +591,6 @@ final class BookDetailViewModel: ObservableObject { private var cachedPublicationUrl: URL? private func getPublicationUrlFromManifest(_ manifest: [String: Any]) -> URL? { - // Return the cached publication URL that we got from the license return cachedPublicationUrl } @@ -948,37 +970,17 @@ extension BookDetailViewModel: BookButtonProvider { // MARK: - LCP Streaming Enhancement private extension BookDetailViewModel { - /// For LCP audiobooks, Readium 2.1.0 already provides proper streaming support - /// No enhancement needed - just use the manifest as-is from Readium - func enhanceManifestForLCPStreaming(manifest: PalaceAudiobookToolkit.Manifest, drmDecryptor: DRMDecryptor?) -> PalaceAudiobookToolkit.Manifest { - Log.debug(#file, "🔍 enhanceManifestForLCPStreaming called with drmDecryptor: \(type(of: drmDecryptor))") - - // For LCP audiobooks, Readium already handles streaming correctly via DRMDecryptor.decrypt - if drmDecryptor is LCPAudiobooks { - Log.info(#file, "✅ LCP audiobook detected - using Readium 2.1.0 streaming (no enhancement needed)") - Log.info(#file, "✅ Manifest has \(manifest.readingOrder?.count ?? 0) tracks, will use Readium streaming") - return manifest // Use as-is - Readium handles streaming via decrypt() calls - } - - Log.debug(#file, "Not an LCP audiobook, using original manifest") - return manifest - } - /// Extract publication URL from LCPAudiobooks instance func getPublicationUrl(from lcpAudiobooks: LCPAudiobooks) -> URL? { - // For now, we need to reconstruct the publication URL - // Since we know this came from openAudiobookUnified, we can get it from the book's license guard let licenseUrl = getLCPLicenseURL(for: book), let license = TPPLCPLicense(url: licenseUrl), let publicationLink = license.firstLink(withRel: .publication), let href = publicationLink.href, let publicationUrl = URL(string: href) else { - Log.error(#file, "Failed to extract publication URL from license for streaming enhancement") return nil } - Log.debug(#file, "📥 Extracted publication URL for streaming: \(publicationUrl.absoluteString)") return publicationUrl } diff --git a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift index 5b8e8d265..060c2a1e1 100644 --- a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift +++ b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift @@ -15,6 +15,7 @@ let kTimerInterval: Double = 5.0 private struct AssociatedKeys { static var audiobookBookmarkBusinessLogic: UInt8 = 0 + static var playbackLoadingCancellable: UInt8 = 0 } private let locationQueue = DispatchQueue(label: "com.palace.latestAudiobookLocation", attributes: .concurrent) @@ -119,6 +120,20 @@ extension TPPBookCellDelegate { self.startLoading(audiobookPlayer) + // Extra safety: dismiss loading overlay when playback actually starts (or fails) + let cancellable = audiobookManager.audiobook.player.playbackStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + switch state { + case .started(_), .failed(_, _), .stopped(_): + self.stopLoading() + default: + break + } + } + objc_setAssociatedObject(self, &AssociatedKeys.playbackLoadingCancellable, cancellable, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + let localAudiobookLocation = TPPBookRegistry.shared.location(forIdentifier: book.identifier) guard let dictionary = localAudiobookLocation?.locationStringDictionary(), diff --git a/Palace/Book/UI/TPPBookCellDelegate.m b/Palace/Book/UI/TPPBookCellDelegate.m index 08fb6bed6..d2ab8fb4c 100644 --- a/Palace/Book/UI/TPPBookCellDelegate.m +++ b/Palace/Book/UI/TPPBookCellDelegate.m @@ -215,7 +215,7 @@ - (void)openAudiobook:(TPPBook *)book completion:(void (^ _Nullable)(void))compl #if defined(LCP) if ([LCPAudiobooks canOpenBook:book]) { - LCPAudiobooks *lcpAudiobooks = [[LCPAudiobooks alloc] initFor:url]; + LCPAudiobooks *lcpAudiobooks = [[LCPAudiobooks alloc] initFor:url licenseUrl:nil]; [lcpAudiobooks contentDictionaryWithCompletion:^(NSDictionary * _Nullable dict, NSError * _Nullable error) { if (error) { [self presentUnsupportedItemError]; diff --git a/Palace/Logging/TPPCirculationAnalytics.swift b/Palace/Logging/TPPCirculationAnalytics.swift index 38f60ac8e..b4888c3f6 100644 --- a/Palace/Logging/TPPCirculationAnalytics.swift +++ b/Palace/Logging/TPPCirculationAnalytics.swift @@ -13,19 +13,29 @@ import Foundation } private class func post(_ event: String, withURL url: URL) { - Task { - do { - let (_, response) = try await TPPNetworkExecutor.shared.GET(url) - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { - Log.info(#file, "Analytics Upload: Success for event \(event)") - } else { - handleFailure(event: event, url: url, response: response) - } - } catch { - Log.error(#file, "Analytics request failed: \(error.localizedDescription)") - handleFailure(event: event, url: url, response: nil) + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 3 + config.timeoutIntervalForResource = 3 + config.waitsForConnectivity = false + let session = URLSession(configuration: config) + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + let task = session.dataTask(with: request) { (_, response, error) in + if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { + Log.info(#file, "Analytics Upload: Success for event \(event)") + return + } + if let error = error as NSError?, error.domain == NSURLErrorDomain, error.code == NSURLErrorTimedOut { + // Downgrade noisy timeouts; queue offline for later + Log.debug(#file, "Analytics request timed out for event \(event)") + handleFailure(event: event, url: url, response: response) + return } + handleFailure(event: event, url: url, response: response) } + task.resume() } private class func handleFailure(event: String, url: URL, response: URLResponse?) { diff --git a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift index a6a294f69..df22110d6 100644 --- a/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift +++ b/Palace/Reader2/ReaderStackConfiguration/AdobeDRM/AdobeDRMContentProtection.swift @@ -168,7 +168,7 @@ extension AdobeDRMContainer: Container { guard let fileURL else { return nil } let archive = try await Archive(url: fileURL, accessMode: .read) - guard let entry = try await archive.first(where: { $0.path == path }) else { + guard let entry = try await archive.get(path) else { return nil } diff --git a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift index c31050a1d..eceb74a9a 100644 --- a/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift +++ b/Palace/Reader2/ReaderStackConfiguration/LCP/LCPPassphraseAuthenticationService.swift @@ -23,6 +23,7 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { do { let (data, _) = try await TPPNetworkExecutor.shared.GET(loansUrl, useTokenIfAvailable: true) + guard let xml = TPPXML(data: data), let entries = xml.children(withName: "entry") as? [TPPXML] else { logError("LCP passphrase retrieval error: loans XML parsing failed", "responseBody", String(data: data, encoding: .utf8) ?? "N/A") @@ -65,7 +66,6 @@ class LCPPassphraseAuthenticationService: LCPAuthenticating { private func retrievePassphraseFromHint(for license: LCPAuthenticatedLicense, reason: LCPAuthenticationReason, allowUserInteraction: Bool, sender: Any?) async -> String? { guard let hintLink = license.hintLink, let hintURL = URL(string: hintLink.href) else { - Log.error(#file, "LCP Authenticated License does not contain valid hint link") return nil } diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index 0c3ade198..4f99b62e0 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit 0c3ade1981e8ca6c569a8af527d9541ea9b06049 +Subproject commit 4f99b62e067259496f06de541faf6c63f4323c08 From 7877cf50f825c70eb4627ac6954a3db68015998f Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 10:33:35 -0400 Subject: [PATCH 06/32] Prune old logic --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 213 +--------------------- 1 file changed, 5 insertions(+), 208 deletions(-) diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index c4e19f9d0..babcffe3d 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -17,14 +17,10 @@ import PalaceAudiobookToolkit /// LCP Audiobooks helper class @objc class LCPAudiobooks: NSObject { - private let audiobookUrlKey = "audiobookUrl" - private let audioFileHrefKey = "audioFileHref" - private let destinationFileUrlKey = "destinationFileUrl" private static let expectedAcquisitionType = "application/vnd.readium.lcp.license.v1.0+json" private let audiobookUrl: AbsoluteURL private let licenseUrl: URL? - private let lcpLibraryService = LCPLibraryService() private let assetRetriever: AssetRetriever private let publicationOpener: PublicationOpener private let httpClient: DefaultHTTPClient @@ -32,7 +28,6 @@ import PalaceAudiobookToolkit private var cachedPublication: Publication? private let publicationCacheLock = NSLock() private var currentPrefetchTask: Task? - private var containerURL: URL? /// Initialize for an LCP audiobook /// - Parameter audiobookUrl: can be a local `.lcpa` package URL OR an `.lcpl` license URL for streaming @@ -215,10 +210,7 @@ extension LCPAudiobooks: LCPStreamingProvider { return true } - - public func getContainerURL() -> URL? { - return containerURL - } + } // MARK: - Cached manifest access @@ -246,6 +238,10 @@ extension LCPAudiobooks { // Kick off a background load; completion is ignored self.contentDictionary { _, _ in } } + + func decrypt(url: URL, to resultUrl: URL, completion: @escaping (Error?) -> Void) { + completion(nil) // No Op, Readium handles decryption // + } /// Cancel any in-flight prefetch task public func cancelPrefetch() { @@ -256,206 +252,7 @@ extension LCPAudiobooks { } } -extension LCPAudiobooks: DRMDecryptor { - /// Get streamable resource URL for AVPlayer (for true streaming without local files) - /// - Parameters: - /// - trackPath: internal track path from manifest (e.g., "track1.mp3") - /// - completion: callback with streamable URL or error - @objc func getStreamableURL(for trackPath: String, completion: @escaping (URL?, Error?) -> Void) { - if let streamingUrl = constructStreamingURL(for: trackPath) { - completion(streamingUrl, nil) - return - } - - Task { - let publication = await getCachedPublication() - - switch publication { - case .success(let pub): - if let resource = pub.getResource(at: trackPath) { - if let httpUrl = constructStreamingURL(for: trackPath) { - completion(httpUrl, nil) - } else { - completion(nil, NSError(domain: "LCPStreaming", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to construct streaming URL"])) - } - } else { - completion(nil, NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found"])) - } - case .failure(let error): - completion(nil, error) - } - } - } - - func getPublication(completion: @escaping (Publication?, Error?) -> Void) { - Task { - let result = await getCachedPublication() - switch result { - case .success(let publication): - completion(publication, nil) - case .failure(let error): - completion(nil, error) - } - } - } - - /// Get cached publication or load it if not cached - private func getCachedPublication() async -> Result { - publicationCacheLock.lock() - defer { publicationCacheLock.unlock() } - - if let cached = cachedPublication { - return .success(cached) - } - - // Use license URL if available, otherwise use audiobook URL - let urlToOpen: AbsoluteURL - if let licenseUrl = licenseUrl { - if let fileUrl = FileURL(url: licenseUrl) { - urlToOpen = fileUrl - } else if let httpUrl = HTTPURL(url: licenseUrl) { - urlToOpen = httpUrl - } else { - return .failure(NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) - } - } else { - urlToOpen = audiobookUrl - } - - let result = await self.assetRetriever.retrieve(url: urlToOpen) - switch result { - case .success(let asset): - let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) - switch publicationResult { - case .success(let publication): - cachedPublication = publication - return .success(publication) - case .failure(let error): - return .failure(error) - } - case .failure(let error): - return .failure(error) - } - } - - private func constructStreamingURL(for trackPath: String) -> URL? { - let trackIndex: Int - - publicationCacheLock.lock() - defer { publicationCacheLock.unlock() } - - if let publication = cachedPublication { - if let index = publication.readingOrder.firstIndex(where: { link in - link.href.contains(trackPath) || link.href.hasSuffix(trackPath) - }) { - trackIndex = index - } else { - let numbers = trackPath.compactMap { Int(String($0)) } - trackIndex = numbers.first ?? 0 - } - } else { - let numbers = trackPath.compactMap { Int(String($0)) } - trackIndex = numbers.first ?? 0 - } - - let fakeUrl = URL(string: "fake://lcp-streaming/track/\(trackIndex)") - return fakeUrl - } - - private func publicationURLFromLocalLicense(_ fileUrl: FileURL) -> URL? { - do { - let licenseData = try Data(contentsOf: fileUrl.url) - guard let licenseJson = try JSONSerialization.jsonObject(with: licenseData) as? [String: Any] else { - TPPErrorLogger.logError(nil, summary: "LCP streaming: license is not valid JSON", metadata: [ - "licenseURL": fileUrl.url.absoluteString - ]) - return nil - } - - if let links = licenseJson["links"] as? [[String: Any]] { - - for link in links { - if let rel = link["rel"] as? String, - rel == "publication", - let href = link["href"] as? String, - let publicationUrl = URL(string: href) { - return publicationUrl - } - } - } - - TPPErrorLogger.logError(nil, summary: "LCP streaming: publication link not found in license", metadata: [ - "licenseURL": fileUrl.url.absoluteString - ]) - } catch { - TPPErrorLogger.logError(error, summary: "Failed to read/parse license file for streaming URL construction", metadata: [ - "licenseURL": fileUrl.url.absoluteString - ]) - } - - return nil - } - - /// Decrypt protected file - /// - Parameters: - /// - url: encrypted file URL. - /// - resultUrl: URL to save decrypted file at. - /// - completion: decryptor callback with optional `Error`. - func decrypt(url: URL, to resultUrl: URL, completion: @escaping (Error?) -> Void) { - Task { - // Use license URL if available, otherwise use audiobook URL - let urlToOpen: AbsoluteURL - if let licenseUrl = licenseUrl { - if let fileUrl = FileURL(url: licenseUrl) { - urlToOpen = fileUrl - } else if let httpUrl = HTTPURL(url: licenseUrl) { - urlToOpen = httpUrl - } else { - completion(NSError(domain: "LCPAudiobooks", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid license URL"])) - return - } - } else { - urlToOpen = audiobookUrl - } - - let result = await self.assetRetriever.retrieve(url: urlToOpen) - switch result { - case .success(let asset): - let publicationResult = await publicationOpener.open(asset: asset, allowUserInteraction: false, sender: nil) - switch publicationResult { - case .success(let publication): - if let resource = publication.getResource(at: url.path) { - do { - let data = try await resource.read().get() - try data.write(to: resultUrl, options: .atomic) - completion(nil) - } catch { - completion(error) - } - } else { - completion(NSError(domain: "AudiobookResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Resource not found"])) - } - case .failure(let error): - completion(error) - } - case .failure(let error): - completion(error) - } - } - } -} - -private extension Publication { - func getResource(at path: String) -> Resource? { - let resource = get(Link(href: path)) - guard type(of: resource) != FailureResource.self else { - return get(Link(href: "/" + path)) - } - - return resource - } -} #endif From c2d88b3f8011c867e2be01296b0fc7a486c47f45 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 11:15:43 -0400 Subject: [PATCH 07/32] Clean up and improvements --- .../UI/BookDetail/BookDetailViewModel.swift | 43 ++++--- .../UI/TPPBookCellDelegate+Extensions.swift | 112 ++++++++++++++++++ Palace/Book/UI/TPPBookCellDelegate.m | 34 ++---- ios-audiobooktoolkit | 2 +- 4 files changed, 142 insertions(+), 49 deletions(-) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index f041fc5ee..e00fe7527 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -104,7 +104,6 @@ final class BookDetailViewModel: ObservableObject { setupObservers() self.downloadProgress = downloadCenter.downloadProgress(for: book.identifier) #if LCP - // Warm LCP streaming as early as possible to minimize UI blocking when opening self.prefetchLCPStreamingIfPossible() #endif } @@ -202,7 +201,6 @@ final class BookDetailViewModel: ObservableObject { self.bookState = registry.state(for: book.identifier) } #if LCP - // Prefetch LCP streaming manifest in background once license is around self.prefetchLCPStreamingIfPossible() #endif } @@ -301,7 +299,9 @@ final class BookDetailViewModel: ObservableObject { case .read, .listen: didSelectRead(for: book) { - self.removeProcessingButton(button) + if self.book.defaultBookContentType != .audiobook { + self.removeProcessingButton(button) + } } case .cancel: @@ -375,16 +375,23 @@ final class BookDetailViewModel: ObservableObject { func openBook(_ book: TPPBook, completion: (() -> Void)?) { TPPCirculationAnalytics.postEvent("open_book", withBook: book) - processingButtons.removeAll() switch book.defaultBookContentType { case .epub: + processingButtons.removeAll() presentEPUB(book) case .pdf: + processingButtons.removeAll() presentPDF(book) case .audiobook: - openAudiobook(book, completion: completion) + openAudiobook(book) { [weak self] in + DispatchQueue.main.async { + self?.processingButtons.removeAll() + completion?() + } + } default: + processingButtons.removeAll() presentUnsupportedItemError() } } @@ -405,11 +412,12 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { -// if let url = downloadCenter.fileUrl(for: book.identifier), -// FileManager.default.fileExists(atPath: url.path) { -// openAudiobookWithLocalFile(book: book, url: url, completion: completion) -// return -// } + if let url = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: url.path) { + openAudiobookUnified(book: book, licenseUrl: url, completion: completion) + downloadCenter.startDownload(for: book) + return + } #if LCP if LCPAudiobooks.canOpenBook(book) { @@ -418,7 +426,7 @@ final class BookDetailViewModel: ObservableObject { downloadCenter.startDownload(for: book) return } - // Fallback: open via publication URL directly (Readium will retrieve LCPL and stream) + if let publicationURL = book.defaultAcquisition?.hrefURL { openAudiobookUnified(book: book, licenseUrl: publicationURL, completion: completion) downloadCenter.startDownload(for: book) @@ -464,7 +472,6 @@ final class BookDetailViewModel: ObservableObject { if let licenseUrl = getLCPLicenseURL(for: book) { openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) - // Start background download once license is available downloadCenter.startDownload(for: book) return } @@ -514,7 +521,6 @@ final class BookDetailViewModel: ObservableObject { return } - // Use the license directly for streaming (Readium approach) guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { self.presentUnsupportedItemError() completion?() @@ -706,17 +712,13 @@ final class BookDetailViewModel: ObservableObject { Log.error(#file, "❌ Failed to create audiobook manager") return } - - Log.info(#file, "✅ AudiobookManager created successfully") - + audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) - Log.info(#file, "✅ AudiobookPlayer created, presenting view controller") TPPRootTabBarController.shared().pushViewController(audiobookPlayer!, animated: true) - Log.info(#file, "🎵 Syncing audiobook location and starting playback") syncAudiobookLocation(for: book) scheduleTimer() } @@ -773,12 +775,7 @@ final class BookDetailViewModel: ObservableObject { return } - // Create position for start of first track let startPosition = TrackPosition(track: firstTrack, timestamp: 0.0, tracks: manager.audiobook.tableOfContents.tracks) - - // Streaming resources are now handled automatically by LCPPlayer - - Log.info(#file, "Starting audiobook playbook from beginning") audiobookManager?.audiobook.player.play(at: startPosition, completion: nil) } diff --git a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift index 060c2a1e1..3283f349f 100644 --- a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift +++ b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift @@ -55,6 +55,118 @@ extension TPPBookCellDelegate { ) } } + + // MARK: - Main Audiobook Opening Entry Point + + @objc func openAudiobookWithUnifiedStreaming(_ book: TPPBook, completion: (() -> Void)? = nil) { +#if LCP + if LCPAudiobooks.canOpenBook(book) { + if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) { + openAudiobookUnified(book: book, licenseUrl: localURL, completion: completion) + return + } + + if let licenseUrl = getLCPLicenseURL(for: book) { + openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) + return + } + + if let publicationURL = book.defaultAcquisition?.hrefURL { + openAudiobookUnified(book: book, licenseUrl: publicationURL, completion: completion) + return + } + + presentUnsupportedItemError() + completion?() + return + } +#endif + + presentUnsupportedItemError() + completion?() + } + + private func getLCPLicenseURL(for book: TPPBook) -> URL? { +#if LCP + guard let bookFileURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { + return nil + } + + let licenseURL = bookFileURL.deletingPathExtension().appendingPathExtension("lcpl") + + if FileManager.default.fileExists(atPath: licenseURL.path) { + return licenseURL + } +#endif + return nil + } + + private func openAudiobookUnified(book: TPPBook, licenseUrl: URL, completion: (() -> Void)?) { +#if LCP + if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path), + licenseUrl.path == localURL.path { + guard let lcpAudiobooks = LCPAudiobooks(for: localURL) else { + self.presentUnsupportedItemError() + completion?() + return + } + + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + if let _ = error { + self.presentUnsupportedItemError() + completion?() + return + } + guard let dict else { + self.presentUnsupportedItemError() + completion?() + return + } + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(withBook: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } + } + return + } + + guard let lcpAudiobooks = LCPAudiobooks(for: licenseUrl) else { + self.presentUnsupportedItemError() + completion?() + return + } + + if let cachedDict = lcpAudiobooks.cachedContentDictionary() { + var jsonDict = cachedDict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(withBook: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + return + } + + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + if let _ = error { + self.presentUnsupportedItemError() + completion?() + return + } + guard let dict else { + self.presentUnsupportedItemError() + completion?() + return + } + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(withBook: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } + } +#endif + } public func openAudiobook(withBook book: TPPBook, json: [String: Any], drmDecryptor: DRMDecryptor?, completion: (() -> Void)?) { AudioBookVendorsHelper.updateVendorKey(book: json) { [weak self] error in diff --git a/Palace/Book/UI/TPPBookCellDelegate.m b/Palace/Book/UI/TPPBookCellDelegate.m index d2ab8fb4c..6303cbb16 100644 --- a/Palace/Book/UI/TPPBookCellDelegate.m +++ b/Palace/Book/UI/TPPBookCellDelegate.m @@ -206,6 +206,15 @@ - (void)presentPDF:(TPPBook *)book { } - (void)openAudiobook:(TPPBook *)book completion:(void (^ _Nullable)(void))completion { +#if defined(LCP) + if ([LCPAudiobooks canOpenBook:book]) { + // Use new unified LCP streaming approach from Swift extension + [self openAudiobookWithUnifiedStreaming:book completion:completion]; + return; + } +#endif + + // Non-LCP audiobook fallback NSURL *const url = [[MyBooksDownloadCenter shared] fileUrlFor:book.identifier]; if (!url) { [self presentCorruptedItemErrorForBook:book fromURL:url]; @@ -213,31 +222,6 @@ - (void)openAudiobook:(TPPBook *)book completion:(void (^ _Nullable)(void))compl return; } -#if defined(LCP) - if ([LCPAudiobooks canOpenBook:book]) { - LCPAudiobooks *lcpAudiobooks = [[LCPAudiobooks alloc] initFor:url licenseUrl:nil]; - [lcpAudiobooks contentDictionaryWithCompletion:^(NSDictionary * _Nullable dict, NSError * _Nullable error) { - if (error) { - [self presentUnsupportedItemError]; - if (completion) completion(); - return; - } - - if (!dict) { - [self presentCorruptedItemErrorForBook:book fromURL:url]; - if (completion) completion(); - return; - } - - NSMutableDictionary *mutableDict = [dict mutableCopy]; - mutableDict[@"id"] = book.identifier; - - [self openAudiobookWithBook:book json:mutableDict drmDecryptor:lcpAudiobooks completion:completion]; - }]; - return; - } -#endif - NSError *error = nil; NSData *data = [NSData dataWithContentsOfURL:url options:NSDataReadingMappedIfSafe error:&error]; diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index 4f99b62e0..64be1d867 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit 4f99b62e067259496f06de541faf6c63f4323c08 +Subproject commit 64be1d867f665e817d1bd86ac94175ca642ee134 From bb0182635efb4d5629a835d1d21a1c3f602c4d5b Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 11:42:44 -0400 Subject: [PATCH 08/32] clean up --- .../UI/BookDetail/BookDetailViewModel.swift | 45 +++++++++++++++++-- .../AudiobookBookmarkBusinessLogic.swift | 2 +- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index e00fe7527..f154a1a50 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -14,6 +14,9 @@ struct BookLane { } final class BookDetailViewModel: ObservableObject { + // MARK: - Constants + private let kTimerInterval: TimeInterval = 3.0 // Save position every 3 seconds + @Published var book: TPPBook /// The registry state, e.g. `unregistered`, `downloading`, `downloadSuccessful`, etc. @@ -715,6 +718,15 @@ final class BookDetailViewModel: ObservableObject { audiobookBookmarkBusinessLogic = AudiobookBookmarkBusinessLogic(book: book) audiobookManager.bookmarkDelegate = audiobookBookmarkBusinessLogic + + // Set up end-of-book completion handler + audiobookManager.playbackCompletionHandler = { [weak self] in + guard let self = self else { return } + DispatchQueue.main.async { + self.presentEndOfBookAlert() + } + } + audiobookPlayer = AudiobookPlayer(audiobookManager: audiobookManager, coverImagePublisher: book.$coverImage.eraseToAnyPublisher()) TPPRootTabBarController.shared().pushViewController(audiobookPlayer!, animated: true) @@ -873,20 +885,20 @@ extension BookDetailViewModel { } @objc public func pollAudiobookReadingLocation() { - - guard let _ = self.audiobookViewController else { + guard let _ = self.audiobookViewController, + let audiobookManager = self.audiobookManager else { timer?.cancel() timer = nil self.audiobookManager = nil return } - guard let currentTrackPosition = self.audiobookManager?.audiobook.player.currentTrackPosition else { + guard let currentTrackPosition = audiobookManager.audiobook.player.currentTrackPosition else { return } let playheadOffset = currentTrackPosition.timestamp - if self.previousPlayheadOffset != playheadOffset && playheadOffset > 0 { + if abs(self.previousPlayheadOffset - playheadOffset) > 1.0 && playheadOffset > 0 { self.previousPlayheadOffset = playheadOffset DispatchQueue.global(qos: .background).async { [weak self] in @@ -955,6 +967,31 @@ extension BookDetailViewModel { TPPAlertUtils.presentFromViewControllerOrNil(alertController: alertController, viewController: nil, animated: true, completion: nil) } } + + private func presentEndOfBookAlert() { + let paths = TPPOPDSAcquisitionPath.supportedAcquisitionPaths( + forAllowedTypes: TPPOPDSAcquisitionPath.supportedTypes(), + allowedRelations: [.borrow, .generic], + acquisitions: book.acquisitions + ) + + if paths.count > 0 { + let alert = TPPReturnPromptHelper.audiobookPrompt { [weak self] returnWasChosen in + guard let self else { return } + + if returnWasChosen { + if let navController = TPPRootTabBarController.shared()?.topMostViewController.navigationController { + navController.popViewController(animated: true) + } + self.didSelectReturn(for: self.book, completion: nil) + } + TPPAppStoreReviewPrompt.presentIfAvailable() + } + TPPRootTabBarController.shared().present(alert, animated: true, completion: nil) + } else { + TPPAppStoreReviewPrompt.presentIfAvailable() + } + } } // MARK: – BookButtonProvider diff --git a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift index 7221767ca..1c6c53816 100644 --- a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift +++ b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift @@ -16,7 +16,7 @@ import PalaceAudiobookToolkit private var isSyncing: Bool = false private let queue = DispatchQueue(label: "com.palace.audiobookBookmarkBusinessLogic", attributes: .concurrent) private var debounceTimer: Timer? - private let debounceInterval: TimeInterval = 1.0 + private let debounceInterval: TimeInterval = 0.5 // Faster saves to prevent position loss private var completionHandlersQueue: [([AudioBookmark]) -> Void] = [] private var debounceWorkItem: DispatchWorkItem? From 911a3d9c2b83012635c3cdc4c0cbc41ffd2914c6 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 11:45:04 -0400 Subject: [PATCH 09/32] Update ios-audiobooktoolkit --- ios-audiobooktoolkit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios-audiobooktoolkit b/ios-audiobooktoolkit index 64be1d867..016d78587 160000 --- a/ios-audiobooktoolkit +++ b/ios-audiobooktoolkit @@ -1 +1 @@ -Subproject commit 64be1d867f665e817d1bd86ac94175ca642ee134 +Subproject commit 016d78587d0c283358e127af0490f1bbe77351f2 From 9caaaa57218765081e294ed3c060054c979a7276 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 12:43:48 -0400 Subject: [PATCH 10/32] Fix download regression with non LCP --- Palace/Book/Models/TPPBookRegistry.swift | 5 ++ .../UI/BookDetail/BookDetailViewModel.swift | 56 +++++++++++++++++-- .../UI/TPPBookCellDelegate+Extensions.swift | 31 +++++++++- Palace/MyBooks/MyBooksDownloadCenter.swift | 9 ++- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/Palace/Book/Models/TPPBookRegistry.swift b/Palace/Book/Models/TPPBookRegistry.swift index e28c956b5..55c52bc17 100644 --- a/Palace/Book/Models/TPPBookRegistry.swift +++ b/Palace/Book/Models/TPPBookRegistry.swift @@ -391,6 +391,11 @@ class TPPBookRegistry: NSObject, TPPBookRegistrySyncing { } func removeBook(forIdentifier bookIdentifier: String) { + guard !bookIdentifier.isEmpty else { + Log.error(#file, "removeBook called with empty bookIdentifier") + return + } + syncQueue.async { let removedBook = self.registry[bookIdentifier]?.book self.registry.removeValue(forKey: bookIdentifier) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index f154a1a50..35032769e 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -415,24 +415,41 @@ final class BookDetailViewModel: ObservableObject { // MARK: - Audiobook Opening func openAudiobook(_ book: TPPBook, completion: (() -> Void)? = nil) { - if let url = downloadCenter.fileUrl(for: book.identifier), - FileManager.default.fileExists(atPath: url.path) { - openAudiobookUnified(book: book, licenseUrl: url, completion: completion) +#if LCP + if LCPAudiobooks.canOpenBook(book) { + openAudiobookWithUnifiedStreaming(book: book, completion: completion) + return + } +#endif + + guard let url = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: url.path) else { downloadCenter.startDownload(for: book) + completion?() return } + openAudiobookWithLocalFile(book: book, url: url, completion: completion) + } + + private func openAudiobookWithUnifiedStreaming(book: TPPBook, completion: (() -> Void)? = nil) { #if LCP if LCPAudiobooks.canOpenBook(book) { + if let localURL = downloadCenter.fileUrl(for: book.identifier), + FileManager.default.fileExists(atPath: localURL.path) { + openLocalLCPAudiobook(book: book, localURL: localURL, completion: completion) + downloadCenter.startDownload(for: book) + return + } + if let licenseUrl = getLCPLicenseURL(for: book) { openAudiobookUnified(book: book, licenseUrl: licenseUrl, completion: completion) downloadCenter.startDownload(for: book) return } - + if let publicationURL = book.defaultAcquisition?.hrefURL { openAudiobookUnified(book: book, licenseUrl: publicationURL, completion: completion) - downloadCenter.startDownload(for: book) return } @@ -451,6 +468,35 @@ final class BookDetailViewModel: ObservableObject { completion?() } + private func openLocalLCPAudiobook(book: TPPBook, localURL: URL, completion: (() -> Void)?) { +#if LCP + guard let lcpAudiobooks = LCPAudiobooks(for: localURL) else { + self.presentCorruptedItemError() + completion?() + return + } + + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + if let _ = error { + self.presentCorruptedItemError() + completion?() + return + } + guard let dict else { + self.presentCorruptedItemError() + completion?() + return + } + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(with: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } + } +#endif + } + private func getLCPLicenseURL(for book: TPPBook) -> URL? { #if LCP guard let bookFileURL = downloadCenter.fileUrl(for: book.identifier) else { diff --git a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift index 3283f349f..4778616cc 100644 --- a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift +++ b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift @@ -63,7 +63,7 @@ extension TPPBookCellDelegate { if LCPAudiobooks.canOpenBook(book) { if let localURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier), FileManager.default.fileExists(atPath: localURL.path) { - openAudiobookUnified(book: book, licenseUrl: localURL, completion: completion) + openLocalLCPAudiobook(book: book, localURL: localURL, completion: completion) return } @@ -87,6 +87,35 @@ extension TPPBookCellDelegate { completion?() } + private func openLocalLCPAudiobook(book: TPPBook, localURL: URL, completion: (() -> Void)?) { +#if LCP + guard let lcpAudiobooks = LCPAudiobooks(for: localURL) else { + self.presentUnsupportedItemError() + completion?() + return + } + + lcpAudiobooks.contentDictionary { [weak self] dict, error in + DispatchQueue.main.async { + guard let self = self else { return } + if let _ = error { + self.presentUnsupportedItemError() + completion?() + return + } + guard let dict else { + self.presentUnsupportedItemError() + completion?() + return + } + var jsonDict = dict as? [String: Any] ?? [:] + jsonDict["id"] = book.identifier + self.openAudiobook(withBook: book, json: jsonDict, drmDecryptor: lcpAudiobooks, completion: completion) + } + } +#endif + } + private func getLCPLicenseURL(for book: TPPBook) -> URL? { #if LCP guard let bookFileURL = MyBooksDownloadCenter.shared.fileUrl(for: book.identifier) else { diff --git a/Palace/MyBooks/MyBooksDownloadCenter.swift b/Palace/MyBooks/MyBooksDownloadCenter.swift index d7c13feab..0031932fd 100644 --- a/Palace/MyBooks/MyBooksDownloadCenter.swift +++ b/Palace/MyBooks/MyBooksDownloadCenter.swift @@ -1112,10 +1112,15 @@ extension MyBooksDownloadCenter { do { let _ = try FileManager.default.replaceItemAt(destURL, withItemAt: sourceLocation, options: .usingNewMetadataOnly) // Note: For LCP audiobooks, state is set in fulfillLCPLicense after license is ready - // For other content types, set state here after content is successfully stored - if book.defaultBookContentType != .audiobook { + // For non-LCP audiobooks and other content types, set state here after content is successfully stored +#if LCP + let isLCPAudiobook = book.defaultBookContentType == .audiobook && LCPAudiobooks.canOpenBook(book) + if !isLCPAudiobook { bookRegistry.setState(.downloadSuccessful, for: book.identifier) } +#else + bookRegistry.setState(.downloadSuccessful, for: book.identifier) +#endif return true } catch { logBookDownloadFailure(book, From dcd9acf5ef301f840f28e6926896f2a6c5fd7705 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 13:26:14 -0400 Subject: [PATCH 11/32] clean up and PR prep --- Palace.xcodeproj/project.pbxproj | 18 +++++++++--------- .../xcschemes/Palace-noDRM.xcscheme | 2 +- .../xcshareddata/xcschemes/Palace.xcscheme | 2 +- RDServices.xcodeproj/project.pbxproj | 9 +++++---- ios-audiobooktoolkit | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 9ae3ced69..2317ea21f 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4746,7 +4746,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4768,7 +4768,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PRODUCT_MODULE_NAME = Palace; PRODUCT_NAME = "Palace-noDRM"; @@ -4805,7 +4805,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4827,7 +4827,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PRODUCT_MODULE_NAME = Palace; PRODUCT_NAME = "Palace-noDRM"; @@ -4989,7 +4989,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5015,7 +5015,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; RUN_CLANG_STATIC_ANALYZER = YES; @@ -5050,7 +5050,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 360; + CURRENT_PROJECT_VERSION = 361; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -5077,7 +5077,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.8; + MARKETING_VERSION = 1.2.9; PRODUCT_BUNDLE_IDENTIFIER = org.thepalaceproject.palace; PROVISIONING_PROFILE_SPECIFIER = ""; "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "App Store"; @@ -5295,7 +5295,7 @@ repositoryURL = "https://github.com/readium/swift-toolkit.git"; requirement = { kind = exactVersion; - version = 3.1.0; + version = 3.3.0; }; }; E77D02232931357400544180 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = { diff --git a/Palace.xcodeproj/xcshareddata/xcschemes/Palace-noDRM.xcscheme b/Palace.xcodeproj/xcshareddata/xcschemes/Palace-noDRM.xcscheme index 8e452ff04..47c751371 100644 --- a/Palace.xcodeproj/xcshareddata/xcschemes/Palace-noDRM.xcscheme +++ b/Palace.xcodeproj/xcshareddata/xcschemes/Palace-noDRM.xcscheme @@ -1,6 +1,6 @@ Date: Fri, 15 Aug 2025 13:31:51 -0400 Subject: [PATCH 12/32] Update LCPAudiobooks.swift --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index babcffe3d..9b8a8bc49 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -233,9 +233,7 @@ extension LCPAudiobooks { return nil } - /// Start a cancellable background prefetch of the publication/manifest public func startPrefetch() { - // Kick off a background load; completion is ignored self.contentDictionary { _, _ in } } @@ -243,7 +241,6 @@ extension LCPAudiobooks { completion(nil) // No Op, Readium handles decryption // } - /// Cancel any in-flight prefetch task public func cancelPrefetch() { publicationCacheLock.lock() currentPrefetchTask?.cancel() @@ -252,7 +249,5 @@ extension LCPAudiobooks { } } - - #endif From 9a7c004865648ed3a6fdbb9a294a6b925025f988 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 13:44:21 -0400 Subject: [PATCH 13/32] PR code cleanup --- .../UI/BookDetail/BookDetailViewModel.swift | 29 --- .../UI/TPPBookCellDelegate+Extensions.swift | 1 - Palace/Book/UI/TPPBookCellDelegate.m | 2 - .../AudiobookBookmarkBusinessLogic.swift | 2 +- tmp.swift | 204 ------------------ 5 files changed, 1 insertion(+), 237 deletions(-) delete mode 100644 tmp.swift diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 35032769e..12ba121fb 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -619,35 +619,6 @@ final class BookDetailViewModel: ObservableObject { } #endif - /// Gets the publication manifest for streaming using Readium LCP service directly - - - private func fetchStreamingManifest(from url: URL, completion: @escaping (NSDictionary?, NSError?) -> Void) { - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/audiobook+json, application/json;q=0.9, */*;q=0.1", forHTTPHeaderField: "Accept") - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { completion(nil, error as NSError); return } - guard let data = data else { completion(nil, NSError(domain: "StreamingManifest", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data"])) ; return } - do { - if let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary { - completion(jsonObject, nil) - } else { - completion(nil, NSError(domain: "StreamingManifest", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"])) - } - } catch { - completion(nil, error as NSError) - } - } - task.resume() - } - /// Extracts the publication URL from the license for use with LCPAudiobooks - /// Since we already have the publication URL from parsing the license, we can store and reuse it - private var cachedPublicationUrl: URL? - - private func getPublicationUrlFromManifest(_ manifest: [String: Any]) -> URL? { - return cachedPublicationUrl - } private func openAudiobookWithLocalFile(book: TPPBook, url: URL, completion: (() -> Void)?) { #if LCP diff --git a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift index 4778616cc..64df826dd 100644 --- a/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift +++ b/Palace/Book/UI/TPPBookCellDelegate+Extensions.swift @@ -261,7 +261,6 @@ extension TPPBookCellDelegate { self.startLoading(audiobookPlayer) - // Extra safety: dismiss loading overlay when playback actually starts (or fails) let cancellable = audiobookManager.audiobook.player.playbackStatePublisher .receive(on: DispatchQueue.main) .sink { [weak self] state in diff --git a/Palace/Book/UI/TPPBookCellDelegate.m b/Palace/Book/UI/TPPBookCellDelegate.m index 6303cbb16..e12666f76 100644 --- a/Palace/Book/UI/TPPBookCellDelegate.m +++ b/Palace/Book/UI/TPPBookCellDelegate.m @@ -208,13 +208,11 @@ - (void)presentPDF:(TPPBook *)book { - (void)openAudiobook:(TPPBook *)book completion:(void (^ _Nullable)(void))completion { #if defined(LCP) if ([LCPAudiobooks canOpenBook:book]) { - // Use new unified LCP streaming approach from Swift extension [self openAudiobookWithUnifiedStreaming:book completion:completion]; return; } #endif - // Non-LCP audiobook fallback NSURL *const url = [[MyBooksDownloadCenter shared] fileUrlFor:book.identifier]; if (!url) { [self presentCorruptedItemErrorForBook:book fromURL:url]; diff --git a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift index 1c6c53816..7221767ca 100644 --- a/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift +++ b/Palace/Reader2/Bookmarks/AudiobookBookmarkBusinessLogic.swift @@ -16,7 +16,7 @@ import PalaceAudiobookToolkit private var isSyncing: Bool = false private let queue = DispatchQueue(label: "com.palace.audiobookBookmarkBusinessLogic", attributes: .concurrent) private var debounceTimer: Timer? - private let debounceInterval: TimeInterval = 0.5 // Faster saves to prevent position loss + private let debounceInterval: TimeInterval = 1.0 private var completionHandlersQueue: [([AudioBookmark]) -> Void] = [] private var debounceWorkItem: DispatchWorkItem? diff --git a/tmp.swift b/tmp.swift deleted file mode 100644 index b6c6e7eba..000000000 --- a/tmp.swift +++ /dev/null @@ -1,204 +0,0 @@ -import Foundation -import AVFoundation -import ReadiumShared -import UniformTypeIdentifiers - -final class LCPResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate { - - var publication: Publication? - private let httpRangeRetriever = HTTPRangeRetriever() - - init(publication: Publication? = nil) { - self.publication = publication - super.init() - ATLog(.debug, "🎵 [LCPResourceLoader] ✅ Delegate initialized with publication: \(publication != nil)") - } - - func resourceLoader( - _ resourceLoader: AVAssetResourceLoader, - shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest - ) -> Bool { - - guard - let pub = publication, - let url = loadingRequest.request.url, - url.scheme == "fake", - url.host == "lcp-streaming" - else { - loadingRequest.finishLoading(with: NSError( - domain: "LCPResourceLoader", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid URL or missing publication"] - )) - return false - } - - let comps = url.pathComponents // ["/", "track", "{index}"] - let index = (comps.count >= 3 && comps[1] == "track") ? Int(comps[2]) ?? 0 : 0 - guard (0.. = start.. Resource? { - // Try exact - if let res = publication.get(Link(href: href)), type(of: res) != FailureResource.self { - return res - } - // Try leading slash - if let res = publication.get(Link(href: "/" + href)), type(of: res) != FailureResource.self { - return res - } - // Try resolving against baseURL if available - if let base = publication.linkWithRel(.self)?.href, let absolute = URL(string: href, relativeTo: URL(string: base)!)?.absoluteString { - if let res = publication.get(Link(href: absolute)), type(of: res) != FailureResource.self { - return res - } - } - return nil - } - static func utiIdentifier(forHref href: String, fallbackMime: String?) -> String { - let ext = URL(fileURLWithPath: href).pathExtension.lowercased() - - // Try modern UTType first - if !ext.isEmpty, let type = UTType(filenameExtension: ext) { - return type.identifier - } - - // Minimal manual mapping for common audio types - switch ext { - case "mp3": - return "public.mp3" // MP3 - case "m4a": - return "com.apple.m4a-audio" // M4A - case "mp4": - return "public.mpeg-4" // MP4 container (audio) - default: - break - } - - // Fallback to MIME-derived guesses - if let mime = fallbackMime?.lowercased() { - if mime.contains("mpeg") || mime.contains("mp3") { return "public.mp3" } - if mime.contains("m4a") || mime.contains("mp4") { return "com.apple.m4a-audio" } - } - - // Last resort - return "public.audio" - } -} From 346db7cd183ffa5134946fd9f5cebf2c597a9c92 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 14:01:27 -0400 Subject: [PATCH 14/32] Update LCPAudiobooks.swift --- Palace/Audiobooks/LCP/LCPAudiobooks.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Palace/Audiobooks/LCP/LCPAudiobooks.swift b/Palace/Audiobooks/LCP/LCPAudiobooks.swift index 4c7d1f6fd..b5817dc86 100644 --- a/Palace/Audiobooks/LCP/LCPAudiobooks.swift +++ b/Palace/Audiobooks/LCP/LCPAudiobooks.swift @@ -21,7 +21,6 @@ import PalaceAudiobookToolkit private let audiobookUrl: AbsoluteURL private let licenseUrl: URL? private let assetRetriever: AssetRetriever - private let httpRangeRetriever: HTTPRangeRetriever private let publicationOpener: PublicationOpener private let httpClient: DefaultHTTPClient From 4b3213987aab2ebf4737e58d8edf540d0bca9862 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Fri, 15 Aug 2025 15:08:25 -0400 Subject: [PATCH 15/32] fix build --- PalaceR2.xcworkspace/contents.xcworkspacedata | 12 - .../xcshareddata/swiftpm/Package.resolved | 258 ------------------ 2 files changed, 270 deletions(-) delete mode 100644 PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/PalaceR2.xcworkspace/contents.xcworkspacedata b/PalaceR2.xcworkspace/contents.xcworkspacedata index 5b081d774..b081f4f2d 100644 --- a/PalaceR2.xcworkspace/contents.xcworkspacedata +++ b/PalaceR2.xcworkspace/contents.xcworkspacedata @@ -4,16 +4,4 @@ - - - - - - - - diff --git a/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 4de80923b..000000000 --- a/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,258 +0,0 @@ -{ - "originHash" : "03d932ab8364af0525c21dc16ed5703bd10b33b6c79bd7dcbf9f4fc639ee5a8d", - "pins" : [ - { - "identity" : "abseil-cpp-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/abseil-cpp-binary.git", - "state" : { - "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", - "version" : "1.2024011602.0" - } - }, - { - "identity" : "app-check", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/app-check.git", - "state" : { - "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", - "version" : "10.19.2" - } - }, - { - "identity" : "cryptoswift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", - "state" : { - "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", - "version" : "1.9.0" - } - }, - { - "identity" : "differencekit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ra1028/DifferenceKit.git", - "state" : { - "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", - "version" : "1.3.0" - } - }, - { - "identity" : "firebase-ios-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/firebase-ios-sdk", - "state" : { - "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", - "version" : "10.29.0" - } - }, - { - "identity" : "fuzi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/readium/Fuzi.git", - "state" : { - "revision" : "347aab158ff8894966ff80469b384bb5337928cd", - "version" : "4.0.0" - } - }, - { - "identity" : "gcdwebserver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/readium/GCDWebServer.git", - "state" : { - "revision" : "584db89a4c3c3be27206cce6afde037b2b6e38d8", - "version" : "4.0.1" - } - }, - { - "identity" : "googleappmeasurement", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleAppMeasurement.git", - "state" : { - "revision" : "fe727587518729046fc1465625b9afd80b5ab361", - "version" : "10.28.0" - } - }, - { - "identity" : "googledatatransport", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleDataTransport.git", - "state" : { - "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", - "version" : "9.4.0" - } - }, - { - "identity" : "googleutilities", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/GoogleUtilities.git", - "state" : { - "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", - "version" : "7.13.3" - } - }, - { - "identity" : "grpc-binary", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/grpc-binary.git", - "state" : { - "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", - "version" : "1.62.2" - } - }, - { - "identity" : "gtm-session-fetcher", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/gtm-session-fetcher.git", - "state" : { - "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", - "version" : "3.5.0" - } - }, - { - "identity" : "interop-ios-for-google-sdks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/interop-ios-for-google-sdks.git", - "state" : { - "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", - "version" : "100.0.0" - } - }, - { - "identity" : "leveldb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/leveldb.git", - "state" : { - "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", - "version" : "1.22.5" - } - }, - { - "identity" : "nanopb", - "kind" : "remoteSourceControl", - "location" : "https://github.com/firebase/nanopb.git", - "state" : { - "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", - "version" : "2.30910.0" - } - }, - { - "identity" : "promises", - "kind" : "remoteSourceControl", - "location" : "https://github.com/google/promises.git", - "state" : { - "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", - "version" : "2.4.0" - } - }, - { - "identity" : "purelayout", - "kind" : "remoteSourceControl", - "location" : "https://github.com/PureLayout/PureLayout", - "state" : { - "revision" : "5561683c96dc49b023c1299bfe4f6fbeed5f8199", - "version" : "3.1.9" - } - }, - { - "identity" : "sqlite.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stephencelis/SQLite.swift.git", - "state" : { - "revision" : "392dd6058624d9f6c5b4c769d165ddd8c7293394", - "version" : "0.15.4" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", - "version" : "1.6.3" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "102a647b573f60f73afdce5613a51d71349fe507", - "version" : "1.30.0" - } - }, - { - "identity" : "swift-toolchain-sqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", - "state" : { - "revision" : "b626d3002773b1a1304166643e7f118f724b2132", - "version" : "1.0.4" - } - }, - { - "identity" : "swift-toolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/readium/swift-toolkit.git", - "state" : { - "revision" : "8219531e3d83a8d7b77e1753e4e06e9101eb2aee", - "version" : "3.1.0" - } - }, - { - "identity" : "swiftminizip", - "kind" : "remoteSourceControl", - "location" : "https://github.com/iharkatkavets/SwiftMiniZip.git", - "state" : { - "revision" : "05a76136bc767b0e16b986a90493bbad14e95be8", - "version" : "1.0.5" - } - }, - { - "identity" : "swiftsoup", - "kind" : "remoteSourceControl", - "location" : "https://github.com/scinfu/SwiftSoup.git", - "state" : { - "revision" : "d1d6add8f277bd76992a0f7e72961a896609b707", - "version" : "2.9.5" - } - }, - { - "identity" : "transifex-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/transifex/transifex-swift/", - "state" : { - "revision" : "e983af3184c456f6061620667a455aa95e99df32", - "version" : "1.0.2" - } - }, - { - "identity" : "ulid.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/yaslab/ULID.swift.git", - "state" : { - "revision" : "e21933de8ac3e8ba9e83aa71956cd27206eec6b9", - "version" : "1.3.1" - } - }, - { - "identity" : "zip", - "kind" : "remoteSourceControl", - "location" : "https://github.com/marmelroy/Zip.git", - "state" : { - "revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76", - "version" : "2.1.2" - } - }, - { - "identity" : "zipfoundation", - "kind" : "remoteSourceControl", - "location" : "https://github.com/readium/ZIPFoundation.git", - "state" : { - "revision" : "4484b17f0d3c872aa200f0ecc9ff004e3991c43e", - "version" : "2.0.0" - } - } - ], - "version" : 3 -} From b53b5ebe660b46daa6ed1bd705d3c69f188af45e Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 13:21:02 -0400 Subject: [PATCH 16/32] Clean up minor errors and resolve checkout crash --- .../xcshareddata/xcschemes/Palace.xcscheme | 10 + Palace/Book/Models/TPPBook.swift | 2 +- Palace/MyBooks/MyBooksDownloadCenter.swift | 12 +- .../xcshareddata/swiftpm/Package.resolved | 240 ++++++++++++++++++ 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme index 3e345d703..43ba1231c 100644 --- a/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme +++ b/Palace.xcodeproj/xcshareddata/xcschemes/Palace.xcscheme @@ -140,6 +140,16 @@ value = "1" isEnabled = "YES"> + + + + MyBooksDownloadInfo? { - bookIdentifierToDownloadInfo[bookIdentifier] + guard let downloadInfo = bookIdentifierToDownloadInfo[bookIdentifier] else { + return nil + } + + if downloadInfo is MyBooksDownloadInfo { + return downloadInfo + } else { + Log.error(#file, "Corrupted download info detected for book \(bookIdentifier), removing entry") + bookIdentifierToDownloadInfo.removeValue(forKey: bookIdentifier) + return nil + } } func broadcastUpdate() { diff --git a/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved b/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..d516b4b88 --- /dev/null +++ b/PalaceR2.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,240 @@ +{ + "originHash" : "eb359fe7a3c949174cd90f187987f826d45ece13623d166fd4d2d3a6ae14f61a", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "3b62f154d00019ae29a71e9738800bb6f18b236d", + "version" : "10.19.2" + } + }, + { + "identity" : "cryptoswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/krzyzanowskim/CryptoSwift.git", + "state" : { + "revision" : "e45a26384239e028ec87fbcc788f513b67e10d8f", + "version" : "1.9.0" + } + }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit.git", + "state" : { + "revision" : "073b9671ce2b9b5b96398611427a1f929927e428", + "version" : "1.3.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "eca84fd638116dd6adb633b5a3f31cc7befcbb7d", + "version" : "10.29.0" + } + }, + { + "identity" : "fuzi", + "kind" : "remoteSourceControl", + "location" : "https://github.com/readium/Fuzi.git", + "state" : { + "revision" : "347aab158ff8894966ff80469b384bb5337928cd", + "version" : "4.0.0" + } + }, + { + "identity" : "gcdwebserver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/readium/GCDWebServer.git", + "state" : { + "revision" : "584db89a4c3c3be27206cce6afde037b2b6e38d8", + "version" : "4.0.1" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "fe727587518729046fc1465625b9afd80b5ab361", + "version" : "10.28.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "a637d318ae7ae246b02d7305121275bc75ed5565", + "version" : "9.4.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "57a1d307f42df690fdef2637f3e5b776da02aad6", + "version" : "7.13.3" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "e9fad491d0673bdda7063a0341fb6b47a30c5359", + "version" : "1.62.2" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "purelayout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/PureLayout/PureLayout", + "state" : { + "revision" : "5561683c96dc49b023c1299bfe4f6fbeed5f8199", + "version" : "3.1.9" + } + }, + { + "identity" : "sqlite.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/SQLite.swift.git", + "state" : { + "revision" : "392dd6058624d9f6c5b4c769d165ddd8c7293394", + "version" : "0.15.4" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "102a647b573f60f73afdce5613a51d71349fe507", + "version" : "1.30.0" + } + }, + { + "identity" : "swift-toolchain-sqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", + "state" : { + "revision" : "b626d3002773b1a1304166643e7f118f724b2132", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-toolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/readium/swift-toolkit.git", + "state" : { + "revision" : "4ae36cf2f6e888f8d2ece7a8bd6f8af832aad350", + "version" : "3.3.0" + } + }, + { + "identity" : "swiftsoup", + "kind" : "remoteSourceControl", + "location" : "https://github.com/scinfu/SwiftSoup.git", + "state" : { + "revision" : "3a439f9eccc391b264d54516ce640251552eb0c4", + "version" : "2.10.3" + } + }, + { + "identity" : "transifex-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/transifex/transifex-swift/", + "state" : { + "revision" : "e983af3184c456f6061620667a455aa95e99df32", + "version" : "1.0.2" + } + }, + { + "identity" : "ulid.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/yaslab/ULID.swift.git", + "state" : { + "revision" : "e21933de8ac3e8ba9e83aa71956cd27206eec6b9", + "version" : "1.3.1" + } + }, + { + "identity" : "zip", + "kind" : "remoteSourceControl", + "location" : "https://github.com/marmelroy/Zip.git", + "state" : { + "revision" : "67fa55813b9e7b3b9acee9c0ae501def28746d76", + "version" : "2.1.2" + } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/readium/ZIPFoundation.git", + "state" : { + "revision" : "175c389832d90cb0e992b2cb9d5d7878eccfe725", + "version" : "3.0.0" + } + } + ], + "version" : 3 +} From 03a2652b8da8f95a060c4d5c45ffc4e3644ad102 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 13:36:47 -0400 Subject: [PATCH 17/32] update build scripts to build on Xcode16 --- .github/workflows/non-drm-build.yml | 11 ++++++++--- .github/workflows/release-on-merge.yml | 11 ++++++++--- .github/workflows/release.yml | 11 ++++++++--- .github/workflows/unit-testing.yml | 11 ++++++++--- .github/workflows/upload.yml | 22 ++++++++++++++++------ 5 files changed, 48 insertions(+), 18 deletions(-) diff --git a/.github/workflows/non-drm-build.yml b/.github/workflows/non-drm-build.yml index 99dc9343a..3a3c08c3a 100644 --- a/.github/workflows/non-drm-build.yml +++ b/.github/workflows/non-drm-build.yml @@ -2,10 +2,15 @@ name: NonDRM Build on: workflow_dispatch jobs: build: - runs-on: macOS-latest + runs-on: macos-15 steps: - - name: Use the latest Xcode - run: sudo xcode-select -switch /Applications/Xcode.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo uses: actions/checkout@v3 - name: Set up repo for nonDRM build diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index d376f6663..8d80900de 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -6,10 +6,15 @@ on: types: [closed] jobs: create-release: - runs-on: macOS-latest + runs-on: macos-15 steps: - - name: Use the latest Xcode - run: sudo xcode-select -switch /Applications/Xcode.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo and submodules uses: actions/checkout@v3 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9357457b..7770869a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,10 +2,15 @@ name: Palace Manual Release on: workflow_dispatch jobs: create-release: - runs-on: macOS-latest + runs-on: macos-15 steps: - - name: Use the latest Xcode - run: sudo xcode-select -switch /Applications/Xcode.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo and submodules uses: actions/checkout@v3 with: diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml index 597e464a1..0a39e77bd 100644 --- a/.github/workflows/unit-testing.yml +++ b/.github/workflows/unit-testing.yml @@ -2,10 +2,15 @@ name: Unit Tests on: [ pull_request, workflow_dispatch ] jobs: build-and-test: - runs-on: macOS-latest + runs-on: macos-15 steps: - - name: Use the latest Xcode - run: sudo xcode-select -switch /Applications/Xcode.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo and submodules uses: actions/checkout@v3 with: diff --git a/.github/workflows/upload.yml b/.github/workflows/upload.yml index fcee15609..3205172fe 100644 --- a/.github/workflows/upload.yml +++ b/.github/workflows/upload.yml @@ -2,10 +2,15 @@ name: Palace Manual Build on: workflow_dispatch jobs: check-version: - runs-on: macOS-latest + runs-on: macos-15 steps: - - name: Use the latest Xcode - run: sudo xcode-select -switch /Applications/Xcode.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo and submodules uses: actions/checkout@v3 with: @@ -19,12 +24,17 @@ jobs: outputs: should_upload: ${{ steps.checkVersion.outputs.version_changed }} upload-build: - runs-on: macOS-latest + runs-on: macos-15 needs: check-version if: needs.check-version.outputs.should_upload == '1' steps: - - name: Force Xcode 16 - run: sudo xcode-select -switch /Applications/Xcode_16.app + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Verify Xcode Version + run: xcodebuild -version - name: Checkout main repo and submodules uses: actions/checkout@v3 with: From 5028af69a55e71da8e35e0ac324840a43345a908 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 14:19:02 -0400 Subject: [PATCH 18/32] Restore build --- fastlane/Fastfile | 30 ++++++++++-------------------- fastlane/README.md | 27 +++++++++++---------------- scripts/xcode-build-nodrm.sh | 10 +++++++++- 3 files changed, 30 insertions(+), 37 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index be0961f9a..e43c01294 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -9,7 +9,7 @@ platform :ios do project: "Palace.xcodeproj", devices: ["iPhone SE (3rd generation)"], scheme: "Palace", - destination: "platform=iOS Simulator,name=iPhone SE (3rd generation),OS=latest,arch=x86_64", + destination: "platform=iOS Simulator,name=iPhone SE (3rd generation),OS=16.1", xcargs: "ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO" ) end @@ -18,19 +18,19 @@ platform :ios do ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "300" ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "4" - build_app( + xcodebuild( project: "Palace.xcodeproj", scheme: "Palace-noDRM", - skip_package_ipa: true, - skip_archive: true, - skip_codesigning: true, - silent: true + destination: "platform=iOS Simulator,name=iPhone 13,OS=16.1", + configuration: "Debug", + build: true, + xcargs: "ONLY_ACTIVE_ARCH=NO CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" ) end lane :beta do |options| - build_app( + gym( project: "Palace.xcodeproj", scheme: "Palace", include_symbols: true, @@ -38,12 +38,7 @@ platform :ios do silent: true, output_name: options[:output_name], output_directory: options[:export_path], - export_options: { - method: "ad-hoc", - provisioningProfiles: { - "org.thepalaceproject.palace" => "Ad Hoc" - } - } + export_method: "ad-hoc" ) end @@ -53,17 +48,12 @@ platform :ios do sh("rm -rf ~/Library/Developer/Xcode/DerivedData/*") - build_app( + gym( project: "Palace.xcodeproj", scheme: "Palace", include_symbols: true, include_bitcode: false, - export_options: { - method: "app-store", - provisioningProfiles: { - "org.thepalaceproject.palace" => "App Store" - } - } + export_method: "app-store" ) pilot( diff --git a/fastlane/README.md b/fastlane/README.md index 0f22695bf..130981c70 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -1,19 +1,9 @@ fastlane documentation ================ # Installation - -Make sure you have the latest version of the Xcode command line tools installed: - -``` -xcode-select --install -``` - -Install _fastlane_ using ``` -[sudo] gem install fastlane -NV +sudo gem install fastlane ``` -or alternatively using `brew install fastlane` - # Available Actions ## iOS ### ios test @@ -21,19 +11,24 @@ or alternatively using `brew install fastlane` fastlane ios test ``` +### ios nodrm +``` +fastlane ios nodrm +``` + ### ios beta ``` fastlane ios beta ``` -### ios testflight +### ios appstore ``` -fastlane ios testflight +fastlane ios appstore ``` ---- -This README.md is auto-generated and will be re-generated every time [fastlane](https://fastlane.tools) is run. -More information about fastlane can be found on [fastlane.tools](https://fastlane.tools). -The documentation of fastlane can be found on [docs.fastlane.tools](https://docs.fastlane.tools). +This README.md is auto-generated and will be re-generated every time to run [fastlane](https://fastlane.tools). +More information about fastlane can be found on [https://fastlane.tools](https://fastlane.tools). +The documentation of fastlane can be found on [GitHub](https://github.com/fastlane/fastlane/tree/master/fastlane). \ No newline at end of file diff --git a/scripts/xcode-build-nodrm.sh b/scripts/xcode-build-nodrm.sh index eba364b17..3b7273a5d 100755 --- a/scripts/xcode-build-nodrm.sh +++ b/scripts/xcode-build-nodrm.sh @@ -14,4 +14,12 @@ echo "Building Palace without DRM support..." -fastlane ios nodrm +xcodebuild \ + -project Palace.xcodeproj \ + -scheme Palace-noDRM \ + -destination 'platform=iOS Simulator,name=iPhone 13,OS=16.1' \ + -configuration Debug \ + build \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO From 6b0c116cd54ea07a9de7ce1f9d4bb7435639be5b Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 14:40:32 -0400 Subject: [PATCH 19/32] Restore build --- Palace/Book/UI/BookDetail/BookDetailViewModel.swift | 7 +++---- fastlane/Fastfile | 2 +- scripts/xcode-build-nodrm.sh | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift index 12ba121fb..ca0418cb6 100644 --- a/Palace/Book/UI/BookDetail/BookDetailViewModel.swift +++ b/Palace/Book/UI/BookDetail/BookDetailViewModel.swift @@ -1019,6 +1019,7 @@ extension BookDetailViewModel: BookButtonProvider { } // MARK: - LCP Streaming Enhancement +#if LCP private extension BookDetailViewModel { /// Extract publication URL from LCPAudiobooks instance @@ -1034,10 +1035,8 @@ private extension BookDetailViewModel { return publicationUrl } - - - - } +#endif + extension BookDetailViewModel: HalfSheetProvider {} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e43c01294..32315084b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -21,7 +21,7 @@ platform :ios do xcodebuild( project: "Palace.xcodeproj", scheme: "Palace-noDRM", - destination: "platform=iOS Simulator,name=iPhone 13,OS=16.1", + destination: "platform=iOS Simulator,id=00E82424-9E89-403B-B393-ACF5F521158A", configuration: "Debug", build: true, xcargs: "ONLY_ACTIVE_ARCH=NO CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" diff --git a/scripts/xcode-build-nodrm.sh b/scripts/xcode-build-nodrm.sh index 3b7273a5d..a36a3286a 100755 --- a/scripts/xcode-build-nodrm.sh +++ b/scripts/xcode-build-nodrm.sh @@ -17,7 +17,7 @@ echo "Building Palace without DRM support..." xcodebuild \ -project Palace.xcodeproj \ -scheme Palace-noDRM \ - -destination 'platform=iOS Simulator,name=iPhone 13,OS=16.1' \ + -destination 'platform=iOS Simulator,id=00E82424-9E89-403B-B393-ACF5F521158A' \ -configuration Debug \ build \ ONLY_ACTIVE_ARCH=NO \ From b647deb9f2b10ebdf753f9d53ac1dfe85746745a Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 15:18:06 -0400 Subject: [PATCH 20/32] Update xcode-build-nodrm.sh --- scripts/xcode-build-nodrm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/xcode-build-nodrm.sh b/scripts/xcode-build-nodrm.sh index a36a3286a..a95a78d3c 100755 --- a/scripts/xcode-build-nodrm.sh +++ b/scripts/xcode-build-nodrm.sh @@ -17,7 +17,7 @@ echo "Building Palace without DRM support..." xcodebuild \ -project Palace.xcodeproj \ -scheme Palace-noDRM \ - -destination 'platform=iOS Simulator,id=00E82424-9E89-403B-B393-ACF5F521158A' \ + -destination 'generic/platform=iOS Simulator' \ -configuration Debug \ build \ ONLY_ACTIVE_ARCH=NO \ From 15e393fc4a4ffa6dc782cdbb5d6562983dc63ff2 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 15:18:09 -0400 Subject: [PATCH 21/32] Update Fastfile --- fastlane/Fastfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 32315084b..23bdc190d 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -9,7 +9,7 @@ platform :ios do project: "Palace.xcodeproj", devices: ["iPhone SE (3rd generation)"], scheme: "Palace", - destination: "platform=iOS Simulator,name=iPhone SE (3rd generation),OS=16.1", + destination: "platform=iOS Simulator,name=iPhone SE (3rd generation)", xcargs: "ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO" ) end @@ -21,7 +21,7 @@ platform :ios do xcodebuild( project: "Palace.xcodeproj", scheme: "Palace-noDRM", - destination: "platform=iOS Simulator,id=00E82424-9E89-403B-B393-ACF5F521158A", + destination: "generic/platform=iOS Simulator", configuration: "Debug", build: true, xcargs: "ONLY_ACTIVE_ARCH=NO CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" From cb781d2be8249275121e4d7cb0250740dabaca7d Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:07:10 -0400 Subject: [PATCH 22/32] Improve CI/CD test speed --- .github/workflows/quick-tests.yml | 134 +++++++++++++++++++++++++++++ .github/workflows/unit-testing.yml | 33 +++++-- fastlane/Fastfile | 13 +++ scripts/test-quick.sh | 39 +++++++++ scripts/xcode-test-optimized.sh | 47 ++++++++++ 5 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/quick-tests.yml create mode 100755 scripts/test-quick.sh create mode 100755 scripts/xcode-test-optimized.sh diff --git a/.github/workflows/quick-tests.yml b/.github/workflows/quick-tests.yml new file mode 100644 index 000000000..ab7081867 --- /dev/null +++ b/.github/workflows/quick-tests.yml @@ -0,0 +1,134 @@ +name: Quick Tests +on: + pull_request: + types: [opened, synchronize] + workflow_dispatch: + inputs: + test_type: + description: 'Test type to run' + required: true + default: 'quick' + type: choice + options: + - quick + - full + +jobs: + quick-test: + runs-on: macos-15 + if: github.event.inputs.test_type != 'full' + steps: + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Checkout main repo and submodules + uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: | + .build + SourcePackages + ~/Library/Developer/Xcode/DerivedData/**/SourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Cache Xcode DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Run quick essential tests only + run: | + echo "Running essential tests for quick feedback..." + xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -quiet \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + -only-testing:PalaceTests/TPPBookTests \ + -only-testing:PalaceTests/AccountTests \ + -only-testing:PalaceTests/OPDSTests \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES + env: + BUILD_CONTEXT: ci + + full-test: + runs-on: macos-15 + if: github.event.inputs.test_type == 'full' + steps: + - name: Set up Xcode 16.2 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '16.2' + + - name: Checkout main repo and submodules + uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: | + .build + SourcePackages + ~/Library/Developer/Xcode/DerivedData/**/SourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Cache Xcode DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Checkout Certificates + uses: actions/checkout@v3 + with: + repository: ThePalaceProject/mobile-certificates + token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + path: ./mobile-certificates + + - name: Checkout Adobe RMSDK + uses: ./.github/actions/checkout-adobe + with: + token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + + - name: Setup repo with DRM + run: ./scripts/setup-repo-drm.sh + env: + BUILD_CONTEXT: ci + + - name: Build non-Carthage 3rd party dependencies + run: ./scripts/build-3rd-party-dependencies.sh + env: + BUILD_CONTEXT: ci + + - name: Run full Palace unit tests + run: ./scripts/xcode-test-optimized.sh + env: + BUILD_CONTEXT: ci diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml index 0a39e77bd..2402ab8bc 100644 --- a/.github/workflows/unit-testing.yml +++ b/.github/workflows/unit-testing.yml @@ -11,34 +11,55 @@ jobs: - name: Verify Xcode Version run: xcodebuild -version + - name: Checkout main repo and submodules uses: actions/checkout@v3 with: submodules: true token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + + - name: Cache Swift packages + uses: actions/cache@v4 + with: + path: | + .build + SourcePackages + ~/Library/Developer/Xcode/DerivedData/**/SourcePackages + key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} + restore-keys: | + ${{ runner.os }}-spm- + + - name: Cache Xcode DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + - name: Checkout Certificates uses: actions/checkout@v3 with: repository: ThePalaceProject/mobile-certificates token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} path: ./mobile-certificates + - name: Checkout Adobe RMSDK uses: ./.github/actions/checkout-adobe with: token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} + - name: Setup repo with DRM run: ./scripts/setup-repo-drm.sh env: BUILD_CONTEXT: ci + - name: Build non-Carthage 3rd party dependencies run: ./scripts/build-3rd-party-dependencies.sh env: BUILD_CONTEXT: ci - - name: Build Palace without DRM support - run: ./scripts/xcode-build-nodrm.sh - env: - BUILD_CONTEXT: ci - - name: Run Palace unit tests - run: ./scripts/xcode-test.sh + + - name: Run Palace unit tests (builds automatically) + run: ./scripts/xcode-test-optimized.sh env: BUILD_CONTEXT: ci diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 23bdc190d..a47b6af49 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -14,6 +14,19 @@ platform :ios do ) end + lane :test_fast do + ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "180" + ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "2" + + run_tests( + project: "Palace.xcodeproj", + scheme: "Palace", + destination: "generic/platform=iOS Simulator", + code_coverage: false, + xcargs: "ONLY_ACTIVE_ARCH=YES CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO GCC_OPTIMIZATION_LEVEL=0 SWIFT_OPTIMIZATION_LEVEL=-Onone ENABLE_TESTABILITY=YES -parallel-testing-enabled YES -maximum-parallel-testing-workers 4" + ) + end + lane :nodrm do ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "300" ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "4" diff --git a/scripts/test-quick.sh b/scripts/test-quick.sh new file mode 100755 index 000000000..fabc97432 --- /dev/null +++ b/scripts/test-quick.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# SUMMARY +# Runs essential Palace unit tests quickly for rapid feedback during development. +# +# SYNOPSIS +# test-quick.sh +# +# USAGE +# Run this script from the root of Palace ios-core repo, e.g.: +# +# ./scripts/test-quick.sh + +set -euo pipefail + +echo "🚀 Running quick essential tests for Palace..." + +# Run only the most important test classes for rapid feedback +xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + -only-testing:PalaceTests/TPPBookTests \ + -only-testing:PalaceTests/AccountTests \ + -only-testing:PalaceTests/OPDSTests \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES + +echo "✅ Quick tests completed successfully!" +echo "💡 To run all tests: ./scripts/xcode-test.sh" +echo "💡 To run optimized tests: ./scripts/xcode-test-optimized.sh" diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh new file mode 100755 index 000000000..bb85bd6a2 --- /dev/null +++ b/scripts/xcode-test-optimized.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# SUMMARY +# Runs optimized unit tests for Palace with performance improvements. +# +# SYNOPSIS +# xcode-test-optimized.sh +# +# USAGE +# Run this script from the root of Palace ios-core repo, e.g.: +# +# ./scripts/xcode-test-optimized.sh + +set -euo pipefail + +echo "Running optimized unit tests for Palace..." + +# Skip the separate build step - xcodebuild test builds automatically and more efficiently +# Use parallel testing and optimized flags + +if [ "${BUILD_CONTEXT:-}" == "ci" ]; then + echo "Running in CI mode with optimizations..." + + # Use generic simulator for faster CI execution + xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -quiet \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES +else + echo "Running in local development mode..." + + # Use fastlane for local development (more user-friendly output) + fastlane ios test_fast +fi + +echo "✅ Unit tests completed successfully!" From 46f322b184c1f67543980adfeb5e14727287b974 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:17:02 -0400 Subject: [PATCH 23/32] remove quick tests --- .github/workflows/quick-tests.yml | 134 ------------------------------ fastlane/Fastfile | 17 +--- scripts/test-quick.sh | 39 --------- scripts/xcode-test-optimized.sh | 16 +++- 4 files changed, 16 insertions(+), 190 deletions(-) delete mode 100644 .github/workflows/quick-tests.yml delete mode 100755 scripts/test-quick.sh diff --git a/.github/workflows/quick-tests.yml b/.github/workflows/quick-tests.yml deleted file mode 100644 index ab7081867..000000000 --- a/.github/workflows/quick-tests.yml +++ /dev/null @@ -1,134 +0,0 @@ -name: Quick Tests -on: - pull_request: - types: [opened, synchronize] - workflow_dispatch: - inputs: - test_type: - description: 'Test type to run' - required: true - default: 'quick' - type: choice - options: - - quick - - full - -jobs: - quick-test: - runs-on: macos-15 - if: github.event.inputs.test_type != 'full' - steps: - - name: Set up Xcode 16.2 - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '16.2' - - - name: Checkout main repo and submodules - uses: actions/checkout@v3 - with: - submodules: true - token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} - - - name: Cache Swift packages - uses: actions/cache@v4 - with: - path: | - .build - SourcePackages - ~/Library/Developer/Xcode/DerivedData/**/SourcePackages - key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - - - name: Cache Xcode DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - - name: Run quick essential tests only - run: | - echo "Running essential tests for quick feedback..." - xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination 'generic/platform=iOS Simulator' \ - -configuration Debug \ - -enableCodeCoverage NO \ - -quiet \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - -only-testing:PalaceTests/TPPBookTests \ - -only-testing:PalaceTests/AccountTests \ - -only-testing:PalaceTests/OPDSTests \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES - env: - BUILD_CONTEXT: ci - - full-test: - runs-on: macos-15 - if: github.event.inputs.test_type == 'full' - steps: - - name: Set up Xcode 16.2 - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: '16.2' - - - name: Checkout main repo and submodules - uses: actions/checkout@v3 - with: - submodules: true - token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} - - - name: Cache Swift packages - uses: actions/cache@v4 - with: - path: | - .build - SourcePackages - ~/Library/Developer/Xcode/DerivedData/**/SourcePackages - key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} - restore-keys: | - ${{ runner.os }}-spm- - - - name: Cache Xcode DerivedData - uses: actions/cache@v4 - with: - path: ~/Library/Developer/Xcode/DerivedData - key: ${{ runner.os }}-deriveddata-${{ hashFiles('**/*.xcodeproj/project.pbxproj') }} - restore-keys: | - ${{ runner.os }}-deriveddata- - - - name: Checkout Certificates - uses: actions/checkout@v3 - with: - repository: ThePalaceProject/mobile-certificates - token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} - path: ./mobile-certificates - - - name: Checkout Adobe RMSDK - uses: ./.github/actions/checkout-adobe - with: - token: ${{ secrets.CI_GITHUB_ACCESS_TOKEN }} - - - name: Setup repo with DRM - run: ./scripts/setup-repo-drm.sh - env: - BUILD_CONTEXT: ci - - - name: Build non-Carthage 3rd party dependencies - run: ./scripts/build-3rd-party-dependencies.sh - env: - BUILD_CONTEXT: ci - - - name: Run full Palace unit tests - run: ./scripts/xcode-test-optimized.sh - env: - BUILD_CONTEXT: ci diff --git a/fastlane/Fastfile b/fastlane/Fastfile index a47b6af49..03d5255b3 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,25 +7,14 @@ platform :ios do run_tests( project: "Palace.xcodeproj", - devices: ["iPhone SE (3rd generation)"], scheme: "Palace", - destination: "platform=iOS Simulator,name=iPhone SE (3rd generation)", - xcargs: "ARCHS=x86_64 ONLY_ACTIVE_ARCH=NO" + destination: "platform=iOS Simulator,name=iPhone 15", + code_coverage: false, + xcargs: "ONLY_ACTIVE_ARCH=YES CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -parallel-testing-enabled YES -maximum-parallel-testing-workers 4" ) end - lane :test_fast do - ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "180" - ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "2" - run_tests( - project: "Palace.xcodeproj", - scheme: "Palace", - destination: "generic/platform=iOS Simulator", - code_coverage: false, - xcargs: "ONLY_ACTIVE_ARCH=YES CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO GCC_OPTIMIZATION_LEVEL=0 SWIFT_OPTIMIZATION_LEVEL=-Onone ENABLE_TESTABILITY=YES -parallel-testing-enabled YES -maximum-parallel-testing-workers 4" - ) - end lane :nodrm do ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "300" diff --git a/scripts/test-quick.sh b/scripts/test-quick.sh deleted file mode 100755 index fabc97432..000000000 --- a/scripts/test-quick.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/bash - -# SUMMARY -# Runs essential Palace unit tests quickly for rapid feedback during development. -# -# SYNOPSIS -# test-quick.sh -# -# USAGE -# Run this script from the root of Palace ios-core repo, e.g.: -# -# ./scripts/test-quick.sh - -set -euo pipefail - -echo "🚀 Running quick essential tests for Palace..." - -# Run only the most important test classes for rapid feedback -xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination 'generic/platform=iOS Simulator' \ - -configuration Debug \ - -enableCodeCoverage NO \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - -only-testing:PalaceTests/TPPBookTests \ - -only-testing:PalaceTests/AccountTests \ - -only-testing:PalaceTests/OPDSTests \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES - -echo "✅ Quick tests completed successfully!" -echo "💡 To run all tests: ./scripts/xcode-test.sh" -echo "💡 To run optimized tests: ./scripts/xcode-test-optimized.sh" diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index bb85bd6a2..bc7e8c6f6 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -21,11 +21,21 @@ echo "Running optimized unit tests for Palace..." if [ "${BUILD_CONTEXT:-}" == "ci" ]; then echo "Running in CI mode with optimizations..." - # Use generic simulator for faster CI execution + # Find an available iOS simulator for CI + AVAILABLE_SIMULATOR=$(xcrun simctl list devices available | grep iPhone | head -1 | sed 's/^ *//' | sed 's/ (.*//') + + if [ -z "$AVAILABLE_SIMULATOR" ]; then + echo "No available iOS simulator found, trying iPhone 15..." + AVAILABLE_SIMULATOR="iPhone 15" + fi + + echo "Using simulator: $AVAILABLE_SIMULATOR" + + # Use available simulator for CI execution xcodebuild test \ -project Palace.xcodeproj \ -scheme Palace \ - -destination 'generic/platform=iOS Simulator' \ + -destination "platform=iOS Simulator,name=$AVAILABLE_SIMULATOR" \ -configuration Debug \ -enableCodeCoverage NO \ -quiet \ @@ -41,7 +51,7 @@ else echo "Running in local development mode..." # Use fastlane for local development (more user-friendly output) - fastlane ios test_fast + fastlane ios test fi echo "✅ Unit tests completed successfully!" From 2e6ab47ff0e4452928ca29f58a18ccd3038cd95f Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:23:11 -0400 Subject: [PATCH 24/32] Update xcode-test-optimized.sh --- scripts/xcode-test-optimized.sh | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index bc7e8c6f6..86fdcef1f 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -21,21 +21,24 @@ echo "Running optimized unit tests for Palace..." if [ "${BUILD_CONTEXT:-}" == "ci" ]; then echo "Running in CI mode with optimizations..." - # Find an available iOS simulator for CI - AVAILABLE_SIMULATOR=$(xcrun simctl list devices available | grep iPhone | head -1 | sed 's/^ *//' | sed 's/ (.*//') + # Find an available iOS simulator for CI using ID for reliability + SIMULATOR_INFO=$(xcrun simctl list devices available | grep iPhone | grep -v "iPad" | head -1) + SIMULATOR_ID=$(echo "$SIMULATOR_INFO" | grep -o '[A-F0-9-]\{36\}') + SIMULATOR_NAME=$(echo "$SIMULATOR_INFO" | sed 's/^ *//' | sed 's/ (.*//') - if [ -z "$AVAILABLE_SIMULATOR" ]; then - echo "No available iOS simulator found, trying iPhone 15..." - AVAILABLE_SIMULATOR="iPhone 15" + if [ -z "$SIMULATOR_ID" ]; then + echo "No available iOS simulator found, using generic destination..." + DESTINATION="platform=iOS Simulator,name=iPhone SE (3rd generation)" + else + echo "Using simulator: $SIMULATOR_NAME (ID: $SIMULATOR_ID)" + DESTINATION="platform=iOS Simulator,id=$SIMULATOR_ID" fi - echo "Using simulator: $AVAILABLE_SIMULATOR" - # Use available simulator for CI execution xcodebuild test \ -project Palace.xcodeproj \ -scheme Palace \ - -destination "platform=iOS Simulator,name=$AVAILABLE_SIMULATOR" \ + -destination "$DESTINATION" \ -configuration Debug \ -enableCodeCoverage NO \ -quiet \ From 8344d20553568bd92e2b0b859732b23f2f1de14e Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:30:24 -0400 Subject: [PATCH 25/32] Update xcode-test.sh --- scripts/xcode-test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/xcode-test.sh b/scripts/xcode-test.sh index 37c5300a6..cff536a03 100755 --- a/scripts/xcode-test.sh +++ b/scripts/xcode-test.sh @@ -12,5 +12,6 @@ # ./scripts/xcode-test.sh echo "Running unit tests for Palace..." +echo "💡 For faster tests, use: ./scripts/xcode-test-optimized.sh" fastlane ios test \ No newline at end of file From 0cde3ba5c281f007d0b9bc0c95e8351cdcb0504f Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:30:27 -0400 Subject: [PATCH 26/32] Update xcode-test-optimized.sh --- scripts/xcode-test-optimized.sh | 56 +++++++++++---------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index 86fdcef1f..19cd60e31 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -18,43 +18,23 @@ echo "Running optimized unit tests for Palace..." # Skip the separate build step - xcodebuild test builds automatically and more efficiently # Use parallel testing and optimized flags -if [ "${BUILD_CONTEXT:-}" == "ci" ]; then - echo "Running in CI mode with optimizations..." - - # Find an available iOS simulator for CI using ID for reliability - SIMULATOR_INFO=$(xcrun simctl list devices available | grep iPhone | grep -v "iPad" | head -1) - SIMULATOR_ID=$(echo "$SIMULATOR_INFO" | grep -o '[A-F0-9-]\{36\}') - SIMULATOR_NAME=$(echo "$SIMULATOR_INFO" | sed 's/^ *//' | sed 's/ (.*//') - - if [ -z "$SIMULATOR_ID" ]; then - echo "No available iOS simulator found, using generic destination..." - DESTINATION="platform=iOS Simulator,name=iPhone SE (3rd generation)" - else - echo "Using simulator: $SIMULATOR_NAME (ID: $SIMULATOR_ID)" - DESTINATION="platform=iOS Simulator,id=$SIMULATOR_ID" - fi - - # Use available simulator for CI execution - xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination "$DESTINATION" \ - -configuration Debug \ - -enableCodeCoverage NO \ - -quiet \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES -else - echo "Running in local development mode..." - - # Use fastlane for local development (more user-friendly output) - fastlane ios test -fi +# Use direct xcodebuild for faster execution (skip Fastlane overhead) +# Generic destination works better for CI compatibility +echo "Running optimized tests with generic destination..." + +xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES echo "✅ Unit tests completed successfully!" From abd52e57fa83878166c1ded5f604aa1296b64441 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 17:37:02 -0400 Subject: [PATCH 27/32] Update xcode-test-optimized.sh --- scripts/xcode-test-optimized.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index 19cd60e31..2e03efa00 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -19,13 +19,13 @@ echo "Running optimized unit tests for Palace..." # Use parallel testing and optimized flags # Use direct xcodebuild for faster execution (skip Fastlane overhead) -# Generic destination works better for CI compatibility -echo "Running optimized tests with generic destination..." +# Use the placeholder ID for generic iOS Simulator compatibility +echo "Running optimized tests with generic iOS Simulator placeholder..." xcodebuild test \ -project Palace.xcodeproj \ -scheme Palace \ - -destination 'platform=iOS Simulator,name=Any iOS Simulator Device' \ + -destination 'platform=iOS Simulator,id=dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder' \ -configuration Debug \ -enableCodeCoverage NO \ -parallel-testing-enabled YES \ From 7486c3f58b34d2ac0e69499bdc79c1c8400f5b7d Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 21:54:56 -0400 Subject: [PATCH 28/32] Update xcode-test-optimized.sh --- scripts/xcode-test-optimized.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index 2e03efa00..da8c2d14c 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -19,13 +19,13 @@ echo "Running optimized unit tests for Palace..." # Use parallel testing and optimized flags # Use direct xcodebuild for faster execution (skip Fastlane overhead) -# Use the placeholder ID for generic iOS Simulator compatibility -echo "Running optimized tests with generic iOS Simulator placeholder..." +# Use iPhone SE (3rd generation) as it's commonly available and reliable +echo "Running optimized tests with iPhone SE (3rd generation)..." xcodebuild test \ -project Palace.xcodeproj \ -scheme Palace \ - -destination 'platform=iOS Simulator,id=dvtdevice-DVTiOSDeviceSimulatorPlaceholder-iphonesimulator:placeholder' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \ -configuration Debug \ -enableCodeCoverage NO \ -parallel-testing-enabled YES \ From bc3017ca1094758b5f91b357886ccd0041ccfd36 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 21:54:58 -0400 Subject: [PATCH 29/32] Update Fastfile --- fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 03d5255b3..2eb055209 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -8,7 +8,7 @@ platform :ios do run_tests( project: "Palace.xcodeproj", scheme: "Palace", - destination: "platform=iOS Simulator,name=iPhone 15", + destination: "platform=iOS Simulator,name=iPhone SE (3rd generation)", code_coverage: false, xcargs: "ONLY_ACTIVE_ARCH=YES CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -parallel-testing-enabled YES -maximum-parallel-testing-workers 4" ) From 93175c3087ecbf30630fd1f05a915aa27d952d3b Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 22:05:19 -0400 Subject: [PATCH 30/32] check for available sims on test run --- .github/workflows/unit-testing.yml | 3 ++ fastlane/Fastfile | 11 +++- scripts/xcode-test-optimized.sh | 82 +++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/.github/workflows/unit-testing.yml b/.github/workflows/unit-testing.yml index 2402ab8bc..3b02f83ea 100644 --- a/.github/workflows/unit-testing.yml +++ b/.github/workflows/unit-testing.yml @@ -59,6 +59,9 @@ jobs: env: BUILD_CONTEXT: ci + - name: List available simulators for debugging + run: xcrun simctl list devices available | grep iPhone | head -10 + - name: Run Palace unit tests (builds automatically) run: ./scripts/xcode-test-optimized.sh env: diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 2eb055209..888a3f6de 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -5,10 +5,19 @@ platform :ios do ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "300" ENV["FASTLANE_XCODEBUILD_SETTINGS_RETRIES"] = "4" + # Find first available iPhone simulator dynamically + simulator_id = sh("xcodebuild -project Palace.xcodeproj -scheme Palace -showdestinations 2>/dev/null | grep 'platform:iOS Simulator' | grep 'iPhone' | grep -v 'error:' | head -1 | sed 's/.*id:\\([^,]*\\).*/\\1/'").strip + + if simulator_id.empty? + UI.user_error!("No available iPhone simulator found") + end + + UI.message("Using iPhone simulator ID: #{simulator_id}") + run_tests( project: "Palace.xcodeproj", scheme: "Palace", - destination: "platform=iOS Simulator,name=iPhone SE (3rd generation)", + destination: "platform=iOS Simulator,id=#{simulator_id}", code_coverage: false, xcargs: "ONLY_ACTIVE_ARCH=YES CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO -parallel-testing-enabled YES -maximum-parallel-testing-workers 4" ) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index da8c2d14c..8ce860724 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -19,22 +19,70 @@ echo "Running optimized unit tests for Palace..." # Use parallel testing and optimized flags # Use direct xcodebuild for faster execution (skip Fastlane overhead) -# Use iPhone SE (3rd generation) as it's commonly available and reliable -echo "Running optimized tests with iPhone SE (3rd generation)..." - -xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation)' \ - -configuration Debug \ - -enableCodeCoverage NO \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES +# Try multiple fallback strategies for CI compatibility +echo "Detecting test environment and finding suitable simulator..." + +if [ "${BUILD_CONTEXT:-}" == "ci" ]; then + echo "Running in CI environment - using CI-optimized approach" + # In CI, use the most basic approach that works across all GitHub runners + xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES +else + echo "Running in local environment - using dynamic detection" + # Get the first available iPhone simulator ID from the Palace scheme destinations + SIMULATOR_ID=$(xcodebuild -project Palace.xcodeproj -scheme Palace -showdestinations 2>/dev/null | \ + grep "platform:iOS Simulator" | \ + grep "iPhone" | \ + grep -v "error:" | \ + head -1 | \ + sed 's/.*id:\([^,]*\).*/\1/') + + if [ -z "$SIMULATOR_ID" ]; then + echo "❌ No available iPhone simulator found, trying fallback..." + # Fallback to name-based approach + xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination 'platform=iOS Simulator,name=iPhone 15' \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES + else + echo "Using iPhone simulator ID: $SIMULATOR_ID" + xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES + fi +fi echo "✅ Unit tests completed successfully!" From c5e465a6dfd2d3464d814e1938776d76a8fc3781 Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 22:11:28 -0400 Subject: [PATCH 31/32] Update xcode-test-optimized.sh --- scripts/xcode-test-optimized.sh | 97 ++++++++++++++++++++++----------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/scripts/xcode-test-optimized.sh b/scripts/xcode-test-optimized.sh index 8ce860724..c1dbea7d3 100755 --- a/scripts/xcode-test-optimized.sh +++ b/scripts/xcode-test-optimized.sh @@ -23,22 +23,37 @@ echo "Running optimized unit tests for Palace..." echo "Detecting test environment and finding suitable simulator..." if [ "${BUILD_CONTEXT:-}" == "ci" ]; then - echo "Running in CI environment - using CI-optimized approach" - # In CI, use the most basic approach that works across all GitHub runners - xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - -configuration Debug \ - -enableCodeCoverage NO \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES + echo "Running in CI environment - trying multiple fallback strategies" + + # List available simulators for debugging + echo "Available iPhone simulators in CI:" + xcrun simctl list devices available | grep iPhone | head -5 + + # Try multiple simulator options that are commonly available in CI + SIMULATORS=("iPhone SE (3rd generation)" "iPhone 14" "iPhone 13" "iPhone 12" "iPhone 11") + + for SIM in "${SIMULATORS[@]}"; do + echo "Attempting to use: $SIM" + if xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination "platform=iOS Simulator,name=$SIM" \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 2 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES 2>/dev/null; then + echo "✅ Successfully used simulator: $SIM" + break + else + echo "❌ Failed with simulator: $SIM, trying next..." + fi + done else echo "Running in local environment - using dynamic detection" # Get the first available iPhone simulator ID from the Palace scheme destinations @@ -51,23 +66,41 @@ else if [ -z "$SIMULATOR_ID" ]; then echo "❌ No available iPhone simulator found, trying fallback..." - # Fallback to name-based approach - xcodebuild test \ - -project Palace.xcodeproj \ - -scheme Palace \ - -destination 'platform=iOS Simulator,name=iPhone 15' \ - -configuration Debug \ - -enableCodeCoverage NO \ - -parallel-testing-enabled YES \ - -maximum-parallel-testing-workers 4 \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO \ - ONLY_ACTIVE_ARCH=YES \ - GCC_OPTIMIZATION_LEVEL=0 \ - SWIFT_OPTIMIZATION_LEVEL=-Onone \ - ENABLE_TESTABILITY=YES + # Clean build folder first + xcodebuild clean -project Palace.xcodeproj -scheme Palace > /dev/null 2>&1 + + # Fallback to name-based approach with common simulators + FALLBACK_SIMULATORS=("iPhone SE (3rd generation)" "iPhone 14" "iPhone 13" "iPhone 12") + + for SIM in "${FALLBACK_SIMULATORS[@]}"; do + echo "Trying fallback simulator: $SIM" + if xcodebuild test \ + -project Palace.xcodeproj \ + -scheme Palace \ + -destination "platform=iOS Simulator,name=$SIM" \ + -configuration Debug \ + -enableCodeCoverage NO \ + -parallel-testing-enabled YES \ + -maximum-parallel-testing-workers 4 \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO \ + ONLY_ACTIVE_ARCH=YES \ + VALID_ARCHS="arm64 x86_64" \ + ARCHS="arm64" \ + GCC_OPTIMIZATION_LEVEL=0 \ + SWIFT_OPTIMIZATION_LEVEL=-Onone \ + ENABLE_TESTABILITY=YES 2>/dev/null; then + echo "✅ Fallback successful with: $SIM" + break + else + echo "❌ Fallback failed with: $SIM" + fi + done else echo "Using iPhone simulator ID: $SIMULATOR_ID" + # Clean build folder to avoid architecture conflicts + xcodebuild clean -project Palace.xcodeproj -scheme Palace > /dev/null 2>&1 + xcodebuild test \ -project Palace.xcodeproj \ -scheme Palace \ @@ -79,6 +112,8 @@ else CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO \ ONLY_ACTIVE_ARCH=YES \ + VALID_ARCHS="arm64 x86_64" \ + ARCHS="arm64" \ GCC_OPTIMIZATION_LEVEL=0 \ SWIFT_OPTIMIZATION_LEVEL=-Onone \ ENABLE_TESTABILITY=YES From e253de16629d6ee3d912753ae06d3f3668bd376d Mon Sep 17 00:00:00 2001 From: Maurice Carrier Date: Mon, 18 Aug 2025 22:20:41 -0400 Subject: [PATCH 32/32] Update project.pbxproj --- Palace.xcodeproj/project.pbxproj | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Palace.xcodeproj/project.pbxproj b/Palace.xcodeproj/project.pbxproj index 2317ea21f..d7a958ea1 100644 --- a/Palace.xcodeproj/project.pbxproj +++ b/Palace.xcodeproj/project.pbxproj @@ -4746,7 +4746,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4805,7 +4805,7 @@ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES_ERROR; CODE_SIGN_ENTITLEMENTS = Palace/SimplyE.entitlements; CODE_SIGN_IDENTITY = "iPhone Distribution"; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = 88CBA74T8K; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO; @@ -4989,7 +4989,7 @@ CODE_SIGN_ENTITLEMENTS = Palace/PalaceDebug.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( @@ -5050,7 +5050,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 361; + CURRENT_PROJECT_VERSION = 362; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = 88CBA74T8K; ENABLE_BITCODE = NO;