From 9f91e074aaacc8c6f418e100773f9debdefec766 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 8 Sep 2026 08:25:03 +0200 Subject: [PATCH] fix(downloads): expose retained archive recovery files --- AGENTS.md | 4 +- CLAUDE.md | 4 +- .../src/xtream-catchup-timezone.e2e.ts | 36 +++++++++++++ .../database/download-catchup-capture.ts | 33 ++++++++---- .../database/download-removal-requests.ts | 11 +++- .../events/database/downloads.events.spec.ts | 34 ++++++++++++ apps/web/src/assets/i18n/ar.json | 5 ++ apps/web/src/assets/i18n/ary.json | 5 ++ apps/web/src/assets/i18n/by.json | 5 ++ apps/web/src/assets/i18n/de.json | 5 ++ apps/web/src/assets/i18n/el.json | 5 ++ apps/web/src/assets/i18n/en.json | 5 ++ apps/web/src/assets/i18n/es.json | 5 ++ apps/web/src/assets/i18n/fr.json | 5 ++ apps/web/src/assets/i18n/hu.json | 5 ++ apps/web/src/assets/i18n/it.json | 5 ++ apps/web/src/assets/i18n/ja.json | 5 ++ apps/web/src/assets/i18n/ko.json | 5 ++ apps/web/src/assets/i18n/nl.json | 5 ++ apps/web/src/assets/i18n/pl.json | 5 ++ apps/web/src/assets/i18n/pt.json | 5 ++ apps/web/src/assets/i18n/ru.json | 5 ++ apps/web/src/assets/i18n/tr.json | 5 ++ apps/web/src/assets/i18n/zh.json | 5 ++ apps/web/src/assets/i18n/zhtw.json | 5 ++ docs/architecture/download-manager.md | 5 +- .../lib/download-manager-actions.service.ts | 29 +++++++++- .../src/lib/downloads.component.spec.ts | 53 +++++++++++++++++++ libs/services/src/lib/downloads.service.ts | 7 ++- .../src/lib/catchup-download.interface.ts | 7 +++ .../src/lib/electron-api.interface.ts | 5 +- 31 files changed, 301 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2b3dbab2..c2e0866c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1058,7 +1058,9 @@ Fresh reservations atomically commit their row path/name and captured ownership before the initial HTTP wait; no preexisting partial is truncated without matching expected ownership. Captured foreign files retain their recovery copy and journal even after public -restoration, until the user explicitly removes the recovery copy. +restoration, until the user explicitly removes the recovery copy. Remove/Clear +show its full path and recovery instructions in a persistent dialog with Copy +recovery path. Private cleanup captures are journaled before relocation, keeping failed Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active failures, promotion and startup share that cleanup; Remove waits for active diff --git a/CLAUDE.md b/CLAUDE.md index 5e555c70e..a294b3039 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1869,7 +1869,9 @@ Fresh reservations atomically commit their row path/name and captured ownership before the initial HTTP wait; no preexisting partial is truncated without matching expected ownership. Captured foreign files retain their recovery copy and journal even after public -restoration, until the user explicitly removes the recovery copy. +restoration, until the user explicitly removes the recovery copy. Remove/Clear +show its full path and recovery instructions in a persistent dialog with Copy +recovery path. Private cleanup captures are journaled before relocation, keeping failed Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active failures, promotion and startup share that cleanup; Remove waits for active diff --git a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts index 7fce8ef50..a023820b3 100644 --- a/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-catchup-timezone.e2e.ts @@ -7,6 +7,7 @@ import { linkSync, readFileSync, readdirSync, + renameSync, unlinkSync, writeFileSync, } from 'node:fs'; @@ -709,6 +710,41 @@ test('@downloads @epg @xtream @electron downloads a completed archive into the l expect(readFileSync(capturedPath)).toEqual( readFileSync(redownloaded.filePath) ); + // A foreign replacement in the retained capture must survive restart + // and be discoverable without backend logs. Retain the original too. + renameSync(capturedPath, capturedPath + '.owned'); + writeFileSync(capturedPath, 'foreign recovery content'); + app = await restartElectronApp(app, dataDir, { + env: { TZ: VIEWER_TIMEZONE }, + }); + await app.mainWindow + .getByRole('button', { name: 'Open downloads', exact: true }) + .click(); + await app.mainWindow + .getByRole('button', { name: 'Clear finished', exact: true }) + .click(); + await app.mainWindow + .getByRole('dialog') + .getByRole('button', { name: 'Clear finished', exact: true }) + .click(); + const recoveryDialog = app.mainWindow.getByRole('dialog'); + await expect(recoveryDialog).toContainText('File recovery needed'); + await expect(recoveryDialog).toContainText(capturedPath); + await expect( + recoveryDialog.getByRole('button', { + name: 'Copy recovery path', + exact: true, + }) + ).toBeVisible(); + expect(readFileSync(capturedPath, 'utf8')).toBe( + 'foreign recovery content' + ); + await recoveryDialog + .getByRole('button', { name: 'Close', exact: true }) + .click(); + // Simulate explicit user recovery; the app never deletes this copy. + unlinkSync(capturedPath); + renameSync(capturedPath + '.owned', capturedPath); app = await restartElectronApp(app, dataDir, { env: { TZ: VIEWER_TIMEZONE }, }); diff --git a/apps/electron-backend/src/app/events/database/download-catchup-capture.ts b/apps/electron-backend/src/app/events/database/download-catchup-capture.ts index c59309b69..51da559d6 100644 --- a/apps/electron-backend/src/app/events/database/download-catchup-capture.ts +++ b/apps/electron-backend/src/app/events/database/download-catchup-capture.ts @@ -7,6 +7,17 @@ import { } from './download-catchup-output'; import type { ArchiveDownloadProof } from './download-catchup-journal'; +export class ArchiveRecoveryRequiredError extends Error { + constructor( + readonly recoveryPath: string, + reason?: unknown + ) { + super( + `Archive cleanup preserved an unrelated recovery file: ${recoveryPath}${reason instanceof Error ? `. ${reason.message}` : ''}` + ); + } +} + /** Retry a journaled private capture without ever deleting a replacement. */ export function cleanupArchiveCapture( proof: ArchiveDownloadProof | undefined @@ -55,19 +66,21 @@ export function restoreArchiveReplacement( captured: string, publicPath: string ): never { - const file = readArchiveStatsSync(captured); try { - linkSync(captured, publicPath); + const file = readArchiveStatsSync(captured); + try { + linkSync(captured, publicPath); + } catch (error) { + if ( + (error as NodeJS.ErrnoException).code !== 'EEXIST' || + !sameArchiveFileIdentity(readArchiveStatsSync(publicPath), file) + ) + throw error; + } } catch (error) { - if ( - (error as NodeJS.ErrnoException).code !== 'EEXIST' || - !sameArchiveFileIdentity(readArchiveStatsSync(publicPath), file) - ) - throw error; + throw new ArchiveRecoveryRequiredError(captured, error); } // A second process can remove the public link at any time. Only an explicit // user cleanup of this recovery copy may release its durable journal entry. - throw new Error( - `Archive cleanup preserved an unrelated recovery file: ${captured}` - ); + throw new ArchiveRecoveryRequiredError(captured); } diff --git a/apps/electron-backend/src/app/events/database/download-removal-requests.ts b/apps/electron-backend/src/app/events/database/download-removal-requests.ts index e810a274f..7b5987713 100644 --- a/apps/electron-backend/src/app/events/database/download-removal-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-removal-requests.ts @@ -1,3 +1,4 @@ +import { ArchiveRecoveryRequiredError } from './download-catchup-capture'; import { and, eq, inArray } from 'drizzle-orm'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; @@ -95,6 +96,9 @@ export async function removeDownloadRequest(downloadId: number) { ); return { error: 'Could not delete the partial file', + ...(cleanupError instanceof ArchiveRecoveryRequiredError + ? { recoveryPath: cleanupError.recoveryPath } + : {}), success: false, }; } @@ -142,6 +146,7 @@ export async function clearCompletedDownloadsRequest(playlistId?: string) { .map((row) => row.id) ); const downloadIdsToDelete: number[] = []; + let recoveryPath: string | undefined; for (const row of rows) { if (hasRuntimeDownload(row.id)) continue; if (row.filePath && removablePartialStatuses.has(row.status)) { @@ -169,6 +174,8 @@ export async function clearCompletedDownloadsRequest(playlistId?: string) { ); else removePartialDownloadFile(row.filePath); } catch (error) { + if (error instanceof ArchiveRecoveryRequiredError) + recoveryPath ??= error.recoveryPath; console.error( '[Downloads] Retaining download after partial cleanup failed:', error @@ -189,7 +196,9 @@ export async function clearCompletedDownloadsRequest(playlistId?: string) { ); broadcastDownloadUpdate(); } - return { success: true }; + return recoveryPath + ? { success: false, recoveryPath } + : { success: true }; } catch (error) { console.error('[Downloads] Error clearing completed:', error); throw error; diff --git a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts index 787e6dd58..f5af0913d 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.spec.ts @@ -21,6 +21,40 @@ describe('downloads events: partial-file cleanup', () => { await setupDownloadsEventsHarness(); }); + it.each(['remove', 'clear'])( + 'returns a recoverable path and retains the row on %s', + async (action) => { + const { ArchiveRecoveryRequiredError } = + await import('./download-catchup-capture'); + const row = { + ...createDownloadRow('failed'), + contentType: 'catchup', + }; + const { deleteWhere } = + action === 'remove' + ? mockDownloadRow(row) + : mockTerminalRows([row]); + const recoveryPath = '/downloads/.iptvnator-cleanup-test/entry'; + mockRemoveJournaledPartial.mockImplementation(() => { + throw new ArchiveRecoveryRequiredError(recoveryPath); + }); + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + try { + const result = + action === 'remove' + ? await getHandler('DOWNLOADS_REMOVE')(null, 42) + : await getHandler('DOWNLOADS_CLEAR_COMPLETED')(null); + expect(result).toMatchObject({ success: false, recoveryPath }); + expect(deleteWhere).not.toHaveBeenCalled(); + expect(mockRemoveDownloadFromRuntime).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + } + ); + it('waits for active archive cancellation before reading or deleting the row', async () => { const { db, deleteWhere } = mockDownloadRow( createDownloadRow('canceled') diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d0a17c7b1..d614de744 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "معلومات", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "يلزم استعادة الملف", + "MESSAGE": "تم الاحتفاظ بملف غير مرتبط بهذا التنزيل في: {{path}}. استعد المحتوى الذي تحتاجه، ثم احذف نسخة الاستعادة هذه وأعد المحاولة.", + "COPY_PATH": "نسخ مسار الاستعادة" + }, "TITLE": "التنزيلات", "DOWNLOAD": "تنزيل", "PLAY_LOCAL": "تشغيل محلي", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 74cad55be..0e4c4405c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "معلومات", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "خاص استرجاع الملف", + "MESSAGE": "تحفظ واحد الملف ما عندوش علاقة بهاد التحميل فـ: {{path}}. رجّع المحتوى اللي محتاج، مسح هاد النسخة الاحتياطية، وعاود جرّب.", + "COPY_PATH": "نسخ مسار الاسترجاع" + }, "TITLE": "التحميلات", "DOWNLOAD": "تحميل", "PLAY_LOCAL": "شغل محلياً", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 9c876ff4f..e73bc6f7a 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Інфармацыя", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Патрабуецца аднаўленне файла", + "MESSAGE": "Файл, не звязаны з гэтай загрузкай, захаваны па шляху: {{path}}. Захавайце патрэбнае змесціва, выдаліце гэтую рэзервовую копію і паўтарыце дзеянне.", + "COPY_PATH": "Капіяваць шлях аднаўлення" + }, "TITLE": "Спампаванні", "DOWNLOAD": "Спампаваць", "PLAY_LOCAL": "Прайграць лакальна", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 685e26ff4..1c9d09093 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Information", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Dateiwiederherstellung erforderlich", + "MESSAGE": "Eine fremde Datei wurde hier gesichert: {{path}}. Sichern Sie benötigte Inhalte, löschen Sie diese Wiederherstellungskopie und versuchen Sie es erneut.", + "COPY_PATH": "Wiederherstellungspfad kopieren" + }, "TITLE": "Downloads", "DOWNLOAD": "Herunterladen", "PLAY_LOCAL": "Lokal abspielen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dc147d7c5..82d1b94f4 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Πληροφορίες", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Απαιτείται ανάκτηση αρχείου", + "MESSAGE": "Ένα αρχείο άσχετο με αυτή τη λήψη διατηρήθηκε στη διαδρομή: {{path}}. Ανακτήστε ό,τι χρειάζεστε, διαγράψτε αυτό το αντίγραφο ανάκτησης και δοκιμάστε ξανά.", + "COPY_PATH": "Αντιγραφή διαδρομής ανάκτησης" + }, "TITLE": "Λήψεις", "DOWNLOAD": "Λήψη", "PLAY_LOCAL": "Τοπική αναπαραγωγή", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 21f146718..7a28a41d2 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Information", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "File recovery needed", + "MESSAGE": "A file unrelated to this download was preserved at: {{path}}. Recover any content you need, delete this recovery copy, then try again.", + "COPY_PATH": "Copy recovery path" + }, "TITLE": "Downloads", "DOWNLOAD": "Download", "PLAY_LOCAL": "Play Local", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 6e533623e..f0fff1fbc 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Información", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Se requiere recuperar un archivo", + "MESSAGE": "Se ha conservado un archivo ajeno a esta descarga en: {{path}}. Recupera el contenido que necesites, elimina esta copia de recuperación y vuelve a intentarlo.", + "COPY_PATH": "Copiar ruta de recuperación" + }, "TITLE": "Descargas", "DOWNLOAD": "Descargar", "PLAY_LOCAL": "Reproducir localmente", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e1b87d058..6a74344b7 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Information", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Récupération du fichier nécessaire", + "MESSAGE": "Un fichier sans rapport avec ce téléchargement a été conservé ici : {{path}}. Récupérez le contenu utile, supprimez cette copie de récupération, puis réessayez.", + "COPY_PATH": "Copier le chemin de récupération" + }, "TITLE": "Téléchargements", "DOWNLOAD": "Télécharger", "PLAY_LOCAL": "Lire localement", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index d5459bda1..597c10b64 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Információk", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Fájlhelyreállítás szükséges", + "MESSAGE": "A letöltéshez nem tartozó fájlt megőriztük itt: {{path}}. Mentse a szükséges tartalmat, törölje ezt a helyreállítási másolatot, majd próbálja újra.", + "COPY_PATH": "Helyreállítási útvonal másolása" + }, "TITLE": "Letöltések", "DOWNLOAD": "Letöltés", "PLAY_LOCAL": "Helyi fájl lejátszása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 727656c69..67bcde38b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Informazioni", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Recupero del file necessario", + "MESSAGE": "Un file estraneo a questo download è stato conservato in: {{path}}. Recupera i contenuti necessari, elimina questa copia di recupero e riprova.", + "COPY_PATH": "Copia percorso di recupero" + }, "TITLE": "Download", "DOWNLOAD": "Scarica", "PLAY_LOCAL": "Riproduci localmente", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e95bc5295..bf6679fbd 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "情報", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "ファイルの復元が必要です", + "MESSAGE": "このダウンロードとは無関係なファイルが {{path}} に保存されています。必要な内容を取り出し、この復元用コピーを削除してから、もう一度お試しください。", + "COPY_PATH": "復元パスをコピー" + }, "TITLE": "ダウンロード", "DOWNLOAD": "ダウンロード", "PLAY_LOCAL": "ローカル再生", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index b5c6e01d8..f21841981 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "정보", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "파일 복구 필요", + "MESSAGE": "이 다운로드와 관련 없는 파일이 {{path}}에 보관되었습니다. 필요한 내용을 복구하고 이 복구용 사본을 삭제한 후 다시 시도하세요.", + "COPY_PATH": "복구 경로 복사" + }, "TITLE": "다운로드", "DOWNLOAD": "다운로드", "PLAY_LOCAL": "로컬 재생", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index f8a0c5bbd..31f709d55 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Informatie", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Bestandsherstel nodig", + "MESSAGE": "Een bestand dat niet bij deze download hoort, is bewaard op: {{path}}. Bewaar de benodigde inhoud, verwijder deze herstelkopie en probeer het opnieuw.", + "COPY_PATH": "Herstelpad kopiëren" + }, "TITLE": "Downloads", "DOWNLOAD": "Downloaden", "PLAY_LOCAL": "Lokaal bestand afspelen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4bc3c4d43..9c36fcc85 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Informacje", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Wymagane odzyskanie pliku", + "MESSAGE": "Plik niezwiązany z tym pobieraniem zachowano pod ścieżką: {{path}}. Odzyskaj potrzebną zawartość, usuń tę kopię odzyskiwania i spróbuj ponownie.", + "COPY_PATH": "Kopiuj ścieżkę odzyskiwania" + }, "TITLE": "Pobrane", "DOWNLOAD": "Pobierz", "PLAY_LOCAL": "Odtwórz plik lokalny", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 040d78b27..1b0b89cf2 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Informação", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "É necessário recuperar um ficheiro", + "MESSAGE": "Um ficheiro alheio a esta transferência foi preservado em: {{path}}. Recupere o conteúdo necessário, elimine esta cópia de recuperação e tente novamente.", + "COPY_PATH": "Copiar caminho de recuperação" + }, "TITLE": "Downloads", "DOWNLOAD": "Baixar", "PLAY_LOCAL": "Reproduzir local", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 385689112..a0422b28a 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Инфо", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Требуется восстановление файла", + "MESSAGE": "Файл, не относящийся к этой загрузке, сохранён по пути: {{path}}. Сохраните нужное содержимое, удалите эту резервную копию и повторите действие.", + "COPY_PATH": "Копировать путь восстановления" + }, "TITLE": "Загрузки", "DOWNLOAD": "Скачать", "PLAY_LOCAL": "Воспроизвести локально", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 1a4af4be7..a5360203b 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "Bilgi", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "Dosya kurtarma gerekiyor", + "MESSAGE": "Bu indirmeyle ilgisi olmayan bir dosya şurada korundu: {{path}}. Gerekli içeriği kurtarın, bu kurtarma kopyasını silin ve yeniden deneyin.", + "COPY_PATH": "Kurtarma yolunu kopyala" + }, "TITLE": "İndirilenler", "DOWNLOAD": "İndir", "PLAY_LOCAL": "Yerel olarak oynat", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 5504fc7b7..5450108c8 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "信息", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "需要恢复文件", + "MESSAGE": "与此下载无关的文件已保存在:{{path}}。请保存所需内容,删除此恢复副本,然后重试。", + "COPY_PATH": "复制恢复路径" + }, "TITLE": "下载", "DOWNLOAD": "下载", "PLAY_LOCAL": "播放本地文件", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f029e5448..4a1fd3cb8 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1246,6 +1246,11 @@ }, "INFORMATION": "資訊", "DOWNLOADS": { + "RECOVERY": { + "TITLE": "需要復原檔案", + "MESSAGE": "與此下載無關的檔案已保存在:{{path}}。請儲存所需內容,刪除此復原副本,然後重試。", + "COPY_PATH": "複製復原路徑" + }, "TITLE": "下載", "DOWNLOAD": "下載", "PLAY_LOCAL": "播放本機檔案", diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index c321fef78..f89d7465b 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -119,8 +119,9 @@ deletion/replacement. Later cleanup retries no-clobber restoration of captured foreign entries; an occupied public path or unsupported hardlinks preserves both the capture and its journal for recovery. Even after a foreign entry is restored, its private recovery copy is never automatically unlinked: another process could -remove the public link first. The error names the recovery file; after the user -recovers it and explicitly removes that private copy, cleanup may release the +remove the public link first. Remove/Clear return a structured recovery path and open a persistent, localized +dialog with the full path, Copy recovery path and manual recovery instructions. +After the user recovers it and explicitly removes that private copy, cleanup may release the journal. Ordinary owned-file cleanup remains automatic. Remove/Clear, Retry/Resume and fresh reservations retry identity-verified cleanup, including after restart and on filesystems without hardlinks. Cleanup remains synchronous after the diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts index 20f988ac4..1c6a2c02f 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -11,6 +11,7 @@ import type { interface DownloadOperationResult { readonly success: boolean; readonly error?: string; + readonly recoveryPath?: string; } @Injectable() @@ -104,7 +105,8 @@ export class DownloadManagerActionsService { try { const result = await this.downloads.clearCompleted(scopePlaylistId); if (!result.success) { - this.showActionError(); + if (result.recoveryPath) this.showRecovery(result.recoveryPath); + else this.showActionError(); } } catch (error) { this.showActionError( @@ -172,6 +174,10 @@ export class DownloadManagerActionsService { let failed = false; try { const result = await operation(); + if (result.recoveryPath) { + this.showRecovery(result.recoveryPath); + return 'failed'; + } failed = !result.success; failure = result.error; } catch (error) { @@ -199,6 +205,27 @@ export class DownloadManagerActionsService { } } + private showRecovery(path: string): void { + this.dialogs.openConfirmDialog({ + title: this.translate.instant('DOWNLOADS.RECOVERY.TITLE'), + cancelLabel: this.translate.instant('CLOSE'), + width: '600px', + message: this.translate.instant('DOWNLOADS.RECOVERY.MESSAGE', { + path, + }), + confirmLabel: this.translate.instant( + 'DOWNLOADS.RECOVERY.COPY_PATH' + ), + onConfirm: async () => { + try { + await navigator.clipboard.writeText(path); + } catch { + this.showActionError(); + } + }, + }); + } + private showFileActionError(error?: string): void { this.showMessage( error === 'File not found' diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts index 3e3fe37a8..1d1412b5b 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -39,6 +39,8 @@ interface Deferred { } interface ConfirmConfig { + readonly confirmLabel?: string; + readonly cancelLabel?: string; readonly title: string; readonly message: string; readonly onConfirm: () => Promise | void; @@ -335,8 +337,15 @@ describe('DownloadsComponent', () => { const translate = TestBed.inject(TranslateService); translate.setTranslation('en', { + CLOSE: 'Close', DOWNLOADS: { TITLE: 'Downloads', + RECOVERY: { + TITLE: 'File recovery needed', + MESSAGE: + 'Recover any needed content at {{path}}, remove that recovery copy, then try again.', + COPY_PATH: 'Copy recovery path', + }, ACTIVE_COUNT: '{{count}} active', TRACKED_DOWNLOADS: 'Tracked downloads', CHANGE_FOLDER: 'Change folder', @@ -640,6 +649,50 @@ describe('DownloadsComponent', () => { } ); + it.each(['remove', 'clear'])( + 'shows a persistent recovery dialog with a copyable path on %s', + async (action) => { + const recoveryPath = '/downloads/.iptvnator-cleanup-example/entry'; + const result = { success: false, recoveryPath }; + downloadsService.removeDownload.mockResolvedValueOnce(result); + downloadsService.clearCompleted.mockResolvedValueOnce(result); + if (action === 'remove') + component.runAction({ + type: 'remove', + item: download(7, { status: 'failed' }), + }); + else component.clearFinished(); + await dialogConfigs[0].onConfirm(); + expect(dialogConfigs[1]).toEqual( + expect.objectContaining({ + title: 'File recovery needed', + message: expect.stringContaining(recoveryPath), + confirmLabel: 'Copy recovery path', + cancelLabel: 'Close', + }) + ); + expect(snackBar.open).not.toHaveBeenCalled(); + expect(component.pendingIds().size).toBe(0); + const original = Object.getOwnPropertyDescriptor( + navigator, + 'clipboard' + ); + const writeText = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }); + try { + await dialogConfigs[1].onConfirm(); + expect(writeText).toHaveBeenCalledWith(recoveryPath); + } finally { + if (original) + Object.defineProperty(navigator, 'clipboard', original); + else Reflect.deleteProperty(navigator, 'clipboard'); + } + } + ); + it('describes both finalized and retained-partial outcomes when clearing', async () => { component.clearFinished(); diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 2e40f869e..7decad581 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,3 +1,4 @@ +import type { DownloadRecoveryResult } from '@iptvnator/shared/interfaces'; import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces'; import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces'; @@ -288,9 +289,7 @@ export class DownloadsService implements OnDestroy { /** * Remove a download from the list */ - async removeDownload( - downloadId: number - ): Promise<{ success: boolean; error?: string }> { + async removeDownload(downloadId: number): Promise { if (!this.isAvailable()) { return { success: false, error: 'Downloads not available' }; } @@ -379,7 +378,7 @@ export class DownloadsService implements OnDestroy { /** * Clear completed/failed downloads */ - async clearCompleted(playlistId?: string): Promise<{ success: boolean }> { + async clearCompleted(playlistId?: string): Promise { if (!this.isAvailable()) { return { success: false }; } diff --git a/libs/shared/interfaces/src/lib/catchup-download.interface.ts b/libs/shared/interfaces/src/lib/catchup-download.interface.ts index 77a664230..97fae434b 100644 --- a/libs/shared/interfaces/src/lib/catchup-download.interface.ts +++ b/libs/shared/interfaces/src/lib/catchup-download.interface.ts @@ -8,3 +8,10 @@ export interface CatchupDownloadMetadata { } export type DownloadContentType = 'vod' | 'episode' | 'catchup'; + +/** Manual recovery is required before a captured unrelated file can be released. */ +export interface DownloadRecoveryResult { + success: boolean; + error?: string; + recoveryPath?: string; +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index f8538671d..4d49d70e4 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1,5 +1,6 @@ import type { CatchupDownloadMetadata, + DownloadRecoveryResult, DownloadContentType, } from './catchup-download.interface'; import { @@ -1296,7 +1297,7 @@ export interface ElectronBridgeApi { downloadsRedownloadMissing: ( downloadId: number ) => Promise; - downloadsRemove: (downloadId: number) => Promise; + downloadsRemove: (downloadId: number) => Promise; downloadsGetList: (playlistId?: string) => Promise; downloadsGet: (downloadId: number) => Promise; downloadsUpdateMetadata: ( @@ -1311,7 +1312,7 @@ export interface ElectronBridgeApi { downloadsPlayFile: (filePath: string) => Promise; downloadsClearCompleted: ( playlistId?: string - ) => Promise; + ) => Promise; onDownloadsUpdate: (callback: () => void) => () => void; // Live-TV recordings surface. Optional: older Electron builds have no // recordings bridge, and `supportsRecordings` (not `supportsDownloads`)