fix(downloads): recover from full storage

close #1655
This commit is contained in:
edde746
2026-07-24 07:48:42 +02:00
parent ef0524485a
commit 54273ab09c
43 changed files with 319 additions and 15 deletions
+24
View File
@@ -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<int> 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<void> updateDownloadStatus(String globalKey, int status) async {
await (update(
downloadedMedia,
+1
View File
@@ -1151,6 +1151,7 @@
"downloadQueued": "Изтеглянето е добавено в опашката",
"downloadResumed": "Изтеглянето е възобновено",
"serverErrorBitrate": "Грешка на сървъра: файлът може да надвишава лимита за отдалечен битрейт",
"storageFull": "Изтеглянията бяха спрени, защото паметта на устройството е пълна. Освободете място и опитайте отново.",
"episodesQueued": "${count} епизода са добавени в опашката за изтегляне",
"downloadDeleted": "Изтеглянето е изтрито",
"deleteConfirm": "Да се изтрие ли \"${title}\" от това устройство?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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 lappareil est plein. Libérez de lespace, puis réessayez.",
"episodesQueued": "${count} épisodes en attente de téléchargement",
"downloadDeleted": "Téléchargement supprimé",
"deleteConfirm": "Supprimer « ${title} » de cet appareil ?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -1148,6 +1148,7 @@
"downloadQueued": "ダウンロードをキューに追加しました",
"downloadResumed": "ダウンロードを再開しました",
"serverErrorBitrate": "サーバーエラー: ファイルがリモートビットレート制限を超えている可能性があります",
"storageFull": "デバイスのストレージがいっぱいのため、ダウンロードを停止しました。空き容量を確保してから、もう一度お試しください。",
"episodesQueued": "${count}エピソードをダウンロードキューに追加しました",
"downloadDeleted": "ダウンロードを削除しました",
"deleteConfirm": "このデバイスから「${title}」を削除しますか?",
+1
View File
@@ -1148,6 +1148,7 @@
"downloadQueued": "다운로드 대기 중",
"downloadResumed": "다운로드를 재개했습니다",
"serverErrorBitrate": "서버 오류: 파일이 원격 비트레이트 제한을 초과할 수 있습니다",
"storageFull": "기기 저장 공간이 가득 차서 다운로드를 중지했습니다. 공간을 확보한 후 다시 시도하세요.",
"episodesQueued": "에피소드 ${count}개가 다운로드 대기열에 추가되었습니다",
"downloadDeleted": "다운로드 삭제됨",
"deleteConfirm": "이 기기에서 \"${title}\"을(를) 삭제할까요?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -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?",
+1
View File
@@ -1157,6 +1157,7 @@
"downloadQueued": "Загрузка поставлена в очередь",
"downloadResumed": "Загрузка возобновлена",
"serverErrorBitrate": "Ошибка сервера: файл может превышать установленное на сервере ограничение битрейта",
"storageFull": "Загрузки остановлены, так как хранилище устройства заполнено. Освободите место и повторите попытку.",
"episodesQueued": "${count} эпизодов поставлено в очередь загрузки",
"downloadDeleted": "Загрузка удалена",
"deleteConfirm": "Удалить \"${title}\" с этого устройства?",
+1 -1
View File
@@ -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
+2
View File
@@ -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}" от това устройство?',
+2
View File
@@ -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?',
+2
View File
@@ -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?',
+4
View File
@@ -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?',
+2
View File
@@ -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?',
+2
View File
@@ -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 lappareil est plein. Libérez de lespace, 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 lappareil est plein. Libérez de lespace, 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 ?',
+2
View File
@@ -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?',
+2
View File
@@ -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?',
+2
View File
@@ -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}」を削除しますか?',
+2
View File
@@ -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}"을(를) 삭제할까요?',
+2
View File
@@ -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?',
+2
View File
@@ -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?',
+2
View File
@@ -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?',
+2
View File
@@ -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?',
+2
View File
@@ -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}" с этого устройства?',
+2
View File
@@ -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?',
+2
View File
@@ -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}”吗?',
+2
View File
@@ -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}」嗎?',
+1
View File
@@ -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?",
+1
View File
@@ -1148,6 +1148,7 @@
"downloadQueued": "下載已排隊",
"downloadResumed": "下載已繼續",
"serverErrorBitrate": "伺服器錯誤:檔案位元率可能超過遠端位元率限制",
"storageFull": "裝置儲存空間已滿,因此下載已停止。請釋出空間後再試一次。",
"episodesQueued": "已將 ${count} 集影片加入下載佇列",
"downloadDeleted": "下載內容已刪除",
"deleteConfirm": "確定要從此裝置刪除「${title}」嗎?",
+1
View File
@@ -1148,6 +1148,7 @@
"downloadQueued": "下载已排队",
"downloadResumed": "下载已继续",
"serverErrorBitrate": "服务器错误:文件可能超过远程比特率限制",
"storageFull": "设备存储空间已满,因此下载已停止。请释放空间后重试。",
"episodesQueued": "${count} 集已加入下载队列",
"downloadDeleted": "下载已删除",
"deleteConfirm": "要从此设备删除“${title}”吗?",
+32 -8
View File
@@ -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<AppDatabaseBootstrap> openAppDatabaseWithDownloadRecovery({
required Future<AppDatabaseBootstrap> Function() openDatabase,
required Future<void> 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<SentryEvent?> _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
+76 -6
View File
@@ -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<void> 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<bool> shouldBlockDownloadOnCellular() async {
final List<ConnectivityResult> 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<void> _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<void> _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<void> _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<void> _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<void> 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<void> retryDownload(String globalKey, MediaServerClient client) async {
if (_skipDownloadsUnsupported('download retry')) return;
_resumeQueueAfterStorageFailure('manual retry');
_autoRetryTimers.remove(globalKey)?.cancel();
await _cleanupStaleDownload(globalKey);
+15
View File
@@ -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');
}
@@ -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());
+60
View File
@@ -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);
});
}