diff --git a/BookPlayer/Base.lproj/Localizable.strings b/BookPlayer/Base.lproj/Localizable.strings index 9da2f799c..e9debbb80 100644 --- a/BookPlayer/Base.lproj/Localizable.strings +++ b/BookPlayer/Base.lproj/Localizable.strings @@ -247,6 +247,7 @@ "gesture_swipe_vertically_title" = "Swipe vertically to create bookmark"; "details_title" = "Details"; "download_title" = "Download"; +"download_incomplete_error" = "The download was incomplete. Please try again."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; diff --git a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift index 7e8808093..08aadfa8f 100644 --- a/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift +++ b/BookPlayer/Library/ItemList/Views/ItemArtworkView.swift @@ -105,6 +105,18 @@ struct ItemArtworkView: View { ) { _ in downloadState = .notDownloaded } + /// A download/verification failure (e.g. a discarded truncated file) must reset + /// the cell — otherwise, now that completion is gated on verification, it would + /// stay stuck on the progress spinner until the view recomputes from disk. The + /// error payload only carries `relativePath`, so this matches the item directly + /// (a bound-book child error doesn't reset the parent cell — same limitation as + /// the Watch). + .onReceive( + syncService.downloadErrorPublisher + .filter { $0.0 == item.relativePath } + ) { _ in + downloadState = .notDownloaded + } } @ViewBuilder diff --git a/BookPlayer/ar.lproj/Localizable.strings b/BookPlayer/ar.lproj/Localizable.strings index 0e9829e76..2474643cc 100644 --- a/BookPlayer/ar.lproj/Localizable.strings +++ b/BookPlayer/ar.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "اسحب عموديًا لإنشاء إشارة مرجعية"; "details_title" = "تفاصيل"; "download_title" = "تحميل"; +"download_incomplete_error" = "لم يكتمل التنزيل. يُرجى المحاولة مرة أخرى."; "cancel_download_title" = "إلغاء التنزيل"; "remove_downloaded_file_title" = "إزالة من الجهاز"; "download_from_url_title" = "تنزيل من URL"; diff --git a/BookPlayer/ca.lproj/Localizable.strings b/BookPlayer/ca.lproj/Localizable.strings index 7f3fcca9f..259a189a8 100644 --- a/BookPlayer/ca.lproj/Localizable.strings +++ b/BookPlayer/ca.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Llisca verticalment per crear un marcador"; "details_title" = "Detalls"; "download_title" = "Descarrega"; +"download_incomplete_error" = "La baixada no s'ha completat. Torneu-ho a provar."; "cancel_download_title" = "Cancel·la la descàrrega"; "remove_downloaded_file_title" = "Suprimeix del dispositiu"; "download_from_url_title" = "Descarrega des de l'URL"; diff --git a/BookPlayer/cs.lproj/Localizable.strings b/BookPlayer/cs.lproj/Localizable.strings index f816161d9..434b548a7 100644 --- a/BookPlayer/cs.lproj/Localizable.strings +++ b/BookPlayer/cs.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Záložku vytvoříte přejetím prstem svisle"; "details_title" = "Podrobnosti"; "download_title" = "Stažení"; +"download_incomplete_error" = "Stahování nebylo dokončeno. Zkuste to prosím znovu."; "cancel_download_title" = "Zrušit stahování"; "remove_downloaded_file_title" = "Odebrat ze zařízení"; "download_from_url_title" = "Stáhnout z URL"; diff --git a/BookPlayer/da.lproj/Localizable.strings b/BookPlayer/da.lproj/Localizable.strings index 62a91bac8..4f15542f6 100644 --- a/BookPlayer/da.lproj/Localizable.strings +++ b/BookPlayer/da.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Stryg lodret for at oprette et bogmærke"; "details_title" = "detaljer"; "download_title" = "Hent"; +"download_incomplete_error" = "Overførslen blev ikke fuldført. Prøv igen."; "cancel_download_title" = "Annuller download"; "remove_downloaded_file_title" = "Fjern fra enheden"; "download_from_url_title" = "Download fra URL"; diff --git a/BookPlayer/de.lproj/Localizable.strings b/BookPlayer/de.lproj/Localizable.strings index 5c24bae0c..61419022f 100644 --- a/BookPlayer/de.lproj/Localizable.strings +++ b/BookPlayer/de.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Streiche vertikal, um ein Lesezeichen zu erstellen"; "details_title" = "Einzelheiten"; "download_title" = "Herunterladen"; +"download_incomplete_error" = "Der Download wurde nicht vollständig abgeschlossen. Bitte versuche es erneut."; "cancel_download_title" = "Download abbrechen"; "remove_downloaded_file_title" = "Vom Gerät entfernen"; "download_from_url_title" = "Von URL herunterladen"; diff --git a/BookPlayer/el.lproj/Localizable.strings b/BookPlayer/el.lproj/Localizable.strings index 564a125d8..b032f8bc6 100644 --- a/BookPlayer/el.lproj/Localizable.strings +++ b/BookPlayer/el.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Σύρετε κάθετα για να δημιουργήσετε σελιδοδείκτη"; "details_title" = "Λεπτομέριες"; "download_title" = "Κατεβάστε"; +"download_incomplete_error" = "Η λήψη δεν ολοκληρώθηκε. Δοκιμάστε ξανά."; "cancel_download_title" = "Ακύρωση λήψης"; "remove_downloaded_file_title" = "Αφαιρέστε από τη συσκευή"; "download_from_url_title" = "Λήψη από τη διεύθυνση URL"; diff --git a/BookPlayer/en.lproj/Localizable.strings b/BookPlayer/en.lproj/Localizable.strings index 7f79b0a42..8cddf428d 100644 --- a/BookPlayer/en.lproj/Localizable.strings +++ b/BookPlayer/en.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Swipe vertically to create bookmark"; "details_title" = "Details"; "download_title" = "Download"; +"download_incomplete_error" = "The download was incomplete. Please try again."; "cancel_download_title" = "Cancel download"; "remove_downloaded_file_title" = "Remove from device"; "download_from_url_title" = "Download from URL"; diff --git a/BookPlayer/es.lproj/Localizable.strings b/BookPlayer/es.lproj/Localizable.strings index 708ded493..53d6317df 100644 --- a/BookPlayer/es.lproj/Localizable.strings +++ b/BookPlayer/es.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Desliza verticalmente para crear un marcador"; "details_title" = "Detalles"; "download_title" = "Descargar"; +"download_incomplete_error" = "La descarga no se completó. Inténtalo de nuevo."; "cancel_download_title" = "Cancelar descarga"; "remove_downloaded_file_title" = "Eliminar del dispositivo"; "download_from_url_title" = "Descargar desde URL"; diff --git a/BookPlayer/fi.lproj/Localizable.strings b/BookPlayer/fi.lproj/Localizable.strings index 153d4b6cc..92c3207a9 100644 --- a/BookPlayer/fi.lproj/Localizable.strings +++ b/BookPlayer/fi.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Luo kirjanmerkki pyyhkäisemällä pystysuunnassa"; "details_title" = "Yksityiskohdat"; "download_title" = "ladata"; +"download_incomplete_error" = "Lataus jäi kesken. Yritä uudelleen."; "cancel_download_title" = "Peruuta lataus"; "remove_downloaded_file_title" = "Poista laitteesta"; "download_from_url_title" = "Lataa osoitteesta URL"; diff --git a/BookPlayer/fr.lproj/Localizable.strings b/BookPlayer/fr.lproj/Localizable.strings index 12bd58ae4..12e2099f2 100644 --- a/BookPlayer/fr.lproj/Localizable.strings +++ b/BookPlayer/fr.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Faites glisser verticalement pour créer un signet"; "details_title" = "Détails"; "download_title" = "Télécharger"; +"download_incomplete_error" = "Le téléchargement n'a pas abouti. Veuillez réessayer."; "cancel_download_title" = "Annuler le téléchargement"; "remove_downloaded_file_title" = "Supprimer de l’appareil"; "download_from_url_title" = "Télécharger à partir de l'URL"; diff --git a/BookPlayer/hu.lproj/Localizable.strings b/BookPlayer/hu.lproj/Localizable.strings index 7a7ac1934..c2883fc73 100644 --- a/BookPlayer/hu.lproj/Localizable.strings +++ b/BookPlayer/hu.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Függőleges csúsztatással hozzon létre könyvjelzőt"; "details_title" = "Részletek"; "download_title" = "Letöltés"; +"download_incomplete_error" = "A letöltés nem fejeződött be. Próbáld újra."; "cancel_download_title" = "Letöltés megszakítása"; "remove_downloaded_file_title" = "Távolítsa el az eszközről"; "download_from_url_title" = "Letöltés URL-ről"; diff --git a/BookPlayer/it.lproj/Localizable.strings b/BookPlayer/it.lproj/Localizable.strings index a97611751..38dbf5c25 100644 --- a/BookPlayer/it.lproj/Localizable.strings +++ b/BookPlayer/it.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Scorri verticalmente per creare un segnalibro"; "details_title" = "Dettagli"; "download_title" = "Scaricamento"; +"download_incomplete_error" = "Il download non è stato completato. Riprova."; "cancel_download_title" = "Annulla il download"; "remove_downloaded_file_title" = "Rimuovi dal dispositivo"; "download_from_url_title" = "Scarica dall'URL"; diff --git a/BookPlayer/ja.lproj/Localizable.strings b/BookPlayer/ja.lproj/Localizable.strings index d9983bde6..a87f59ac0 100644 --- a/BookPlayer/ja.lproj/Localizable.strings +++ b/BookPlayer/ja.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "縦にスワイプしてブックマークを作成"; "details_title" = "詳細"; "download_title" = "ダウンロード"; +"download_incomplete_error" = "ダウンロードが完了しませんでした。もう一度お試しください。"; "cancel_download_title" = "ダウンロードをキャンセル"; "remove_downloaded_file_title" = "デバイスから削除"; "download_from_url_title" = "URLを指定してダウンロード"; diff --git a/BookPlayer/nb.lproj/Localizable.strings b/BookPlayer/nb.lproj/Localizable.strings index 9d93c03f6..cb9d1ecef 100644 --- a/BookPlayer/nb.lproj/Localizable.strings +++ b/BookPlayer/nb.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Sveip vertikalt for å opprette bokmerke"; "details_title" = "Detaljer"; "download_title" = "Last ned"; +"download_incomplete_error" = "Nedlastingen ble ikke fullført. Prøv igjen."; "cancel_download_title" = "Avbryt nedlasting"; "remove_downloaded_file_title" = "Fjern fra enhet"; "download_from_url_title" = "Last ned fra URL"; diff --git a/BookPlayer/nl.lproj/Localizable.strings b/BookPlayer/nl.lproj/Localizable.strings index 89f34bedd..5d727c0c8 100644 --- a/BookPlayer/nl.lproj/Localizable.strings +++ b/BookPlayer/nl.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Veeg verticaal om een bladwijzer te maken"; "details_title" = "Details"; "download_title" = "Downloaden"; +"download_incomplete_error" = "De download is niet voltooid. Probeer het opnieuw."; "cancel_download_title" = "Download annuleren"; "remove_downloaded_file_title" = "Verwijderen van apparaat"; "download_from_url_title" = "Downloaden van URL"; diff --git a/BookPlayer/pl.lproj/Localizable.strings b/BookPlayer/pl.lproj/Localizable.strings index 097a04092..de08ef8af 100644 --- a/BookPlayer/pl.lproj/Localizable.strings +++ b/BookPlayer/pl.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Przeciągnij w pionie, aby utworzyć zakładkę"; "details_title" = "Szczegóły"; "download_title" = "Pobierz"; +"download_incomplete_error" = "Pobieranie nie zostało ukończone. Spróbuj ponownie."; "cancel_download_title" = "Anuluj pobieranie"; "remove_downloaded_file_title" = "Usuń z urządzenia"; "download_from_url_title" = "Pobierz z adresu URL"; diff --git a/BookPlayer/pt-BR.lproj/Localizable.strings b/BookPlayer/pt-BR.lproj/Localizable.strings index 426b30b50..12bcdfc09 100644 --- a/BookPlayer/pt-BR.lproj/Localizable.strings +++ b/BookPlayer/pt-BR.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Deslize verticalmente para criar um marcador"; "details_title" = "Detalhes"; "download_title" = "Download"; +"download_incomplete_error" = "O download não foi concluído. Tente novamente."; "cancel_download_title" = "Cancelar download"; "remove_downloaded_file_title" = "Remover do dispositivo"; "download_from_url_title" = "Baixar do URL"; diff --git a/BookPlayer/pt-PT.lproj/Localizable.strings b/BookPlayer/pt-PT.lproj/Localizable.strings index 158d3f8f9..6f781a4b7 100644 --- a/BookPlayer/pt-PT.lproj/Localizable.strings +++ b/BookPlayer/pt-PT.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Deslize na vertical para criar um marcador"; "details_title" = "Detalhes"; "download_title" = "Download"; +"download_incomplete_error" = "O download não foi concluído. Tente novamente."; "cancel_download_title" = "Cancelar o download"; "remove_downloaded_file_title" = "Apagar do dispositivo"; "download_from_url_title" = "Descarregar do URL"; diff --git a/BookPlayer/ro.lproj/Localizable.strings b/BookPlayer/ro.lproj/Localizable.strings index 9a5cc02cf..36b1f5fee 100644 --- a/BookPlayer/ro.lproj/Localizable.strings +++ b/BookPlayer/ro.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Glisați vertical pentru a crea un marcaj"; "details_title" = "Detalii"; "download_title" = "Descarca"; +"download_incomplete_error" = "Descărcarea nu s-a finalizat. Încercați din nou."; "cancel_download_title" = "Anulați descărcarea"; "remove_downloaded_file_title" = "Scoateți de pe dispozitiv"; "download_from_url_title" = "Descărcați de la URL"; diff --git a/BookPlayer/ru.lproj/Localizable.strings b/BookPlayer/ru.lproj/Localizable.strings index 34f74bf69..74a97ceb2 100644 --- a/BookPlayer/ru.lproj/Localizable.strings +++ b/BookPlayer/ru.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Проведите по вертикали, чтобы создать закладку"; "details_title" = "Подробности"; "download_title" = "Скачать"; +"download_incomplete_error" = "Загрузка не завершена. Повторите попытку."; "cancel_download_title" = "Отменить загрузку"; "remove_downloaded_file_title" = "Удалить с устройства"; "download_from_url_title" = "Скачать с URL-адреса"; diff --git a/BookPlayer/sk-SK.lproj/Localizable.strings b/BookPlayer/sk-SK.lproj/Localizable.strings index 0f48e96df..cb29a237a 100644 --- a/BookPlayer/sk-SK.lproj/Localizable.strings +++ b/BookPlayer/sk-SK.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Potiahnutím zvisle vytvoríte záložku"; "details_title" = "Podrobnosti"; "download_title" = "Stiahnuť"; +"download_incomplete_error" = "Sťahovanie sa nedokončilo. Skúste to znova."; "cancel_download_title" = "Zrušiť sťahovanie"; "remove_downloaded_file_title" = "Odstrániť zo zariadenia"; "download_from_url_title" = "Sťahovanie z adresy URL"; diff --git a/BookPlayer/sv.lproj/Localizable.strings b/BookPlayer/sv.lproj/Localizable.strings index d534e6807..8d5baff42 100644 --- a/BookPlayer/sv.lproj/Localizable.strings +++ b/BookPlayer/sv.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Svep vertikalt för att skapa ett bokmärke"; "details_title" = "Detaljer"; "download_title" = "Ladda ner"; +"download_incomplete_error" = "Nedladdningen slutfördes inte. Försök igen."; "cancel_download_title" = "Avbryt nedladdning"; "remove_downloaded_file_title" = "Ta bort från enheten"; "download_from_url_title" = "Ladda ner från URL"; diff --git a/BookPlayer/tr.lproj/Localizable.strings b/BookPlayer/tr.lproj/Localizable.strings index 3b8294002..6688acf65 100644 --- a/BookPlayer/tr.lproj/Localizable.strings +++ b/BookPlayer/tr.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Yer imi oluşturmak için dikey olarak kaydırın"; "details_title" = "Detaylar"; "download_title" = "İndirmek"; +"download_incomplete_error" = "İndirme tamamlanmadı. Lütfen tekrar deneyin."; "cancel_download_title" = "İndirmeyi iptal et"; "remove_downloaded_file_title" = "Cihazdan kaldır"; "download_from_url_title" = "URL'den indir"; diff --git a/BookPlayer/uk.lproj/Localizable.strings b/BookPlayer/uk.lproj/Localizable.strings index 271e2bec3..192d02589 100644 --- a/BookPlayer/uk.lproj/Localizable.strings +++ b/BookPlayer/uk.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "Проведіть вертикально, щоб створити закладку"; "details_title" = "Подробиці"; "download_title" = "Завантажити"; +"download_incomplete_error" = "Завантаження не завершено. Спробуйте ще раз."; "cancel_download_title" = "Скасувати завантаження"; "remove_downloaded_file_title" = "Видалити з пристрою"; "download_from_url_title" = "Завантажити з URL"; diff --git a/BookPlayer/zh-Hans.lproj/Localizable.strings b/BookPlayer/zh-Hans.lproj/Localizable.strings index cccf351c3..1bb19fa28 100644 --- a/BookPlayer/zh-Hans.lproj/Localizable.strings +++ b/BookPlayer/zh-Hans.lproj/Localizable.strings @@ -245,6 +245,7 @@ "gesture_swipe_vertically_title" = "垂直滑动以创建书签"; "details_title" = "细节"; "download_title" = "下载"; +"download_incomplete_error" = "下载未完成,请重试。"; "cancel_download_title" = "取消下载"; "remove_downloaded_file_title" = "从设备中删除"; "download_from_url_title" = "从网址下载"; diff --git a/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift b/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift index e3ad19e11..6614825a4 100644 --- a/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift +++ b/BookPlayerWatch/LocalPlayback/RemoteItemList/RemoteItemCellViewModel.swift @@ -50,6 +50,20 @@ class RemoteItemCellViewModel: ObservableObject { self?.downloadState = .downloading(progress: progress) }.store(in: &disposeBag) + + /// A download/verification failure (e.g. a truncated file that was discarded) + /// must reset the cell — otherwise it would stay stuck showing the in-progress + /// or downloaded state for a file that no longer exists on disk. The error + /// publisher only carries the failing `relativePath`, so this matches single + /// items directly (bound-book chapter errors are surfaced but don't reset the + /// parent cell — handled when the error payload carries the initiating path). + coreServices.syncService.downloadErrorPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] (relativePath, _) in + guard relativePath == self?.item.relativePath else { return } + + self?.downloadState = .notDownloaded + }.store(in: &disposeBag) } func startDownload() async throws { diff --git a/Shared/Network/BPTaskDownloadDelegate.swift b/Shared/Network/BPTaskDownloadDelegate.swift index 25f3ae293..5e5978af9 100644 --- a/Shared/Network/BPTaskDownloadDelegate.swift +++ b/Shared/Network/BPTaskDownloadDelegate.swift @@ -8,6 +8,25 @@ import Foundation +/// Errors raised when a download finishes but the resulting file can't be trusted. +/// The associated values carry diagnostic detail (logged at `.warning`); the +/// user-facing `errorDescription` is a single localized, non-technical message. +public enum DownloadError: LocalizedError { + /// The transfer finished without a network error, but fewer bytes were written + /// than the server's `Content-Length`, i.e. a truncated/botched file. + case incompleteDownload(received: Int64, expected: Int64) + /// The downloaded file's real audio duration is meaningfully shorter than the + /// duration we expected from the sync metadata. + case durationMismatch(expected: Double, actual: Double) + /// The duration couldn't be read from the downloaded file at all, despite a + /// known-good synced duration — a strong truncation/corruption signal. + case durationUnreadable + + public var errorDescription: String? { + return "download_incomplete_error".localized + } +} + public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { /// Callback triggered when the download task is finished public var didFinishDownloadingTask: ((URLSessionTask, URL?, Error?) -> Void)? @@ -25,7 +44,7 @@ public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { didFinishDownloadingTask?( downloadTask, location, - parseErrorFromTask(downloadTask) + parseErrorFromTask(downloadTask) ?? verifyDownloadIsComplete(downloadTask) ) } @@ -56,6 +75,24 @@ public class BPTaskDownloadDelegate: NSObject, URLSessionDownloadDelegate { } } + /// Verifies the bytes written to disk match the `Content-Length` the server + /// reported. Background downloads can finish "successfully" with a truncated + /// body (interrupted transfer, early connection close) and `URLSession` won't + /// flag it, so we treat a short file as an error instead of accepting it. + private func verifyDownloadIsComplete(_ task: URLSessionTask) -> Error? { + let expectedBytes = task.countOfBytesExpectedToReceive + /// `NSURLSessionTransferSizeUnknown` (-1) means the server didn't send a + /// Content-Length; we can't validate the size, so don't block the download. + guard expectedBytes != NSURLSessionTransferSizeUnknown else { return nil } + + let receivedBytes = task.countOfBytesReceived + guard receivedBytes >= expectedBytes else { + return DownloadError.incompleteDownload(received: receivedBytes, expected: expectedBytes) + } + + return nil + } + private func parseErrorFromTask(_ task: URLSessionTask) -> Error? { guard let response = task.response as? HTTPURLResponse, diff --git a/Shared/Services/LibraryService+Sync.swift b/Shared/Services/LibraryService+Sync.swift index ebc354d11..7e03fa99d 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -39,6 +39,9 @@ public protocol LibrarySyncProtocol { func updateLibraryLastBook(with relativePath: String?) async /// Returns boolean determining if the item exists for the relativePath func itemExists(for relativePath: String) async -> Bool + /// Fetch the stored (synced) duration for the item, read on a background context + /// so it's safe to call off the main thread (e.g. from a download delegate queue). + func getItemDuration(at relativePath: String) async -> Double? /// Load encoded chapters from file into DB func loadChaptersIfNeeded(relativePath: String) async @@ -85,6 +88,21 @@ extension LibraryService: LibrarySyncProtocol { } } + public func getItemDuration(at relativePath: String) async -> Double? { + return await withCheckedContinuation { continuation in + let context = dataManager.getBackgroundContext() + context.perform { [unowned self, context] in + let duration = self.getItemProperty( + #keyPath(LibraryItem.duration), + relativePath: relativePath, + context: context + ) as? Double + + continuation.resume(returning: duration) + } + } + } + public func updateInfo(for itemsDict: [String: SyncableItem], parentFolder: String?) async { return await withCheckedContinuation { continuation in let context = dataManager.getBackgroundContext() diff --git a/Shared/Services/Sync/SyncService.swift b/Shared/Services/Sync/SyncService.swift index b9ea20c80..5622c8a38 100644 --- a/Shared/Services/Sync/SyncService.swift +++ b/Shared/Services/Sync/SyncService.swift @@ -6,6 +6,7 @@ // Copyright © 2022 BookPlayer LLC. All rights reserved. // +import AVFoundation import Combine import Foundation @@ -703,55 +704,146 @@ extension SyncService { ) { guard let relativePath = task.taskDescription else { return } - do { - if error == nil, - let location - { - let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + var finalError = error + var movedFileURL: URL? + + if error == nil, + let location + { + let fileURL = DataManager.getProcessedFolderURL().appendingPathComponent(relativePath) + do { /// If there's already something there, replace with new finished download if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } try DataManager.createContainingFolderIfNeeded(for: fileURL) try FileManager.default.moveItem(at: location, to: fileURL) - - Task { - await self.libraryService.loadChaptersIfNeeded(relativePath: relativePath) - } + movedFileURL = fileURL + } catch { + finalError = error + Self.logger.warning("Error moving downloaded file to the destination: \(error.localizedDescription)") } - } catch { - Self.logger.trace("Error moving downloaded file to the destination: \(error.localizedDescription)") } - if let error { - DispatchQueue.main.async { - self.downloadErrorPublisher.send((relativePath, error)) - } + /// Capture and clear the per-task bookkeeping synchronously (we're on the + /// delegate queue). The completion event is emitted later, only after the + /// file is verified — see `finalizeDownloadedFile`. + let startingItemPath = ongoingTasksParentReference[relativePath] + let parentFolderPath = initiatingFolderReference[relativePath] + if let startingItemPath, + downloadTasksDictionary[startingItemPath]? + .filter({ $0 != task }) + .allSatisfy({ $0.state == .completed }) == true + { + downloadTasksDictionary[startingItemPath] = nil } + ongoingTasksParentReference[relativePath] = nil + initiatingFolderReference[relativePath] = nil - guard let startingItemPath = ongoingTasksParentReference[relativePath] else { - initiatingFolderReference[relativePath] = nil + /// The download itself failed (network/move error): surface it and stop. We + /// never emit a completion event for a failed download. + if let finalError { + DispatchQueue.main.async { + self.downloadErrorPublisher.send((relativePath, finalError)) + } return } - let parentFolderPath = initiatingFolderReference[relativePath] + /// Second delegate callback (`didCompleteWithError` with no location) for a + /// download that already finished: nothing to move, nothing to announce. + guard let movedFileURL else { return } - /// cleanup individual reference - if downloadTasksDictionary[startingItemPath]? - .filter({ $0 != task }) - .allSatisfy({ $0.state == .completed }) == true - { - downloadTasksDictionary[startingItemPath] = nil + Task { + await self.finalizeDownloadedFile( + relativePath: relativePath, + fileURL: movedFileURL, + startingItemPath: startingItemPath, + parentFolderPath: parentFolderPath + ) + } + } + + /// Loads chapters, validates the downloaded file, and only then announces + /// completion. Splitting this out (and gating the completion event on the + /// validation result) ensures a truncated file is never broadcast as + /// `.downloaded` before being discarded. + private func finalizeDownloadedFile( + relativePath: String, + fileURL: URL, + startingItemPath: String?, + parentFolderPath: String? + ) async { + await libraryService.loadChaptersIfNeeded(relativePath: relativePath) + + /// Read on a background context (off the main/view context) — this runs on + /// the download delegate's queue / a detached task. + let expectedDuration = await libraryService.getItemDuration(at: relativePath) + + if let verificationError = await verifyDownloadedFile( + relativePath: relativePath, + fileURL: fileURL, + expectedDuration: expectedDuration + ) { + DispatchQueue.main.async { + self.downloadErrorPublisher.send((relativePath, verificationError)) + } + return } - ongoingTasksParentReference[relativePath] = nil - initiatingFolderReference[relativePath] = nil + /// Only announce completion once the file is verified, and only for a tracked + /// (user-initiated) download — restored/untracked tasks just get validated. + guard let startingItemPath else { return } DispatchQueue.main.async { self.downloadCompletedPublisher.send((relativePath, startingItemPath, parentFolderPath)) } } + /// Backstop against truncated/botched downloads that finish without surfacing a + /// network error (e.g. the connection closed early, or watchOS suspended the + /// background transfer). `URLSession` won't flag these, so a short file would be + /// promoted as a fully-downloaded book and silently cut playback off partway + /// through. Returns a non-nil error (and deletes the file) when the download is + /// rejected; `nil` when the file is acceptable or can't be validated. + private func verifyDownloadedFile( + relativePath: String, + fileURL: URL, + expectedDuration: Double? + ) async -> Error? { + /// No trustworthy reference duration (item not yet stored, or a duration of 0) + /// means we can't validate — leave the download in place. + guard let expectedDuration, expectedDuration > 0 else { return nil } + + let actualDuration: Double + do { + let asset = AVURLAsset(url: fileURL) + actualDuration = CMTimeGetSeconds(try await asset.load(.duration)) + } catch { + /// We have a trustworthy synced duration, so this is a format we can play — + /// a complete file would be readable. A load failure on a fresh download is + /// a strong truncation/corruption signal (e.g. a cut-off AAC/m4b missing its + /// `moov` atom), which is exactly the case a byte/duration check would + /// otherwise miss. Reject it. + try? FileManager.default.removeItem(at: fileURL) + Self.logger.warning( + "Discarding download for \(relativePath); duration unreadable (likely truncated): \(error.localizedDescription)" + ) + return DownloadError.durationUnreadable + } + + guard actualDuration.isFinite else { return nil } + + /// Allow a small tolerance for container/encoder rounding differences. + let tolerance = max(2, expectedDuration * 0.02) + guard expectedDuration - actualDuration > tolerance else { return nil } + + try? FileManager.default.removeItem(at: fileURL) + Self.logger.warning( + "Discarding truncated download for \(relativePath): expected \(expectedDuration)s, got \(actualDuration)s" + ) + return DownloadError.durationMismatch(expected: expectedDuration, actual: actualDuration) + } + public func cancelDownload(of item: SimpleLibraryItem) throws { guard let tasks = downloadTasksDictionary[item.relativePath] else { return }