fix(downloads): expose retained archive recovery files

This commit is contained in:
4gray committed 2026-09-08 08:25:03 +02:00
1 parent 0f0e6ebf86
commit 9f91e074aa
31 files changed
+301 -22

No files matched your search

+3 -1
View File
@@ -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
+3 -1
View File
@@ -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')
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "يلزم استعادة الملف",
"MESSAGE": "تم الاحتفاظ بملف غير مرتبط بهذا التنزيل في: {{path}}. استعد المحتوى الذي تحتاجه، ثم احذف نسخة الاستعادة هذه وأعد المحاولة.",
"COPY_PATH": "نسخ مسار الاستعادة"
},
"TITLE": "التنزيلات",
"DOWNLOAD": "تنزيل",
"PLAY_LOCAL": "تشغيل محلي",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "معلومات",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "خاص استرجاع الملف",
"MESSAGE": "تحفظ واحد الملف ما عندوش علاقة بهاد التحميل فـ: {{path}}. رجّع المحتوى اللي محتاج، مسح هاد النسخة الاحتياطية، وعاود جرّب.",
"COPY_PATH": "نسخ مسار الاسترجاع"
},
"TITLE": "التحميلات",
"DOWNLOAD": "تحميل",
"PLAY_LOCAL": "شغل محلياً",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "Інфармацыя",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Патрабуецца аднаўленне файла",
"MESSAGE": "Файл, не звязаны з гэтай загрузкай, захаваны па шляху: {{path}}. Захавайце патрэбнае змесціва, выдаліце гэтую рэзервовую копію і паўтарыце дзеянне.",
"COPY_PATH": "Капіяваць шлях аднаўлення"
},
"TITLE": "Спампаванні",
"DOWNLOAD": "Спампаваць",
"PLAY_LOCAL": "Прайграць лакальна",
+5
View File
@@ -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",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "Πληροφορίες",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Απαιτείται ανάκτηση αρχείου",
"MESSAGE": "Ένα αρχείο άσχετο με αυτή τη λήψη διατηρήθηκε στη διαδρομή: {{path}}. Ανακτήστε ό,τι χρειάζεστε, διαγράψτε αυτό το αντίγραφο ανάκτησης και δοκιμάστε ξανά.",
"COPY_PATH": "Αντιγραφή διαδρομής ανάκτησης"
},
"TITLE": "Λήψεις",
"DOWNLOAD": "Λήψη",
"PLAY_LOCAL": "Τοπική αναπαραγωγή",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "情報",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "ファイルの復元が必要です",
"MESSAGE": "このダウンロードとは無関係なファイルが {{path}} に保存されています。必要な内容を取り出し、この復元用コピーを削除してから、もう一度お試しください。",
"COPY_PATH": "復元パスをコピー"
},
"TITLE": "ダウンロード",
"DOWNLOAD": "ダウンロード",
"PLAY_LOCAL": "ローカル再生",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "정보",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "파일 복구 필요",
"MESSAGE": "이 다운로드와 관련 없는 파일이 {{path}}에 보관되었습니다. 필요한 내용을 복구하고 이 복구용 사본을 삭제한 후 다시 시도하세요.",
"COPY_PATH": "복구 경로 복사"
},
"TITLE": "다운로드",
"DOWNLOAD": "다운로드",
"PLAY_LOCAL": "로컬 재생",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "Инфо",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "Требуется восстановление файла",
"MESSAGE": "Файл, не относящийся к этой загрузке, сохранён по пути: {{path}}. Сохраните нужное содержимое, удалите эту резервную копию и повторите действие.",
"COPY_PATH": "Копировать путь восстановления"
},
"TITLE": "Загрузки",
"DOWNLOAD": "Скачать",
"PLAY_LOCAL": "Воспроизвести локально",
+5
View File
@@ -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",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "信息",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "需要恢复文件",
"MESSAGE": "与此下载无关的文件已保存在:{{path}}。请保存所需内容,删除此恢复副本,然后重试。",
"COPY_PATH": "复制恢复路径"
},
"TITLE": "下载",
"DOWNLOAD": "下载",
"PLAY_LOCAL": "播放本地文件",
+5
View File
@@ -1246,6 +1246,11 @@
},
"INFORMATION": "資訊",
"DOWNLOADS": {
"RECOVERY": {
"TITLE": "需要復原檔案",
"MESSAGE": "與此下載無關的檔案已保存在:{{path}}。請儲存所需內容,刪除此復原副本,然後重試。",
"COPY_PATH": "複製復原路徑"
},
"TITLE": "下載",
"DOWNLOAD": "下載",
"PLAY_LOCAL": "播放本機檔案",
+3 -2
View File
@@ -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();
+3 -4
View File
@@ -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`)