From 5866efc9c9bb3db59917f055d53a641eb76288cf Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Mon, 29 Jun 2026 00:30:30 -0500 Subject: [PATCH 1/3] Add download verification for Watch app --- Shared/Network/BPTaskDownloadDelegate.swift | 39 ++++++++++++++- Shared/Services/LibraryService+Sync.swift | 2 + Shared/Services/Sync/SyncService.swift | 53 +++++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/Shared/Network/BPTaskDownloadDelegate.swift b/Shared/Network/BPTaskDownloadDelegate.swift index 25f3ae293..1ba7e8ca8 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. +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) + + public var errorDescription: String? { + switch self { + case let .incompleteDownload(received, expected): + return "The download was incomplete (\(received)/\(expected) bytes). Please try again." + case let .durationMismatch(expected, actual): + return "The downloaded file was incomplete (\(Int(actual))s of \(Int(expected))s). Please try again." + } + } +} + 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..2afcf272d 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -39,6 +39,8 @@ 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 a single stored item (with its expected `duration`) for the relativePath + func getSimpleItem(with relativePath: String) -> SimpleLibraryItem? /// Load encoded chapters from file into DB func loadChaptersIfNeeded(relativePath: String) async diff --git a/Shared/Services/Sync/SyncService.swift b/Shared/Services/Sync/SyncService.swift index b9ea20c80..f652a088a 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 @@ -716,8 +717,18 @@ extension SyncService { try DataManager.createContainingFolderIfNeeded(for: fileURL) try FileManager.default.moveItem(at: location, to: fileURL) + /// Capture the expected duration from the synced metadata *before* loading + /// chapters, so the truncation check below compares against the value the + /// server reported rather than anything derived from the downloaded file. + let expectedDuration = libraryService.getSimpleItem(with: relativePath)?.duration + Task { await self.libraryService.loadChaptersIfNeeded(relativePath: relativePath) + await self.verifyDownloadedFileDuration( + relativePath: relativePath, + fileURL: fileURL, + expectedDuration: expectedDuration + ) } } } catch { @@ -752,6 +763,48 @@ extension SyncService { } } + /// 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. We compare the file's real audio duration against the duration stored + /// from the sync metadata, and discard the file if it falls meaningfully short. + private func verifyDownloadedFileDuration( + relativePath: String, + fileURL: URL, + expectedDuration: Double? + ) async { + /// 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 } + + do { + let asset = AVURLAsset(url: fileURL) + let actualDuration = CMTimeGetSeconds(try await asset.load(.duration)) + + guard actualDuration.isFinite else { return } + + /// Allow a small tolerance for container/encoder rounding differences. + let tolerance = max(2, expectedDuration * 0.02) + guard expectedDuration - actualDuration > tolerance else { return } + + try? FileManager.default.removeItem(at: fileURL) + Self.logger.trace( + "Discarding truncated download for \(relativePath): expected \(expectedDuration)s, got \(actualDuration)s" + ) + DispatchQueue.main.async { + self.downloadErrorPublisher.send(( + relativePath, + DownloadError.durationMismatch(expected: expectedDuration, actual: actualDuration) + )) + } + } catch { + /// If the duration can't be read we don't have enough signal to reject the + /// file; leave it in place rather than deleting a possibly-valid download. + Self.logger.trace("Could not verify duration for \(relativePath): \(error.localizedDescription)") + } + } + public func cancelDownload(of item: SimpleLibraryItem) throws { guard let tasks = downloadTasksDictionary[item.relativePath] else { return } From 2ad2da11db6a9ddedcd4b83657419c3a434fbf02 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Tue, 30 Jun 2026 10:38:39 -0500 Subject: [PATCH 2/3] Address feedback --- BookPlayer/Base.lproj/Localizable.strings | 1 + BookPlayer/ar.lproj/Localizable.strings | 1 + BookPlayer/ca.lproj/Localizable.strings | 1 + BookPlayer/cs.lproj/Localizable.strings | 1 + BookPlayer/da.lproj/Localizable.strings | 1 + BookPlayer/de.lproj/Localizable.strings | 1 + BookPlayer/el.lproj/Localizable.strings | 1 + BookPlayer/en.lproj/Localizable.strings | 1 + BookPlayer/es.lproj/Localizable.strings | 1 + BookPlayer/fi.lproj/Localizable.strings | 1 + BookPlayer/fr.lproj/Localizable.strings | 1 + BookPlayer/hu.lproj/Localizable.strings | 1 + BookPlayer/it.lproj/Localizable.strings | 1 + BookPlayer/ja.lproj/Localizable.strings | 1 + BookPlayer/nb.lproj/Localizable.strings | 1 + BookPlayer/nl.lproj/Localizable.strings | 1 + BookPlayer/pl.lproj/Localizable.strings | 1 + BookPlayer/pt-BR.lproj/Localizable.strings | 1 + BookPlayer/pt-PT.lproj/Localizable.strings | 1 + BookPlayer/ro.lproj/Localizable.strings | 1 + BookPlayer/ru.lproj/Localizable.strings | 1 + BookPlayer/sk-SK.lproj/Localizable.strings | 1 + BookPlayer/sv.lproj/Localizable.strings | 1 + BookPlayer/tr.lproj/Localizable.strings | 1 + BookPlayer/uk.lproj/Localizable.strings | 1 + BookPlayer/zh-Hans.lproj/Localizable.strings | 1 + .../RemoteItemCellViewModel.swift | 14 ++ Shared/Network/BPTaskDownloadDelegate.swift | 12 +- Shared/Services/LibraryService+Sync.swift | 20 ++- Shared/Services/Sync/SyncService.swift | 159 +++++++++++------- 30 files changed, 163 insertions(+), 68 deletions(-) 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/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 1ba7e8ca8..5e5978af9 100644 --- a/Shared/Network/BPTaskDownloadDelegate.swift +++ b/Shared/Network/BPTaskDownloadDelegate.swift @@ -9,6 +9,8 @@ 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. @@ -16,14 +18,12 @@ public enum DownloadError: LocalizedError { /// 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? { - switch self { - case let .incompleteDownload(received, expected): - return "The download was incomplete (\(received)/\(expected) bytes). Please try again." - case let .durationMismatch(expected, actual): - return "The downloaded file was incomplete (\(Int(actual))s of \(Int(expected))s). Please try again." - } + return "download_incomplete_error".localized } } diff --git a/Shared/Services/LibraryService+Sync.swift b/Shared/Services/LibraryService+Sync.swift index 2afcf272d..7e03fa99d 100644 --- a/Shared/Services/LibraryService+Sync.swift +++ b/Shared/Services/LibraryService+Sync.swift @@ -39,8 +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 a single stored item (with its expected `duration`) for the relativePath - func getSimpleItem(with relativePath: String) -> SimpleLibraryItem? + /// 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 @@ -87,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 f652a088a..5622c8a38 100644 --- a/Shared/Services/Sync/SyncService.swift +++ b/Shared/Services/Sync/SyncService.swift @@ -704,60 +704,96 @@ 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) - - /// Capture the expected duration from the synced metadata *before* loading - /// chapters, so the truncation check below compares against the value the - /// server reported rather than anything derived from the downloaded file. - let expectedDuration = libraryService.getSimpleItem(with: relativePath)?.duration - - Task { - await self.libraryService.loadChaptersIfNeeded(relativePath: relativePath) - await self.verifyDownloadedFileDuration( - relativePath: relativePath, - fileURL: fileURL, - expectedDuration: expectedDuration - ) - } + 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 { + /// 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 + + /// 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, error)) + self.downloadErrorPublisher.send((relativePath, finalError)) } + return } - guard let startingItemPath = ongoingTasksParentReference[relativePath] else { - initiatingFolderReference[relativePath] = nil - return + /// Second delegate callback (`didCompleteWithError` with no location) for a + /// download that already finished: nothing to move, nothing to announce. + guard let movedFileURL else { return } + + Task { + await self.finalizeDownloadedFile( + relativePath: relativePath, + fileURL: movedFileURL, + startingItemPath: startingItemPath, + parentFolderPath: parentFolderPath + ) } + } - let parentFolderPath = initiatingFolderReference[relativePath] + /// 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) - /// cleanup individual reference - if downloadTasksDictionary[startingItemPath]? - .filter({ $0 != task }) - .allSatisfy({ $0.state == .completed }) == true - { - downloadTasksDictionary[startingItemPath] = nil + /// 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)) } @@ -767,42 +803,45 @@ extension SyncService { /// 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. We compare the file's real audio duration against the duration stored - /// from the sync metadata, and discard the file if it falls meaningfully short. - private func verifyDownloadedFileDuration( + /// 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 { + ) 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 } + guard let expectedDuration, expectedDuration > 0 else { return nil } + let actualDuration: Double do { let asset = AVURLAsset(url: fileURL) - let actualDuration = CMTimeGetSeconds(try await asset.load(.duration)) - - guard actualDuration.isFinite else { return } - - /// Allow a small tolerance for container/encoder rounding differences. - let tolerance = max(2, expectedDuration * 0.02) - guard expectedDuration - actualDuration > tolerance else { return } - + 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.trace( - "Discarding truncated download for \(relativePath): expected \(expectedDuration)s, got \(actualDuration)s" + Self.logger.warning( + "Discarding download for \(relativePath); duration unreadable (likely truncated): \(error.localizedDescription)" ) - DispatchQueue.main.async { - self.downloadErrorPublisher.send(( - relativePath, - DownloadError.durationMismatch(expected: expectedDuration, actual: actualDuration) - )) - } - } catch { - /// If the duration can't be read we don't have enough signal to reject the - /// file; leave it in place rather than deleting a possibly-valid download. - Self.logger.trace("Could not verify duration for \(relativePath): \(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 { From 8fa718cccfcbf2b3242099d82248bd76b77e55d2 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Tue, 30 Jun 2026 13:35:55 -0500 Subject: [PATCH 3/3] Address feedback --- .../Library/ItemList/Views/ItemArtworkView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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