diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index bb5b5617..ea0def15 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -504,6 +504,30 @@ extension DownloadDatabaseOperations on AppDatabase { return rows.map((row) => row.readTable(downloadQueue)).toList(growable: false); } + /// Fails every queued or active download after the target filesystem fills. + /// + /// Clearing native task ids and queue rows prevents startup recovery from + /// immediately re-enqueueing work after partial files have been discarded. + Future failActiveDownloadsForStorageFull(String errorMessage) { + return transaction(() async { + final active = await (select( + downloadedMedia, + )..where((row) => row.status.isIn([DownloadStatus.queued.index, DownloadStatus.downloading.index]))).get(); + if (active.isEmpty) return 0; + + final globalKeys = active.map((item) => item.globalKey).toList(growable: false); + await (update(downloadedMedia)..where((row) => row.globalKey.isIn(globalKeys))).write( + DownloadedMediaCompanion( + status: Value(DownloadStatus.failed.index), + bgTaskId: const Value(null), + errorMessage: Value(errorMessage), + ), + ); + await (delete(downloadQueue)..where((row) => row.mediaGlobalKey.isIn(globalKeys))).go(); + return globalKeys.length; + }); + } + Future updateDownloadStatus(String globalKey, int status) async { await (update( downloadedMedia, diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 94d4ec4f..3b9fe903 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Изтеглянето е добавено в опашката", "downloadResumed": "Изтеглянето е възобновено", "serverErrorBitrate": "Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт", + "storageFull": "Изтеглянията бяха спрени, защото паметта на устройството е пълна. Освободете място и опитайте отново.", "episodesQueued": "${count} епизода са добавени в опашката за изтегляне", "downloadDeleted": "Изтеглянето е изтрито", "deleteConfirm": "Да се изтрие ли \"${title}\" от това устройство?", diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index 42cd3a4a..d4e243ac 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download i kø", "downloadResumed": "Download genoptaget", "serverErrorBitrate": "Serverfejl: filen overskrider muligvis grænsen for ekstern bitrate", + "storageFull": "Downloads blev stoppet, fordi enhedens lagerplads er fuld. Frigør plads, og prøv igen.", "episodesQueued": "${count} episoder i downloadkø", "downloadDeleted": "Download slettet", "deleteConfirm": "Slet \"${title}\" fra denne enhed?", diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 8398110b..c858106d 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download in Warteschlange", "downloadResumed": "Download fortgesetzt", "serverErrorBitrate": "Serverfehler: Datei überschreitet möglicherweise das Remote-Bitrate-Limit", + "storageFull": "Die Downloads wurden angehalten, weil der Gerätespeicher voll ist. Gib Speicherplatz frei und versuche es erneut.", "episodesQueued": "${count} Episoden zum Download hinzugefügt", "downloadDeleted": "Download gelöscht", "deleteConfirm": "\"${title}\" von diesem Gerät löschen?", diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 6ccbb3b5..706201c7 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download queued", "downloadResumed": "Download resumed", "serverErrorBitrate": "Server error: file may exceed the remote bitrate limit", + "storageFull": "Downloads stopped because device storage is full. Free some space, then retry.", "episodesQueued": "${count} episodes queued for download", "downloadDeleted": "Download deleted", "deleteConfirm": "Delete \"${title}\" from this device?", diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 83601554..4738c1b9 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Descarga en cola", "downloadResumed": "Descarga reanudada", "serverErrorBitrate": "Error del servidor: el archivo puede superar el límite remoto de tasa de bits", + "storageFull": "Las descargas se detuvieron porque el almacenamiento del dispositivo está lleno. Libera espacio e inténtalo de nuevo.", "episodesQueued": "${count} episodios en cola para descargar", "downloadDeleted": "Descarga eliminada", "deleteConfirm": "¿Eliminar \"${title}\" de este dispositivo?", diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index f9e2f855..58b1d5e2 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Téléchargement en attente", "downloadResumed": "Téléchargement repris", "serverErrorBitrate": "Erreur du serveur : le fichier peut dépasser la limite de débit distant", + "storageFull": "Les téléchargements ont été arrêtés car le stockage de l’appareil est plein. Libérez de l’espace, puis réessayez.", "episodesQueued": "${count} épisodes en attente de téléchargement", "downloadDeleted": "Téléchargement supprimé", "deleteConfirm": "Supprimer « ${title} » de cet appareil ?", diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index 0d9288ae..5d2a9be6 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Letöltés sorba állítva", "downloadResumed": "Letöltés folytatva", "serverErrorBitrate": "Szerverhiba: a fájl meghaladhatja a távoli bitrátakorlátot", + "storageFull": "A letöltések leálltak, mert az eszköz tárhelye megtelt. Szabadíts fel helyet, majd próbáld újra.", "episodesQueued": "${count} epizód letöltésre sorba állítva", "downloadDeleted": "Letöltés törölve", "deleteConfirm": "Törlöd a következőt: \"${title}\" erről az eszközről?", diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 083ac044..efde9705 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download in coda", "downloadResumed": "Download ripreso", "serverErrorBitrate": "Errore server: il file può superare il limite di bitrate remoto", + "storageFull": "I download sono stati interrotti perché lo spazio di archiviazione del dispositivo è esaurito. Libera spazio e riprova.", "episodesQueued": "${count} episodi in coda per il download", "downloadDeleted": "Download eliminato", "deleteConfirm": "Eliminare \"${title}\" da questo dispositivo?", diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 798fb8e7..e7201703 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1148,6 +1148,7 @@ "downloadQueued": "ダウンロードをキューに追加しました", "downloadResumed": "ダウンロードを再開しました", "serverErrorBitrate": "サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります", + "storageFull": "デバイスのストレージがいっぱいのため、ダウンロードを停止しました。空き容量を確保してから、もう一度お試しください。", "episodesQueued": "${count}エピソードをダウンロードキューに追加しました", "downloadDeleted": "ダウンロードを削除しました", "deleteConfirm": "このデバイスから「${title}」を削除しますか?", diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index 087ab622..51fa5910 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1148,6 +1148,7 @@ "downloadQueued": "다운로드 대기 중", "downloadResumed": "다운로드를 재개했습니다", "serverErrorBitrate": "서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다", + "storageFull": "기기 저장 공간이 가득 차서 다운로드를 중지했습니다. 공간을 확보한 후 다시 시도하세요.", "episodesQueued": "에피소드 ${count}개가 다운로드 대기열에 추가되었습니다", "downloadDeleted": "다운로드 삭제됨", "deleteConfirm": "이 기기에서 \"${title}\"을(를) 삭제할까요?", diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 995f7626..adda5a22 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Nedlasting i kø", "downloadResumed": "Nedlasting gjenopptatt", "serverErrorBitrate": "Serverfeil: filen kan overskride grensen for ekstern bitrate", + "storageFull": "Nedlastingene ble stoppet fordi lagringsplassen på enheten er full. Frigjør plass, og prøv igjen.", "episodesQueued": "${count} episoder i nedlastingskø", "downloadDeleted": "Nedlasting slettet", "deleteConfirm": "Slette \"${title}\" fra denne enheten?", diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 82061efb..d3da6d64 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download in wachtrij", "downloadResumed": "Download hervat", "serverErrorBitrate": "Serverfout: bestand overschrijdt mogelijk de externe bitrate-limiet", + "storageFull": "Downloads zijn gestopt omdat de opslag van het apparaat vol is. Maak ruimte vrij en probeer het opnieuw.", "episodesQueued": "${count} afleveringen in wachtrij voor download", "downloadDeleted": "Download verwijderd", "deleteConfirm": "\"${title}\" van dit apparaat verwijderen?", diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 33def8f2..02ff88d2 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1157,6 +1157,7 @@ "downloadQueued": "Pobranie w kolejce", "downloadResumed": "Pobieranie wznowione", "serverErrorBitrate": "Błąd serwera: plik może przekraczać zdalny limit bitrate", + "storageFull": "Pobieranie zostało zatrzymane, ponieważ pamięć urządzenia jest pełna. Zwolnij miejsce i spróbuj ponownie.", "episodesQueued": "${count} odcinków w kolejce pobierania", "downloadDeleted": "Pobranie usunięte", "deleteConfirm": "Usunąć \"${title}\" z tego urządzenia?", diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 5e0aaeac..872ab8cd 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Download na fila", "downloadResumed": "Download retomado", "serverErrorBitrate": "Erro do servidor: o arquivo pode exceder o limite remoto de taxa de bits", + "storageFull": "Os downloads foram interrompidos porque o armazenamento do dispositivo está cheio. Libere espaço e tente novamente.", "episodesQueued": "${count} episódios na fila de download", "downloadDeleted": "Download excluído", "deleteConfirm": "Excluir \"${title}\" deste dispositivo?", diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index bbb8c243..5802f8fa 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1157,6 +1157,7 @@ "downloadQueued": "Загрузка поставлена в очередь", "downloadResumed": "Загрузка возобновлена", "serverErrorBitrate": "Ошибка сервера: файл может превышать установленное на сервере ограничение битрейта", + "storageFull": "Загрузки остановлены, так как хранилище устройства заполнено. Освободите место и повторите попытку.", "episodesQueued": "${count} эпизодов поставлено в очередь загрузки", "downloadDeleted": "Загрузка удалена", "deleteConfirm": "Удалить \"${title}\" с этого устройства?", diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index d0e3d271..eab07bb8 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,7 +4,7 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 18 -/// Strings: 26028 (1446 per locale) +/// Strings: 26046 (1447 per locale) // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_bg.g.dart b/lib/i18n/strings_bg.g.dart index c6bb4026..8b9c2333 100644 --- a/lib/i18n/strings_bg.g.dart +++ b/lib/i18n/strings_bg.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$bg extends Translations$downloads$en { @override String get downloadQueued => 'Изтеглянето е добавено в опашката'; @override String get downloadResumed => 'Изтеглянето е възобновено'; @override String get serverErrorBitrate => 'Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт'; + @override String get storageFull => 'Изтеглянията бяха спрени, защото паметта на устройството е пълна. Освободете място и опитайте отново.'; @override String episodesQueued({required Object count}) => '${count} епизода са добавени в опашката за изтегляне'; @override String get downloadDeleted => 'Изтеглянето е изтрито'; @override String deleteConfirm({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?'; @@ -3208,6 +3209,7 @@ extension on TranslationsBg { 'downloads.downloadQueued' => 'Изтеглянето е добавено в опашката', 'downloads.downloadResumed' => 'Изтеглянето е възобновено', 'downloads.serverErrorBitrate' => 'Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт', + 'downloads.storageFull' => 'Изтеглянията бяха спрени, защото паметта на устройството е пълна. Освободете място и опитайте отново.', 'downloads.episodesQueued' => ({required Object count}) => '${count} епизода са добавени в опашката за изтегляне', 'downloads.downloadDeleted' => 'Изтеглянето е изтрито', 'downloads.deleteConfirm' => ({required Object title}) => 'Да се изтрие ли "${title}" от това устройство?', diff --git a/lib/i18n/strings_da.g.dart b/lib/i18n/strings_da.g.dart index f4e6d013..98b1c9d7 100644 --- a/lib/i18n/strings_da.g.dart +++ b/lib/i18n/strings_da.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$da extends Translations$downloads$en { @override String get downloadQueued => 'Download i kø'; @override String get downloadResumed => 'Download genoptaget'; @override String get serverErrorBitrate => 'Serverfejl: filen overskrider muligvis grænsen for ekstern bitrate'; + @override String get storageFull => 'Downloads blev stoppet, fordi enhedens lagerplads er fuld. Frigør plads, og prøv igen.'; @override String episodesQueued({required Object count}) => '${count} episoder i downloadkø'; @override String get downloadDeleted => 'Download slettet'; @override String deleteConfirm({required Object title}) => 'Slet "${title}" fra denne enhed?'; @@ -3208,6 +3209,7 @@ extension on TranslationsDa { 'downloads.downloadQueued' => 'Download i kø', 'downloads.downloadResumed' => 'Download genoptaget', 'downloads.serverErrorBitrate' => 'Serverfejl: filen overskrider muligvis grænsen for ekstern bitrate', + 'downloads.storageFull' => 'Downloads blev stoppet, fordi enhedens lagerplads er fuld. Frigør plads, og prøv igen.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i downloadkø', 'downloads.downloadDeleted' => 'Download slettet', 'downloads.deleteConfirm' => ({required Object title}) => 'Slet "${title}" fra denne enhed?', diff --git a/lib/i18n/strings_de.g.dart b/lib/i18n/strings_de.g.dart index 83dde280..818f1808 100644 --- a/lib/i18n/strings_de.g.dart +++ b/lib/i18n/strings_de.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$de extends Translations$downloads$en { @override String get downloadQueued => 'Download in Warteschlange'; @override String get downloadResumed => 'Download fortgesetzt'; @override String get serverErrorBitrate => 'Serverfehler: Datei überschreitet möglicherweise das Remote-Bitrate-Limit'; + @override String get storageFull => 'Die Downloads wurden angehalten, weil der Gerätespeicher voll ist. Gib Speicherplatz frei und versuche es erneut.'; @override String episodesQueued({required Object count}) => '${count} Episoden zum Download hinzugefügt'; @override String get downloadDeleted => 'Download gelöscht'; @override String deleteConfirm({required Object title}) => '"${title}" von diesem Gerät löschen?'; @@ -3208,6 +3209,7 @@ extension on TranslationsDe { 'downloads.downloadQueued' => 'Download in Warteschlange', 'downloads.downloadResumed' => 'Download fortgesetzt', 'downloads.serverErrorBitrate' => 'Serverfehler: Datei überschreitet möglicherweise das Remote-Bitrate-Limit', + 'downloads.storageFull' => 'Die Downloads wurden angehalten, weil der Gerätespeicher voll ist. Gib Speicherplatz frei und versuche es erneut.', 'downloads.episodesQueued' => ({required Object count}) => '${count} Episoden zum Download hinzugefügt', 'downloads.downloadDeleted' => 'Download gelöscht', 'downloads.deleteConfirm' => ({required Object title}) => '"${title}" von diesem Gerät löschen?', diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index db691140..3653c15d 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -3330,6 +3330,9 @@ class Translations$downloads$en { /// en: 'Server error: file may exceed the remote bitrate limit' String get serverErrorBitrate => 'Server error: file may exceed the remote bitrate limit'; + /// en: 'Downloads stopped because device storage is full. Free some space, then retry.' + String get storageFull => 'Downloads stopped because device storage is full. Free some space, then retry.'; + /// en: '${count} episodes queued for download' String episodesQueued({required Object count}) => '${count} episodes queued for download'; @@ -6101,6 +6104,7 @@ extension on Translations { 'downloads.downloadQueued' => 'Download queued', 'downloads.downloadResumed' => 'Download resumed', 'downloads.serverErrorBitrate' => 'Server error: file may exceed the remote bitrate limit', + 'downloads.storageFull' => 'Downloads stopped because device storage is full. Free some space, then retry.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodes queued for download', 'downloads.downloadDeleted' => 'Download deleted', 'downloads.deleteConfirm' => ({required Object title}) => 'Delete "${title}" from this device?', diff --git a/lib/i18n/strings_es.g.dart b/lib/i18n/strings_es.g.dart index 1132c77b..3a671056 100644 --- a/lib/i18n/strings_es.g.dart +++ b/lib/i18n/strings_es.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$es extends Translations$downloads$en { @override String get downloadQueued => 'Descarga en cola'; @override String get downloadResumed => 'Descarga reanudada'; @override String get serverErrorBitrate => 'Error del servidor: el archivo puede superar el límite remoto de tasa de bits'; + @override String get storageFull => 'Las descargas se detuvieron porque el almacenamiento del dispositivo está lleno. Libera espacio e inténtalo de nuevo.'; @override String episodesQueued({required Object count}) => '${count} episodios en cola para descargar'; @override String get downloadDeleted => 'Descarga eliminada'; @override String deleteConfirm({required Object title}) => '¿Eliminar "${title}" de este dispositivo?'; @@ -3208,6 +3209,7 @@ extension on TranslationsEs { 'downloads.downloadQueued' => 'Descarga en cola', 'downloads.downloadResumed' => 'Descarga reanudada', 'downloads.serverErrorBitrate' => 'Error del servidor: el archivo puede superar el límite remoto de tasa de bits', + 'downloads.storageFull' => 'Las descargas se detuvieron porque el almacenamiento del dispositivo está lleno. Libera espacio e inténtalo de nuevo.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodios en cola para descargar', 'downloads.downloadDeleted' => 'Descarga eliminada', 'downloads.deleteConfirm' => ({required Object title}) => '¿Eliminar "${title}" de este dispositivo?', diff --git a/lib/i18n/strings_fr.g.dart b/lib/i18n/strings_fr.g.dart index 8c5c2706..c076d4c1 100644 --- a/lib/i18n/strings_fr.g.dart +++ b/lib/i18n/strings_fr.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$fr extends Translations$downloads$en { @override String get downloadQueued => 'Téléchargement en attente'; @override String get downloadResumed => 'Téléchargement repris'; @override String get serverErrorBitrate => 'Erreur du serveur : le fichier peut dépasser la limite de débit distant'; + @override String get storageFull => 'Les téléchargements ont été arrêtés car le stockage de l’appareil est plein. Libérez de l’espace, puis réessayez.'; @override String episodesQueued({required Object count}) => '${count} épisodes en attente de téléchargement'; @override String get downloadDeleted => 'Téléchargement supprimé'; @override String deleteConfirm({required Object title}) => 'Supprimer « ${title} » de cet appareil ?'; @@ -3208,6 +3209,7 @@ extension on TranslationsFr { 'downloads.downloadQueued' => 'Téléchargement en attente', 'downloads.downloadResumed' => 'Téléchargement repris', 'downloads.serverErrorBitrate' => 'Erreur du serveur : le fichier peut dépasser la limite de débit distant', + 'downloads.storageFull' => 'Les téléchargements ont été arrêtés car le stockage de l’appareil est plein. Libérez de l’espace, puis réessayez.', 'downloads.episodesQueued' => ({required Object count}) => '${count} épisodes en attente de téléchargement', 'downloads.downloadDeleted' => 'Téléchargement supprimé', 'downloads.deleteConfirm' => ({required Object title}) => 'Supprimer « ${title} » de cet appareil ?', diff --git a/lib/i18n/strings_hu.g.dart b/lib/i18n/strings_hu.g.dart index 8fa59d36..81189219 100644 --- a/lib/i18n/strings_hu.g.dart +++ b/lib/i18n/strings_hu.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$hu extends Translations$downloads$en { @override String get downloadQueued => 'Letöltés sorba állítva'; @override String get downloadResumed => 'Letöltés folytatva'; @override String get serverErrorBitrate => 'Szerverhiba: a fájl meghaladhatja a távoli bitrátakorlátot'; + @override String get storageFull => 'A letöltések leálltak, mert az eszköz tárhelye megtelt. Szabadíts fel helyet, majd próbáld újra.'; @override String episodesQueued({required Object count}) => '${count} epizód letöltésre sorba állítva'; @override String get downloadDeleted => 'Letöltés törölve'; @override String deleteConfirm({required Object title}) => 'Törlöd a következőt: "${title}" erről az eszközről?'; @@ -3208,6 +3209,7 @@ extension on TranslationsHu { 'downloads.downloadQueued' => 'Letöltés sorba állítva', 'downloads.downloadResumed' => 'Letöltés folytatva', 'downloads.serverErrorBitrate' => 'Szerverhiba: a fájl meghaladhatja a távoli bitrátakorlátot', + 'downloads.storageFull' => 'A letöltések leálltak, mert az eszköz tárhelye megtelt. Szabadíts fel helyet, majd próbáld újra.', 'downloads.episodesQueued' => ({required Object count}) => '${count} epizód letöltésre sorba állítva', 'downloads.downloadDeleted' => 'Letöltés törölve', 'downloads.deleteConfirm' => ({required Object title}) => 'Törlöd a következőt: "${title}" erről az eszközről?', diff --git a/lib/i18n/strings_it.g.dart b/lib/i18n/strings_it.g.dart index 7dd3bc7b..9e50169c 100644 --- a/lib/i18n/strings_it.g.dart +++ b/lib/i18n/strings_it.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$it extends Translations$downloads$en { @override String get downloadQueued => 'Download in coda'; @override String get downloadResumed => 'Download ripreso'; @override String get serverErrorBitrate => 'Errore server: il file può superare il limite di bitrate remoto'; + @override String get storageFull => 'I download sono stati interrotti perché lo spazio di archiviazione del dispositivo è esaurito. Libera spazio e riprova.'; @override String episodesQueued({required Object count}) => '${count} episodi in coda per il download'; @override String get downloadDeleted => 'Download eliminato'; @override String deleteConfirm({required Object title}) => 'Eliminare "${title}" da questo dispositivo?'; @@ -3208,6 +3209,7 @@ extension on TranslationsIt { 'downloads.downloadQueued' => 'Download in coda', 'downloads.downloadResumed' => 'Download ripreso', 'downloads.serverErrorBitrate' => 'Errore server: il file può superare il limite di bitrate remoto', + 'downloads.storageFull' => 'I download sono stati interrotti perché lo spazio di archiviazione del dispositivo è esaurito. Libera spazio e riprova.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episodi in coda per il download', 'downloads.downloadDeleted' => 'Download eliminato', 'downloads.deleteConfirm' => ({required Object title}) => 'Eliminare "${title}" da questo dispositivo?', diff --git a/lib/i18n/strings_ja.g.dart b/lib/i18n/strings_ja.g.dart index 6adae22f..3fbf4665 100644 --- a/lib/i18n/strings_ja.g.dart +++ b/lib/i18n/strings_ja.g.dart @@ -1397,6 +1397,7 @@ class _Translations$downloads$ja extends Translations$downloads$en { @override String get downloadQueued => 'ダウンロードをキューに追加しました'; @override String get downloadResumed => 'ダウンロードを再開しました'; @override String get serverErrorBitrate => 'サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります'; + @override String get storageFull => 'デバイスのストレージがいっぱいのため、ダウンロードを停止しました。空き容量を確保してから、もう一度お試しください。'; @override String episodesQueued({required Object count}) => '${count}エピソードをダウンロードキューに追加しました'; @override String get downloadDeleted => 'ダウンロードを削除しました'; @override String deleteConfirm({required Object title}) => 'このデバイスから「${title}」を削除しますか?'; @@ -3205,6 +3206,7 @@ extension on TranslationsJa { 'downloads.downloadQueued' => 'ダウンロードをキューに追加しました', 'downloads.downloadResumed' => 'ダウンロードを再開しました', 'downloads.serverErrorBitrate' => 'サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります', + 'downloads.storageFull' => 'デバイスのストレージがいっぱいのため、ダウンロードを停止しました。空き容量を確保してから、もう一度お試しください。', 'downloads.episodesQueued' => ({required Object count}) => '${count}エピソードをダウンロードキューに追加しました', 'downloads.downloadDeleted' => 'ダウンロードを削除しました', 'downloads.deleteConfirm' => ({required Object title}) => 'このデバイスから「${title}」を削除しますか?', diff --git a/lib/i18n/strings_ko.g.dart b/lib/i18n/strings_ko.g.dart index 48690ee6..736981bc 100644 --- a/lib/i18n/strings_ko.g.dart +++ b/lib/i18n/strings_ko.g.dart @@ -1397,6 +1397,7 @@ class _Translations$downloads$ko extends Translations$downloads$en { @override String get downloadQueued => '다운로드 대기 중'; @override String get downloadResumed => '다운로드를 재개했습니다'; @override String get serverErrorBitrate => '서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다'; + @override String get storageFull => '기기 저장 공간이 가득 차서 다운로드를 중지했습니다. 공간을 확보한 후 다시 시도하세요.'; @override String episodesQueued({required Object count}) => '에피소드 ${count}개가 다운로드 대기열에 추가되었습니다'; @override String get downloadDeleted => '다운로드 삭제됨'; @override String deleteConfirm({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?'; @@ -3205,6 +3206,7 @@ extension on TranslationsKo { 'downloads.downloadQueued' => '다운로드 대기 중', 'downloads.downloadResumed' => '다운로드를 재개했습니다', 'downloads.serverErrorBitrate' => '서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다', + 'downloads.storageFull' => '기기 저장 공간이 가득 차서 다운로드를 중지했습니다. 공간을 확보한 후 다시 시도하세요.', 'downloads.episodesQueued' => ({required Object count}) => '에피소드 ${count}개가 다운로드 대기열에 추가되었습니다', 'downloads.downloadDeleted' => '다운로드 삭제됨', 'downloads.deleteConfirm' => ({required Object title}) => '이 기기에서 "${title}"을(를) 삭제할까요?', diff --git a/lib/i18n/strings_nb.g.dart b/lib/i18n/strings_nb.g.dart index 86937a7e..186286b2 100644 --- a/lib/i18n/strings_nb.g.dart +++ b/lib/i18n/strings_nb.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$nb extends Translations$downloads$en { @override String get downloadQueued => 'Nedlasting i kø'; @override String get downloadResumed => 'Nedlasting gjenopptatt'; @override String get serverErrorBitrate => 'Serverfeil: filen kan overskride grensen for ekstern bitrate'; + @override String get storageFull => 'Nedlastingene ble stoppet fordi lagringsplassen på enheten er full. Frigjør plass, og prøv igjen.'; @override String episodesQueued({required Object count}) => '${count} episoder i nedlastingskø'; @override String get downloadDeleted => 'Nedlasting slettet'; @override String deleteConfirm({required Object title}) => 'Slette "${title}" fra denne enheten?'; @@ -3208,6 +3209,7 @@ extension on TranslationsNb { 'downloads.downloadQueued' => 'Nedlasting i kø', 'downloads.downloadResumed' => 'Nedlasting gjenopptatt', 'downloads.serverErrorBitrate' => 'Serverfeil: filen kan overskride grensen for ekstern bitrate', + 'downloads.storageFull' => 'Nedlastingene ble stoppet fordi lagringsplassen på enheten er full. Frigjør plass, og prøv igjen.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episoder i nedlastingskø', 'downloads.downloadDeleted' => 'Nedlasting slettet', 'downloads.deleteConfirm' => ({required Object title}) => 'Slette "${title}" fra denne enheten?', diff --git a/lib/i18n/strings_nl.g.dart b/lib/i18n/strings_nl.g.dart index cb63eac8..fd599c1f 100644 --- a/lib/i18n/strings_nl.g.dart +++ b/lib/i18n/strings_nl.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$nl extends Translations$downloads$en { @override String get downloadQueued => 'Download in wachtrij'; @override String get downloadResumed => 'Download hervat'; @override String get serverErrorBitrate => 'Serverfout: bestand overschrijdt mogelijk de externe bitrate-limiet'; + @override String get storageFull => 'Downloads zijn gestopt omdat de opslag van het apparaat vol is. Maak ruimte vrij en probeer het opnieuw.'; @override String episodesQueued({required Object count}) => '${count} afleveringen in wachtrij voor download'; @override String get downloadDeleted => 'Download verwijderd'; @override String deleteConfirm({required Object title}) => '"${title}" van dit apparaat verwijderen?'; @@ -3208,6 +3209,7 @@ extension on TranslationsNl { 'downloads.downloadQueued' => 'Download in wachtrij', 'downloads.downloadResumed' => 'Download hervat', 'downloads.serverErrorBitrate' => 'Serverfout: bestand overschrijdt mogelijk de externe bitrate-limiet', + 'downloads.storageFull' => 'Downloads zijn gestopt omdat de opslag van het apparaat vol is. Maak ruimte vrij en probeer het opnieuw.', 'downloads.episodesQueued' => ({required Object count}) => '${count} afleveringen in wachtrij voor download', 'downloads.downloadDeleted' => 'Download verwijderd', 'downloads.deleteConfirm' => ({required Object title}) => '"${title}" van dit apparaat verwijderen?', diff --git a/lib/i18n/strings_pl.g.dart b/lib/i18n/strings_pl.g.dart index 95609f0a..0cce99d0 100644 --- a/lib/i18n/strings_pl.g.dart +++ b/lib/i18n/strings_pl.g.dart @@ -1406,6 +1406,7 @@ class _Translations$downloads$pl extends Translations$downloads$en { @override String get downloadQueued => 'Pobranie w kolejce'; @override String get downloadResumed => 'Pobieranie wznowione'; @override String get serverErrorBitrate => 'Błąd serwera: plik może przekraczać zdalny limit bitrate'; + @override String get storageFull => 'Pobieranie zostało zatrzymane, ponieważ pamięć urządzenia jest pełna. Zwolnij miejsce i spróbuj ponownie.'; @override String episodesQueued({required Object count}) => '${count} odcinków w kolejce pobierania'; @override String get downloadDeleted => 'Pobranie usunięte'; @override String deleteConfirm({required Object title}) => 'Usunąć "${title}" z tego urządzenia?'; @@ -3214,6 +3215,7 @@ extension on TranslationsPl { 'downloads.downloadQueued' => 'Pobranie w kolejce', 'downloads.downloadResumed' => 'Pobieranie wznowione', 'downloads.serverErrorBitrate' => 'Błąd serwera: plik może przekraczać zdalny limit bitrate', + 'downloads.storageFull' => 'Pobieranie zostało zatrzymane, ponieważ pamięć urządzenia jest pełna. Zwolnij miejsce i spróbuj ponownie.', 'downloads.episodesQueued' => ({required Object count}) => '${count} odcinków w kolejce pobierania', 'downloads.downloadDeleted' => 'Pobranie usunięte', 'downloads.deleteConfirm' => ({required Object title}) => 'Usunąć "${title}" z tego urządzenia?', diff --git a/lib/i18n/strings_pt.g.dart b/lib/i18n/strings_pt.g.dart index 87df7234..e8a4491c 100644 --- a/lib/i18n/strings_pt.g.dart +++ b/lib/i18n/strings_pt.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$pt extends Translations$downloads$en { @override String get downloadQueued => 'Download na fila'; @override String get downloadResumed => 'Download retomado'; @override String get serverErrorBitrate => 'Erro do servidor: o arquivo pode exceder o limite remoto de taxa de bits'; + @override String get storageFull => 'Os downloads foram interrompidos porque o armazenamento do dispositivo está cheio. Libere espaço e tente novamente.'; @override String episodesQueued({required Object count}) => '${count} episódios na fila de download'; @override String get downloadDeleted => 'Download excluído'; @override String deleteConfirm({required Object title}) => 'Excluir "${title}" deste dispositivo?'; @@ -3208,6 +3209,7 @@ extension on TranslationsPt { 'downloads.downloadQueued' => 'Download na fila', 'downloads.downloadResumed' => 'Download retomado', 'downloads.serverErrorBitrate' => 'Erro do servidor: o arquivo pode exceder o limite remoto de taxa de bits', + 'downloads.storageFull' => 'Os downloads foram interrompidos porque o armazenamento do dispositivo está cheio. Libere espaço e tente novamente.', 'downloads.episodesQueued' => ({required Object count}) => '${count} episódios na fila de download', 'downloads.downloadDeleted' => 'Download excluído', 'downloads.deleteConfirm' => ({required Object title}) => 'Excluir "${title}" deste dispositivo?', diff --git a/lib/i18n/strings_ru.g.dart b/lib/i18n/strings_ru.g.dart index 8672b065..7b37ae29 100644 --- a/lib/i18n/strings_ru.g.dart +++ b/lib/i18n/strings_ru.g.dart @@ -1406,6 +1406,7 @@ class _Translations$downloads$ru extends Translations$downloads$en { @override String get downloadQueued => 'Загрузка поставлена в очередь'; @override String get downloadResumed => 'Загрузка возобновлена'; @override String get serverErrorBitrate => 'Ошибка сервера: файл может превышать установленное на сервере ограничение битрейта'; + @override String get storageFull => 'Загрузки остановлены, так как хранилище устройства заполнено. Освободите место и повторите попытку.'; @override String episodesQueued({required Object count}) => '${count} эпизодов поставлено в очередь загрузки'; @override String get downloadDeleted => 'Загрузка удалена'; @override String deleteConfirm({required Object title}) => 'Удалить "${title}" с этого устройства?'; @@ -3214,6 +3215,7 @@ extension on TranslationsRu { 'downloads.downloadQueued' => 'Загрузка поставлена в очередь', 'downloads.downloadResumed' => 'Загрузка возобновлена', 'downloads.serverErrorBitrate' => 'Ошибка сервера: файл может превышать установленное на сервере ограничение битрейта', + 'downloads.storageFull' => 'Загрузки остановлены, так как хранилище устройства заполнено. Освободите место и повторите попытку.', 'downloads.episodesQueued' => ({required Object count}) => '${count} эпизодов поставлено в очередь загрузки', 'downloads.downloadDeleted' => 'Загрузка удалена', 'downloads.deleteConfirm' => ({required Object title}) => 'Удалить "${title}" с этого устройства?', diff --git a/lib/i18n/strings_sv.g.dart b/lib/i18n/strings_sv.g.dart index 83e5f464..6f7044d8 100644 --- a/lib/i18n/strings_sv.g.dart +++ b/lib/i18n/strings_sv.g.dart @@ -1400,6 +1400,7 @@ class _Translations$downloads$sv extends Translations$downloads$en { @override String get downloadQueued => 'Nedladdning köad'; @override String get downloadResumed => 'Nedladdning återupptagen'; @override String get serverErrorBitrate => 'Serverfel: filen kan överskrida serverns bithastighetsgräns'; + @override String get storageFull => 'Nedladdningarna stoppades eftersom enhetens lagringsutrymme är fullt. Frigör utrymme och försök igen.'; @override String episodesQueued({required Object count}) => '${count} avsnitt köade för nedladdning'; @override String get downloadDeleted => 'Nedladdning borttagen'; @override String deleteConfirm({required Object title}) => 'Ta bort "${title}" från den här enheten?'; @@ -3208,6 +3209,7 @@ extension on TranslationsSv { 'downloads.downloadQueued' => 'Nedladdning köad', 'downloads.downloadResumed' => 'Nedladdning återupptagen', 'downloads.serverErrorBitrate' => 'Serverfel: filen kan överskrida serverns bithastighetsgräns', + 'downloads.storageFull' => 'Nedladdningarna stoppades eftersom enhetens lagringsutrymme är fullt. Frigör utrymme och försök igen.', 'downloads.episodesQueued' => ({required Object count}) => '${count} avsnitt köade för nedladdning', 'downloads.downloadDeleted' => 'Nedladdning borttagen', 'downloads.deleteConfirm' => ({required Object title}) => 'Ta bort "${title}" från den här enheten?', diff --git a/lib/i18n/strings_zh.g.dart b/lib/i18n/strings_zh.g.dart index 2fe792ea..247be64f 100644 --- a/lib/i18n/strings_zh.g.dart +++ b/lib/i18n/strings_zh.g.dart @@ -1397,6 +1397,7 @@ class Translations$downloads$zh extends Translations$downloads$en { @override String get downloadQueued => '下载已排队'; @override String get downloadResumed => '下载已继续'; @override String get serverErrorBitrate => '服务器错误:文件可能超过远程比特率限制'; + @override String get storageFull => '设备存储空间已满,因此下载已停止。请释放空间后重试。'; @override String episodesQueued({required Object count}) => '${count} 集已加入下载队列'; @override String get downloadDeleted => '下载已删除'; @override String deleteConfirm({required Object title}) => '要从此设备删除“${title}”吗?'; @@ -3205,6 +3206,7 @@ extension on TranslationsZh { 'downloads.downloadQueued' => '下载已排队', 'downloads.downloadResumed' => '下载已继续', 'downloads.serverErrorBitrate' => '服务器错误:文件可能超过远程比特率限制', + 'downloads.storageFull' => '设备存储空间已满,因此下载已停止。请释放空间后重试。', 'downloads.episodesQueued' => ({required Object count}) => '${count} 集已加入下载队列', 'downloads.downloadDeleted' => '下载已删除', 'downloads.deleteConfirm' => ({required Object title}) => '要从此设备删除“${title}”吗?', diff --git a/lib/i18n/strings_zh_Hant.g.dart b/lib/i18n/strings_zh_Hant.g.dart index 0c91dac1..6a612e41 100644 --- a/lib/i18n/strings_zh_Hant.g.dart +++ b/lib/i18n/strings_zh_Hant.g.dart @@ -1398,6 +1398,7 @@ class _Translations$downloads$zh_Hant extends Translations$downloads$zh { @override String get downloadQueued => '下載已排隊'; @override String get downloadResumed => '下載已繼續'; @override String get serverErrorBitrate => '伺服器錯誤:檔案位元率可能超過遠端位元率限制'; + @override String get storageFull => '裝置儲存空間已滿,因此下載已停止。請釋出空間後再試一次。'; @override String episodesQueued({required Object count}) => '已將 ${count} 集影片加入下載佇列'; @override String get downloadDeleted => '下載內容已刪除'; @override String deleteConfirm({required Object title}) => '確定要從此裝置刪除「${title}」嗎?'; @@ -3206,6 +3207,7 @@ extension on TranslationsZhHant { 'downloads.downloadQueued' => '下載已排隊', 'downloads.downloadResumed' => '下載已繼續', 'downloads.serverErrorBitrate' => '伺服器錯誤:檔案位元率可能超過遠端位元率限制', + 'downloads.storageFull' => '裝置儲存空間已滿,因此下載已停止。請釋出空間後再試一次。', 'downloads.episodesQueued' => ({required Object count}) => '已將 ${count} 集影片加入下載佇列', 'downloads.downloadDeleted' => '下載內容已刪除', 'downloads.deleteConfirm' => ({required Object title}) => '確定要從此裝置刪除「${title}」嗎?', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 73dc1e34..f794df45 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1151,6 +1151,7 @@ "downloadQueued": "Nedladdning köad", "downloadResumed": "Nedladdning återupptagen", "serverErrorBitrate": "Serverfel: filen kan överskrida serverns bithastighetsgräns", + "storageFull": "Nedladdningarna stoppades eftersom enhetens lagringsutrymme är fullt. Frigör utrymme och försök igen.", "episodesQueued": "${count} avsnitt köade för nedladdning", "downloadDeleted": "Nedladdning borttagen", "deleteConfirm": "Ta bort \"${title}\" från den här enheten?", diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 95791572..4b816954 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1148,6 +1148,7 @@ "downloadQueued": "下載已排隊", "downloadResumed": "下載已繼續", "serverErrorBitrate": "伺服器錯誤:檔案位元率可能超過遠端位元率限制", + "storageFull": "裝置儲存空間已滿,因此下載已停止。請釋出空間後再試一次。", "episodesQueued": "已將 ${count} 集影片加入下載佇列", "downloadDeleted": "下載內容已刪除", "deleteConfirm": "確定要從此裝置刪除「${title}」嗎?", diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 95d1c880..9b670b1e 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1148,6 +1148,7 @@ "downloadQueued": "下载已排队", "downloadResumed": "下载已继续", "serverErrorBitrate": "服务器错误:文件可能超过远程比特率限制", + "storageFull": "设备存储空间已满,因此下载已停止。请释放空间后重试。", "episodesQueued": "${count} 集已加入下载队列", "downloadDeleted": "下载已删除", "deleteConfirm": "要从此设备删除“${title}”吗?", diff --git a/lib/main.dart b/lib/main.dart index e8962aa5..9891c797 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -64,6 +64,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'services/jellyfin_api_cache.dart'; import 'services/plex_api_cache.dart'; import 'database/app_database.dart'; +import 'database/download_operations.dart'; import 'database/tvos_database_recovery_store.dart'; import 'screens/video_player_screen.dart'; import 'utils/app_logger.dart'; @@ -82,6 +83,7 @@ import 'utils/navigation_transitions.dart'; import 'utils/log_redaction_manager.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'utils/android_exit_diagnostics.dart'; +import 'utils/storage_failure.dart'; const bool _enableSentry = bool.fromEnvironment('ENABLE_SENTRY', defaultValue: false); const String _sentryDsn = 'https://6a1a6ef8c72140099b2798973c1bfb2f@bugs.plezy.app/1'; @@ -349,6 +351,30 @@ class _StartupDependencies { final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; } +@visibleForTesting +Future openAppDatabaseWithDownloadRecovery({ + required Future Function() openDatabase, + required Future Function() recoverNativeDownloads, + required String storageFullMessage, +}) async { + try { + return await openDatabase(); + } catch (error, stackTrace) { + if (!isStorageFullError(error)) rethrow; + appLogger.w( + 'Application database could not open because storage is full; discarding interrupted downloads', + error: error, + stackTrace: stackTrace, + ); + } + + await recoverNativeDownloads(); + final bootstrap = await openDatabase(); + final failedCount = await bootstrap.database.failActiveDownloadsForStorageFull(storageFullMessage); + appLogger.w('Recovered startup after storage exhaustion; stopped $failedCount active download(s)'); + return bootstrap; +} + Future<_StartupDependencies> _initializeStartup(SettingsService settings) async { final startupWatch = Stopwatch()..start(); var lastStartupMarkMs = 0; @@ -386,7 +412,11 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async markStartupPhase('platform-services'); AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseOpenStarted); - final databaseBootstrap = await AppDatabase.open(isTvos: PlatformDetector.isAppleTV()); + final databaseBootstrap = await openAppDatabaseWithDownloadRecovery( + openDatabase: () => AppDatabase.open(isTvos: PlatformDetector.isAppleTV()), + recoverNativeDownloads: DownloadManagerService.discardInterruptedNativeDownloadsAfterStorageFailure, + storageFullMessage: t.downloads.storageFull, + ); openedDatabase = databaseBootstrap.database; AndroidExitDiagnostics.markStartupPhase(AndroidStartupPhase.databaseReady); markStartupPhase('database-recovery'); @@ -516,13 +546,7 @@ FutureOr _beforeSend(SentryEvent event, Hint _) { return true; } // Device out of disk space - if (v != null && - (v.contains('SQLITE_FULL') || - v.contains('No space left on device') || - v.contains('errno = 112') || - v.contains('database or disk is full'))) { - return true; - } + if (isStorageFullMessage(v)) return true; // Native HTTP errors from CFNetwork (server errors, not actionable) if (e.type == 'HTTPClientError') return true; // Benign EventChannel teardown race: the engine replies this when a diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index d78c1ca6..e9d04e40 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -35,6 +35,7 @@ import '../utils/serial_future_queue.dart'; import '../utils/active_client_scope.dart'; import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; +import '../utils/storage_failure.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; typedef MediaClientResolver = MediaServerClient? Function(ServerId serverId, {String? clientScopeId}); @@ -142,6 +143,7 @@ class DownloadManagerService { bool _isProcessingQueue = false; bool _isRepairingArtwork = false; bool _disposed = false; + bool _queueBlockedByStorageFailure = false; bool _loggedDownloadsUnsupported = false; // Debounce timers for DB progress writes (keyed by globalKey). @@ -163,6 +165,22 @@ class DownloadManagerService { @visibleForTesting static bool downloadsSupportedFor({required bool tvosBuild}) => !tvosBuild; + /// Cancels native work and discards resumable partial files so startup can + /// recover enough space to reopen the application database. + static Future discardInterruptedNativeDownloadsAfterStorageFailure() async { + final downloader = FileDownloader(); + try { + await downloader.reset(group: _downloadGroup); + } catch (error, stackTrace) { + appLogger.w('Failed to cancel native downloads during storage recovery', error: error, stackTrace: stackTrace); + } + try { + await downloader.database.deleteAllRecords(group: _downloadGroup); + } catch (error, stackTrace) { + appLogger.w('Failed to discard partial downloads during storage recovery', error: error, stackTrace: stackTrace); + } + } + static Future shouldBlockDownloadOnCellular() async { final List connectivity; try { @@ -1465,6 +1483,7 @@ class DownloadManagerService { int mediaIndex = 0, }) async { if (_skipDownloadsUnsupported('queue download')) return; + _resumeQueueAfterStorageFailure('new download'); final globalKey = metadata.globalKey; @@ -1511,7 +1530,7 @@ class DownloadManagerService { /// Process the download queue — prepares and enqueues items with background_downloader. /// Non-blocking: returns after all queued items are enqueued (downloads run natively). Future _processQueue(MediaServerClient client) async { - if (_skipDownloadsUnsupported('download queue processing')) return; + if (_skipDownloadsUnsupported('download queue processing') || _queueBlockedByStorageFailure) return; final queueProcessorOverride = _queueProcessorOverride; if (queueProcessorOverride != null) { _fallbackClient = client; @@ -1525,7 +1544,7 @@ class DownloadManagerService { try { await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader()); - while (true) { + while (!_queueBlockedByStorageFailure) { if (_consecutiveQueueFailures >= _maxConsecutiveFailures) { appLogger.w('Circuit breaker: $_consecutiveQueueFailures consecutive failures, pausing queue'); break; @@ -1677,6 +1696,7 @@ class DownloadManagerService { ) async { if (_skipDownloadsUnsupported('download enqueue')) return false; if (_cancellingKeys.contains(globalKey)) return true; + if (_queueBlockedByStorageFailure) return true; try { // Guard: don't re-enqueue an item that's already completed or was deleted @@ -1697,6 +1717,7 @@ class DownloadManagerService { await _database.removeFromQueue(globalKey); return true; } + if (_queueBlockedByStorageFailure) return true; await _transitionStatus(globalKey, DownloadStatus.downloading); final parsed = parseGlobalKey(globalKey); @@ -1770,6 +1791,7 @@ class DownloadManagerService { final MediaItem resolvedMetadata = metadata; final becameInactive = await _serializeSafOwnership(() async { + if (_queueBlockedByStorageFailure) return true; final metadata = resolvedMetadata; final safBaseUri = _storageService.safBaseUri; if (_storageService.isUsingSaf && safBaseUri != null) { @@ -2026,7 +2048,7 @@ class DownloadManagerService { case TaskStatus.complete: await _onDownloadComplete(globalKey, update.task); case TaskStatus.failed: - await _onDownloadFailed(globalKey, update.task.taskId, update.exception?.description ?? 'Download failed'); + await _onDownloadFailed(globalKey, update.task.taskId, update.exception); case TaskStatus.notFound: await _onDownloadPermanentlyFailed(globalKey, update.task.taskId, 'File not found (404)'); case TaskStatus.canceled: @@ -2072,6 +2094,43 @@ class DownloadManagerService { await _requeueDownload(globalKey); } + void _resumeQueueAfterStorageFailure(String operation) { + if (!_queueBlockedByStorageFailure) return; + appLogger.i('Resuming download queue after storage failure: $operation'); + _queueBlockedByStorageFailure = false; + _consecutiveQueueFailures = 0; + } + + Future _handleStorageFullFailure(String globalKey, String taskId) async { + _queueBlockedByStorageFailure = true; + for (final timer in _autoRetryTimers.values) { + timer.cancel(); + } + _autoRetryTimers.clear(); + + if (downloadsSupported) { + try { + await FileDownloader().database.deleteRecordWithId(taskId); + } catch (error, stackTrace) { + appLogger.w('Failed to discard partial download for $globalKey', error: error, stackTrace: stackTrace); + } + try { + await FileDownloader().reset(group: _downloadGroup); + } catch (error, stackTrace) { + appLogger.w( + 'Failed to stop native download queue after storage exhaustion', + error: error, + stackTrace: stackTrace, + ); + } + } + + final errorMessage = t.downloads.storageFull; + final failedCount = await _database.failActiveDownloadsForStorageFull(errorMessage); + _emitProgress(globalKey, DownloadStatus.failed, 0, errorMessage: errorMessage); + appLogger.e('Device storage exhausted; stopped $failedCount active download(s)'); + } + bool _isRetryablePrepareFailure(Object error) { if (error is! MediaServerHttpException || error.isCancellation) return false; final status = error.statusCode; @@ -2101,9 +2160,13 @@ class DownloadManagerService { if (processQueueAfterProgress) unawaited(_processQueue(client)); } - /// Handle a failed download — auto-retry if retries remain, otherwise permanently fail. - /// Native retries (Range-based resume) are already exhausted at this point. - Future _onDownloadFailed(String globalKey, String taskId, String errorMessage) async { + bool _isStorageFullDownloadFailure(TaskException? exception) { + return exception != null && isStorageFullMessage(exception.description); + } + + /// Handle a failed download — stop the queue on storage exhaustion, + /// otherwise auto-retry if retries remain. + Future _onDownloadFailed(String globalKey, String taskId, TaskException? exception) async { if (_cancellingKeys.contains(globalKey)) { appLogger.d('Ignoring failure for $globalKey: cancellation in progress'); return; @@ -2122,6 +2185,11 @@ class DownloadManagerService { if (existing == null) return; _cancelDownloadTimers(globalKey); _pendingDownloadContext.remove(globalKey); + if (_isStorageFullDownloadFailure(exception)) { + await _handleStorageFullFailure(globalKey, taskId); + return; + } + final errorMessage = exception?.description ?? 'Download failed'; final retryCount = existing.retryCount; // DNS/connection errors fail instantly and exhaust native retries in milliseconds, @@ -2663,6 +2731,7 @@ class DownloadManagerService { /// Resume a paused download Future resumeDownload(String globalKey, MediaServerClient client) async { if (_skipDownloadsUnsupported('download resume')) return; + _resumeQueueAfterStorageFailure('manual resume'); final bgTaskId = await _database.getBgTaskId(globalKey); @@ -2714,6 +2783,7 @@ class DownloadManagerService { /// Retry a failed download Future retryDownload(String globalKey, MediaServerClient client) async { if (_skipDownloadsUnsupported('download retry')) return; + _resumeQueueAfterStorageFailure('manual retry'); _autoRetryTimers.remove(globalKey)?.cancel(); await _cleanupStaleDownload(globalKey); diff --git a/lib/utils/storage_failure.dart b/lib/utils/storage_failure.dart new file mode 100644 index 00000000..0b4813f6 --- /dev/null +++ b/lib/utils/storage_failure.dart @@ -0,0 +1,15 @@ +bool isStorageFullError(Object? error) => isStorageFullMessage(error?.toString()); + +bool isStorageFullMessage(String? message) { + if (message == null || message.isEmpty) return false; + final value = message.toLowerCase(); + return value.contains('sqlite_full') || + value.contains('database or disk is full') || + value.contains('no space left on device') || + value.contains('enospc') || + value.contains('errno = 112') || + value.contains('not enough space on the disk') || + value.contains('insufficient space to store') || + value.contains('volume is out of space') || + value.contains('nsfilewriteoutofspaceerror'); +} diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index 1c3fc5c2..f19af6d0 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -14,6 +14,7 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/download_resolution.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -1288,6 +1289,60 @@ void main() { }); }); + group('storage exhaustion', () { + test('filesystem-full failure stops every active download and clears the queue', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(db.close); + const currentKey = 'srv:item-1'; + const queuedKey = 'srv:item-2'; + + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'item-1', + globalKey: currentKey, + type: 'movie', + status: DownloadStatus.downloading.index, + ); + await db.updateBgTaskId(currentKey, 'current-task'); + await db.addToQueue(mediaGlobalKey: currentKey); + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'item-2', + globalKey: queuedKey, + type: 'movie', + status: DownloadStatus.queued.index, + ); + await db.updateBgTaskId(queuedKey, 'queued-task'); + await db.addToQueue(mediaGlobalKey: queuedKey); + + final manager = DownloadManagerService( + database: db, + storageService: DownloadStorageService.instance, + clientResolver: (serverId, {clientScopeId}) => null, + downloadsSupportedOverride: false, + ); + addTearDown(manager.dispose); + + await manager.debugHandleTaskStatus( + TaskStatusUpdate( + _downloadTask('current-task', currentKey), + TaskStatus.failed, + TaskFileSystemException('write failed: ENOSPC (No space left on device)'), + ), + ); + + final current = await db.getDownloadedMedia(currentKey); + final queued = await db.getDownloadedMedia(queuedKey); + expect(current?.status, DownloadStatus.failed.index); + expect(queued?.status, DownloadStatus.failed.index); + expect(current?.bgTaskId, isNull); + expect(queued?.bgTaskId, isNull); + expect(current?.errorMessage, t.downloads.storageFull); + expect(queued?.errorMessage, t.downloads.storageFull); + expect(await db.select(db.downloadQueue).get(), isEmpty); + }); + }); + group('task session validation', () { test('ignores progress from stale native task ids', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index ff2233a2..64ba2a40 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -1,8 +1,15 @@ import 'dart:async'; +import 'dart:io'; +import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/database/tvos_database_recovery_store.dart'; import 'package:plezy/main.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/download_models.dart'; void main() { testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async { @@ -104,4 +111,57 @@ void main() { expect(discarded, [9]); expect(find.text('ready 9'), findsNothing); }); + + test('storage-full database open discards native work before retrying', () async { + final database = AppDatabase.forTesting(NativeDatabase.memory()); + addTearDown(database.close); + await database.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'active', + globalKey: 'srv:active', + type: 'movie', + status: DownloadStatus.downloading.index, + ); + await database.updateBgTaskId('srv:active', 'native-task'); + await database.addToQueue(mediaGlobalKey: 'srv:active'); + await database.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'complete', + globalKey: 'srv:complete', + type: 'movie', + status: DownloadStatus.completed.index, + ); + + var openAttempts = 0; + var recoveries = 0; + final bootstrap = AppDatabaseBootstrap( + database: database, + recoveryOutcome: TvosDatabaseRecoveryOutcome.notApplicable, + ); + + final result = await openAppDatabaseWithDownloadRecovery( + openDatabase: () async { + openAttempts++; + if (openAttempts == 1) { + throw const FileSystemException('write failed: No space left on device'); + } + return bootstrap; + }, + recoverNativeDownloads: () async { + recoveries++; + }, + storageFullMessage: 'Storage full', + ); + + final active = await database.getDownloadedMedia('srv:active'); + final complete = await database.getDownloadedMedia('srv:complete'); + expect(result, same(bootstrap)); + expect(openAttempts, 2); + expect(recoveries, 1); + expect(active?.status, DownloadStatus.failed.index); + expect(active?.bgTaskId, isNull); + expect(active?.errorMessage, 'Storage full'); + expect(complete?.status, DownloadStatus.completed.index); + expect(await database.select(database.downloadQueue).get(), isEmpty); + }); }