mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(downloads): expose retained archive recovery files
This commit is contained in:
1 parent
0f0e6ebf86
commit
9f91e074aa
31 files changed
+301
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "يلزم استعادة الملف",
|
||||
"MESSAGE": "تم الاحتفاظ بملف غير مرتبط بهذا التنزيل في: {{path}}. استعد المحتوى الذي تحتاجه، ثم احذف نسخة الاستعادة هذه وأعد المحاولة.",
|
||||
"COPY_PATH": "نسخ مسار الاستعادة"
|
||||
},
|
||||
"TITLE": "التنزيلات",
|
||||
"DOWNLOAD": "تنزيل",
|
||||
"PLAY_LOCAL": "تشغيل محلي",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "معلومات",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "خاص استرجاع الملف",
|
||||
"MESSAGE": "تحفظ واحد الملف ما عندوش علاقة بهاد التحميل فـ: {{path}}. رجّع المحتوى اللي محتاج، مسح هاد النسخة الاحتياطية، وعاود جرّب.",
|
||||
"COPY_PATH": "نسخ مسار الاسترجاع"
|
||||
},
|
||||
"TITLE": "التحميلات",
|
||||
"DOWNLOAD": "تحميل",
|
||||
"PLAY_LOCAL": "شغل محلياً",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "Інфармацыя",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "Патрабуецца аднаўленне файла",
|
||||
"MESSAGE": "Файл, не звязаны з гэтай загрузкай, захаваны па шляху: {{path}}. Захавайце патрэбнае змесціва, выдаліце гэтую рэзервовую копію і паўтарыце дзеянне.",
|
||||
"COPY_PATH": "Капіяваць шлях аднаўлення"
|
||||
},
|
||||
"TITLE": "Спампаванні",
|
||||
"DOWNLOAD": "Спампаваць",
|
||||
"PLAY_LOCAL": "Прайграць лакальна",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "Πληροφορίες",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "Απαιτείται ανάκτηση αρχείου",
|
||||
"MESSAGE": "Ένα αρχείο άσχετο με αυτή τη λήψη διατηρήθηκε στη διαδρομή: {{path}}. Ανακτήστε ό,τι χρειάζεστε, διαγράψτε αυτό το αντίγραφο ανάκτησης και δοκιμάστε ξανά.",
|
||||
"COPY_PATH": "Αντιγραφή διαδρομής ανάκτησης"
|
||||
},
|
||||
"TITLE": "Λήψεις",
|
||||
"DOWNLOAD": "Λήψη",
|
||||
"PLAY_LOCAL": "Τοπική αναπαραγωγή",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "情報",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "ファイルの復元が必要です",
|
||||
"MESSAGE": "このダウンロードとは無関係なファイルが {{path}} に保存されています。必要な内容を取り出し、この復元用コピーを削除してから、もう一度お試しください。",
|
||||
"COPY_PATH": "復元パスをコピー"
|
||||
},
|
||||
"TITLE": "ダウンロード",
|
||||
"DOWNLOAD": "ダウンロード",
|
||||
"PLAY_LOCAL": "ローカル再生",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "정보",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "파일 복구 필요",
|
||||
"MESSAGE": "이 다운로드와 관련 없는 파일이 {{path}}에 보관되었습니다. 필요한 내용을 복구하고 이 복구용 사본을 삭제한 후 다시 시도하세요.",
|
||||
"COPY_PATH": "복구 경로 복사"
|
||||
},
|
||||
"TITLE": "다운로드",
|
||||
"DOWNLOAD": "다운로드",
|
||||
"PLAY_LOCAL": "로컬 재생",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "Инфо",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "Требуется восстановление файла",
|
||||
"MESSAGE": "Файл, не относящийся к этой загрузке, сохранён по пути: {{path}}. Сохраните нужное содержимое, удалите эту резервную копию и повторите действие.",
|
||||
"COPY_PATH": "Копировать путь восстановления"
|
||||
},
|
||||
"TITLE": "Загрузки",
|
||||
"DOWNLOAD": "Скачать",
|
||||
"PLAY_LOCAL": "Воспроизвести локально",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "信息",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "需要恢复文件",
|
||||
"MESSAGE": "与此下载无关的文件已保存在:{{path}}。请保存所需内容,删除此恢复副本,然后重试。",
|
||||
"COPY_PATH": "复制恢复路径"
|
||||
},
|
||||
"TITLE": "下载",
|
||||
"DOWNLOAD": "下载",
|
||||
"PLAY_LOCAL": "播放本地文件",
|
||||
|
||||
@@ -1246,6 +1246,11 @@
|
||||
},
|
||||
"INFORMATION": "資訊",
|
||||
"DOWNLOADS": {
|
||||
"RECOVERY": {
|
||||
"TITLE": "需要復原檔案",
|
||||
"MESSAGE": "與此下載無關的檔案已保存在:{{path}}。請儲存所需內容,刪除此復原副本,然後重試。",
|
||||
"COPY_PATH": "複製復原路徑"
|
||||
},
|
||||
"TITLE": "下載",
|
||||
"DOWNLOAD": "下載",
|
||||
"PLAY_LOCAL": "播放本機檔案",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -39,6 +39,8 @@ interface Deferred<T> {
|
||||
}
|
||||
|
||||
interface ConfirmConfig {
|
||||
readonly confirmLabel?: string;
|
||||
readonly cancelLabel?: string;
|
||||
readonly title: string;
|
||||
readonly message: string;
|
||||
readonly onConfirm: () => Promise<void> | 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();
|
||||
|
||||
|
||||
@@ -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<DownloadRecoveryResult> {
|
||||
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<DownloadRecoveryResult> {
|
||||
if (!this.isAvailable()) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<ElectronBridgeDownloadRedownloadResult>;
|
||||
downloadsRemove: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsRemove: (downloadId: number) => Promise<DownloadRecoveryResult>;
|
||||
downloadsGetList: (playlistId?: string) => Promise<ElectronDownloadItem[]>;
|
||||
downloadsGet: (downloadId: number) => Promise<ElectronDownloadItem | null>;
|
||||
downloadsUpdateMetadata: (
|
||||
@@ -1311,7 +1312,7 @@ export interface ElectronBridgeApi {
|
||||
downloadsPlayFile: (filePath: string) => Promise<ElectronBridgeErrorResult>;
|
||||
downloadsClearCompleted: (
|
||||
playlistId?: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
) => Promise<DownloadRecoveryResult>;
|
||||
onDownloadsUpdate: (callback: () => void) => () => void;
|
||||
// Live-TV recordings surface. Optional: older Electron builds have no
|
||||
// recordings bridge, and `supportsRecordings` (not `supportsDownloads`)
|
||||
|
||||
Reference in new issue
Block a user