diff --git a/.changes/downloads-manager-mvp.md b/.changes/downloads-manager-mvp.md index d42954a78..43c73c335 100644 --- a/.changes/downloads-manager-mvp.md +++ b/.changes/downloads-manager-mvp.md @@ -3,8 +3,8 @@ type: feature area: downloads --- -Downloads now separate active transfers from an offline poster library that -follows your cover-size setting and groups completed episodes by series. Movie -and series cards open details, while Play starts the local file and downloaded -details prioritize offline playback. Legacy episodes without a usable series -ID remain directly playable. +Downloads now separate active transfers from a cover-size-aware offline +library and group completed episodes by series. Missing files move to Needs +attention with a safe Download again action, while ready cards open provider +details and keep local Play explicit. Card source details now live in the +overflow menu. diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index 12a6d7c57..a88a44fdb 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -1,4 +1,11 @@ -import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, +} from 'fs'; import { createServer } from 'http'; import type { AddressInfo } from 'net'; import { join } from 'path'; @@ -419,6 +426,60 @@ test.describe('Electron Downloads', () => { name: 'Show in Folder: E2E Movie', }) ).toBeVisible(); + await expect( + card.getByText('Offline', { exact: true }) + ).toHaveCount(0); + await expect( + card.getByText('Download Portal', { exact: true }) + ).toHaveCount(0); + await card + .getByRole('button', { + name: 'More actions: E2E Movie', + }) + .click(); + const sourceHeader = app.mainWindow.locator( + '.cdk-overlay-pane app-download-source-menu-header' + ); + await expect( + sourceHeader.getByText('Source', { exact: true }) + ).toBeVisible(); + await expect(sourceHeader).toContainText('Download Portal'); + await app.mainWindow.keyboard.press('Escape'); + + // Removing the finalized file must move the persisted completed + // row out of Ready to watch after a file action refreshes the + // authoritative list. + unlinkSync(finalPath); + await card + .getByRole('button', { name: 'Play: E2E Movie' }) + .last() + .click(); + const missingRow = app.mainWindow.getByTestId( + `download-queue-item-${startResult?.id}` + ); + await expect(missingRow).toBeVisible({ timeout: 20000 }); + await expect( + missingRow.locator('.download-queue__status') + ).toContainText('File missing'); + await expect(card).toHaveCount(0); + await expect( + missingRow.getByRole('button', { name: 'Play: E2E Movie' }) + ).toHaveCount(0); + await expect( + missingRow.getByRole('button', { + name: 'Show in Folder: E2E Movie', + }) + ).toHaveCount(0); + + await missingRow + .getByRole('button', { + name: 'Download E2E Movie again', + }) + .click(); + await expect(card).toBeVisible({ timeout: 20000 }); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); await card .getByRole('button', { @@ -674,7 +735,20 @@ test.describe('Electron Downloads', () => { `download-library-series-${sourceA}-${seriesXtreamId}` ); await expect(seriesCard).toContainText('2 episodes'); - await expect(seriesCard).toContainText('Library Source A'); + await expect( + seriesCard.getByText('Library Source A', { exact: true }) + ).toHaveCount(0); + await seriesCard + .getByRole('button', { + name: 'More actions: Aurora', + }) + .click(); + await expect( + app.mainWindow.locator( + '.cdk-overlay-pane app-download-source-menu-header' + ) + ).toContainText('Library Source A'); + await app.mainWindow.keyboard.press('Escape'); await app.mainWindow .getByTestId('downloads-filter-in-progress') diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts index 6ed2950db..0bcccee8f 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.spec.ts @@ -28,9 +28,9 @@ describe('download file availability', () => { lstat ) ).toBe('available'); - expect( - isAvailableDownloadFile('/downloads/movie.mp4', lstat) - ).toBe(true); + expect(isAvailableDownloadFile('/downloads/movie.mp4', lstat)).toBe( + true + ); }); it.each([ @@ -43,11 +43,7 @@ describe('download file availability', () => { throw new Error('ENOENT'); }) as DownloadLstat, ], - [ - 'directory', - '/downloads/folder', - lstatResult({ isFile: false }), - ], + ['directory', '/downloads/folder', lstatResult({ isFile: false })], [ 'symbolic link', '/downloads/link.mp4', @@ -86,12 +82,12 @@ describe('download file availability', () => { title: 'Movie', }); - expect(decorateDownloadItem(row, lstatResult({ isFile: true }))).toEqual( - { - ...row, - fileAvailability: 'available', - } - ); + expect( + decorateDownloadItem(row, lstatResult({ isFile: true })) + ).toEqual({ + ...row, + fileAvailability: 'available', + }); expect(row).not.toHaveProperty('fileAvailability'); }); }); diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts index 53ac05838..9cfd1eb24 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -9,10 +9,9 @@ interface DownloadFileRow { status: ElectronBridgeDownloadStatus; } -export type DownloadLstat = (filePath: string) => Pick< - Stats, - 'isFile' | 'isSymbolicLink' ->; +export type DownloadLstat = ( + filePath: string +) => Pick; export function isAvailableDownloadFile( filePath: string | null | undefined, diff --git a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts index e2b62ea75..1514e50b2 100644 --- a/apps/electron-backend/src/app/events/database/download-redownload.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-redownload.spec.ts @@ -26,9 +26,7 @@ async function setup(options: SetupOptions = {}) { ...options.row, }; const limit = jest.fn().mockResolvedValue(row ? [row] : []); - const where = jest - .fn() - .mockResolvedValue(options.claim ?? { changes: 1 }); + const where = jest.fn().mockResolvedValue(options.claim ?? { changes: 1 }); const set = jest.fn(() => ({ where })); const db = { select: jest.fn(() => ({ diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts index cb08b0ab4..747939561 100644 --- a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -48,9 +48,7 @@ describe('downloads events: file availability', () => { return regularFile(); }); - await expect( - getHandler('DOWNLOADS_GET_LIST')(null) - ).resolves.toEqual([ + await expect(getHandler('DOWNLOADS_GET_LIST')(null)).resolves.toEqual([ { ...rows[0], fileAvailability: 'available' }, { ...rows[1], fileAvailability: 'missing' }, { ...rows[2], fileAvailability: 'not-applicable' }, @@ -94,7 +92,9 @@ describe('downloads events: file availability', () => { })), }); - await expect(getHandler('DOWNLOADS_GET')(null, 404)).resolves.toBeNull(); + await expect( + getHandler('DOWNLOADS_GET')(null, 404) + ).resolves.toBeNull(); expect(mockLstatSync).not.toHaveBeenCalled(); }); }); diff --git a/apps/electron-backend/src/app/events/database/downloads.events.ts b/apps/electron-backend/src/app/events/database/downloads.events.ts index 71fc37ceb..c1ad4f7be 100644 --- a/apps/electron-backend/src/app/events/database/downloads.events.ts +++ b/apps/electron-backend/src/app/events/database/downloads.events.ts @@ -176,7 +176,7 @@ ipcMain.handle('DOWNLOADS_REMOVE', async (_event, downloadId: number) => { .select({ filePath: schema.downloads.filePath, status: schema.downloads.status, - }) + }) .from(schema.downloads) .where(eq(schema.downloads.id, downloadId)) .limit(1); @@ -294,7 +294,10 @@ ipcMain.handle( 'canceled', ]); const terminalFilter = playlistId - ? and(eq(schema.downloads.playlistId, playlistId), terminalStatus) + ? and( + eq(schema.downloads.playlistId, playlistId), + terminalStatus + ) : terminalStatus; const rows = await db .select({ diff --git a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts index cd4067bc3..5161cf7c1 100644 --- a/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts +++ b/apps/electron-backend/src/app/events/database/downloads.test-helpers.ts @@ -152,14 +152,17 @@ export function expectManagedPathLookup( expect(mockEq).toHaveBeenCalledTimes(1); expect(mockEq.mock.calls[0][0] === downloadsFilePathColumn).toBe(true); expect(mockEq.mock.calls[0][1]).toBe(filePath); - expect( - lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value - ).toBe(true); + expect(lookup.where.mock.calls[0][0] === mockEq.mock.results[0].value).toBe( + true + ); expect(lookup.limit).toHaveBeenCalledTimes(1); expect(lookup.limit).toHaveBeenCalledWith(1); } -export function mockDownloadRow(row: { filePath: string | null; status: string }) { +export function mockDownloadRow(row: { + filePath: string | null; + status: string; +}) { const deleteWhere = jest.fn().mockResolvedValue(undefined); const db = { delete: jest.fn(() => ({ where: deleteWhere })), diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 2de4ee4f1..650591f2a 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1084,7 +1084,8 @@ "COMPLETED": "مكتمل", "FAILED": "فشل", "CANCELED": "ملغى", - "PAUSED": "متوقف مؤقتًا" + "PAUSED": "متوقف مؤقتًا", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 0b0c98a22..be4f3da89 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1084,7 +1084,8 @@ "COMPLETED": "كمل", "FAILED": "فشل", "CANCELED": "تلغى", - "PAUSED": "موقوف مؤقتاً" + "PAUSED": "موقوف مؤقتاً", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "التحكم عن بعد", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e91889290..b39e92710 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Завершана", "FAILED": "Няўдала", "CANCELED": "Скасавана", - "PAUSED": "На паўзе" + "PAUSED": "На паўзе", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Аддаленае кіраванне", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 5b8dadf6c..1da6c2f96 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Abgeschlossen", "FAILED": "Fehlgeschlagen", "CANCELED": "Abgebrochen", - "PAUSED": "Pausiert" + "PAUSED": "Pausiert", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Fernbedienung", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 175c855f1..6766a232d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Ολοκληρώθηκε", "FAILED": "Απέτυχε", "CANCELED": "Ακυρώθηκε", - "PAUSED": "Σε παύση" + "PAUSED": "Σε παύση", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Τηλεχειριστήριο", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index b1423c060..a8e7fb43b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Completed", "FAILED": "Failed", "CANCELED": "Canceled", - "PAUSED": "Paused" + "PAUSED": "Paused", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Remote control", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 48426b2c3..54562a378 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Completado", "FAILED": "Fallido", "CANCELED": "Cancelado", - "PAUSED": "En pausa" + "PAUSED": "En pausa", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Control remoto", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 6d58350f2..5e4f78682 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Terminé", "FAILED": "Échec", "CANCELED": "Annulé", - "PAUSED": "En pause" + "PAUSED": "En pause", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Télécommande", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 0a3c8fa7b..382e635aa 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Befejeződött", "FAILED": "Sikertelen", "CANCELED": "Megszakítva", - "PAUSED": "Paused" + "PAUSED": "Paused", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Távvezérlés", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 55cdbfb89..65fe47395 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Completato", "FAILED": "Non riuscito", "CANCELED": "Annullato", - "PAUSED": "In pausa" + "PAUSED": "In pausa", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Controllo remoto", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index c4d7566a2..5965dee1f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1084,7 +1084,8 @@ "COMPLETED": "完了", "FAILED": "失敗", "CANCELED": "キャンセル済み", - "PAUSED": "一時停止中" + "PAUSED": "一時停止中", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "リモートコントロール", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 14e876048..c89826023 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1084,7 +1084,8 @@ "COMPLETED": "완료됨", "FAILED": "실패", "CANCELED": "취소됨", - "PAUSED": "일시정지됨" + "PAUSED": "일시정지됨", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "원격 제어", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 2337d9d2d..0976d1d35 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Voltooid", "FAILED": "Mislukt", "CANCELED": "Geannuleerd", - "PAUSED": "Gepauzeerd" + "PAUSED": "Gepauzeerd", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Afstandsbediening", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index ec332808e..505ac7c78 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Ukończone", "FAILED": "Niepowodzenie", "CANCELED": "Anulowane", - "PAUSED": "Wstrzymane" + "PAUSED": "Wstrzymane", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Zdalne sterowanie", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f5277c648..7e5a87c9b 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Concluído", "FAILED": "Falhou", "CANCELED": "Cancelado", - "PAUSED": "Pausado" + "PAUSED": "Pausado", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Controle remoto", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index bc8334209..2389eeb68 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Завершено", "FAILED": "Ошибка", "CANCELED": "Отменено", - "PAUSED": "На паузе" + "PAUSED": "На паузе", + "FILE_MISSING": "Файл отсутствует" }, "ACTIVE_COUNT": "{{count}} активн.", "TRACKED_DOWNLOADS": "Объём загрузок", @@ -1137,8 +1138,10 @@ "REMOVE": "Убрать {{title}} из менеджера", "RETRY": "Повторить {{title}}", "MORE_ACTIONS": "Другие действия для {{title}}", - "COPY_URL": "Копировать URL загрузки {{title}}" - } + "COPY_URL": "Копировать URL загрузки {{title}}", + "DOWNLOAD_AGAIN": "Скачать {{title}} заново" + }, + "DOWNLOAD_AGAIN": "Скачать заново" }, "REMOTE_CONTROL": { "TITLE": "Дистанционное управление", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index bf3ab6faf..2b1ec5033 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1084,7 +1084,8 @@ "COMPLETED": "Tamamlandı", "FAILED": "Başarısız", "CANCELED": "İptal Edildi", - "PAUSED": "Duraklatıldı" + "PAUSED": "Duraklatıldı", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "Uzaktan kontrol", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 92c0a3832..46d588637 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1084,7 +1084,8 @@ "COMPLETED": "已完成", "FAILED": "失败", "CANCELED": "已取消", - "PAUSED": "已暂停" + "PAUSED": "已暂停", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "远程控制", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 49185c449..96c4dce98 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1084,7 +1084,8 @@ "COMPLETED": "已完成", "FAILED": "失敗", "CANCELED": "已取消", - "PAUSED": "已暫停" + "PAUSED": "已暫停", + "FILE_MISSING": "File missing" }, "ACTIVE_COUNT": "{{count}} active", "TRACKED_DOWNLOADS": "Tracked downloads", @@ -1137,8 +1138,10 @@ "REMOVE": "Remove {{title}} from manager", "RETRY": "Retry {{title}}", "MORE_ACTIONS": "More actions for {{title}}", - "COPY_URL": "Copy download URL for {{title}}" - } + "COPY_URL": "Copy download URL for {{title}}", + "DOWNLOAD_AGAIN": "Download {{title}} again" + }, + "DOWNLOAD_AGAIN": "Download again" }, "REMOTE_CONTROL": { "TITLE": "遠端控制", diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 981405b71..a1c1069f5 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -29,8 +29,21 @@ variants, contextual buttons, and theme-aware styling. without re-downloading); pause and restart recovery keep it for a later resume. Re-downloading such a failed row from a detail page (`DOWNLOADS_START`) deletes the retained `.part` before the row is reset. +- **Derived file readiness and recovery** + `DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on + every read. Only regular, non-symbolic-link files are reported as available; + missing paths, directories, symlinks, and inspection errors are reported as + missing without changing the persisted `completed` transfer status. + `DOWNLOADS_REDOWNLOAD_MISSING` accepts only the managed row id, rechecks the + file, and returns a recovered result without network access when it has + reappeared. Otherwise it conditionally claims the completed row, preserves + its owned destination, re-applies the stored header allowlist and URL safety + policy, and enqueues a fresh transfer without overwriting an existing file. - **IPC surface** - The backend exposes `DOWNLOADS_*` handlers for list retrieval, start/pause/resume/cancel/retry/remove operations, folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the renderer listens to in order to refresh its signal store. + The backend exposes `DOWNLOADS_*` handlers for list retrieval, + start/pause/resume/cancel/retry/missing-file recovery/remove operations, + folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the + renderer listens to in order to refresh its signal store. ## Renderer architecture @@ -48,8 +61,10 @@ variants, contextual buttons, and theme-aware styling. derives the current route scope, search/category filtering, queue partitions, stable ordering, counts, and tracked byte total without mutating service state. `queued`, `downloading`, and `paused` rows form the active surface; - `failed` and `canceled` rows form the attention surface; only `completed` - rows enter the offline library. Completed episodes with a valid + `failed` and `canceled` rows form the attention surface. A `completed` row + whose derived file availability is missing also enters attention with a + dedicated recovery reason; only available completed rows enter the offline + library. Completed episodes with a valid `seriesXtreamId` are grouped by `(playlistId, seriesXtreamId)`, ordered by season and episode, and represented by one poster card. Episodes without a usable series id remain standalone cards so they never disappear. @@ -67,7 +82,9 @@ variants, contextual buttons, and theme-aware styling. cards and their loading skeleton consume the global `--cover-grid-min-width` / `--cover-gap` tokens, so the Small, Medium, and Large cover preference behaves like it does elsewhere in the app. All - surfaces use the existing `--app-*` and Material system tokens. The global + surfaces use the existing `--app-*` and Material system tokens. Ready cards + do not repeat an Offline badge; source provenance is available at the top of + their overflow menus. The global workspace download shortcut displays the service's active count, while the page badge and filter counts reflect the current route scope. - **Honest interactions** @@ -83,14 +100,20 @@ variants, contextual buttons, and theme-aware styling. titles on completed movie and grouped-series cards open the provider detail page; the explicit Play action starts the local file. A legacy standalone episode without a usable series id stays directly playable because there is - no reliable detail route to open. + no reliable detail route to open. Missing completed rows show `File missing` + with `Download again`; Play and Show in folder are withheld. A file-action + race that returns `File not found` refreshes the authoritative list before + pending state clears. ## Offline detail playback -- A completed VOD renders an Offline tag on both rich and fallback detail - shells. Its primary action plays the downloaded file, while a neutral +- A completed VOD with an available finalized file renders an Offline tag on + both rich and fallback detail shells. Its primary action plays the downloaded + file, while a neutral “Play from this source” action, when a usable provider source is available, preserves the existing provider, pinned-source, resume, and restart path. +- A completed row whose file is missing does not advertise Offline or local + playback on details; the provider playback path remains primary. - Managed MPV/VLC state remains authoritative: Opening disables conflicting playback actions, and Stop closes the matched external session before any local or provider choice can run. diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.html b/libs/portal/downloads/feature/src/lib/download-library.component.html index ef9e46df5..09081d888 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -176,8 +176,7 @@ type="button" [matMenuTriggerFor]="seriesMenu" [attr.aria-label]=" - ('DOWNLOADS.MORE_ACTIONS' - | translate) + + ('DOWNLOADS.MORE_ACTIONS' | translate) + ': ' + entity.title " diff --git a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts index 914c76411..3c14397ec 100644 --- a/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts +++ b/libs/portal/downloads/feature/src/lib/download-manager.viewmodel.ts @@ -182,8 +182,7 @@ export function buildDownloadManagerViewModel({ })); const scopedActive = scopedRows.filter(({ item }) => isActive(item)); const scopedAttention = scopedRows.filter( - ({ item }) => - needsAttention(item) || isMissingCompletedFile(item) + ({ item }) => needsAttention(item) || isMissingCompletedFile(item) ); const scopedLibrary = buildDownloadLibrary( scopedRows.filter(({ item }) => isReady(item)) @@ -197,8 +196,7 @@ export function buildDownloadManagerViewModel({ .sort(compareQueued); const attention = displayed .filter( - ({ item }) => - needsAttention(item) || isMissingCompletedFile(item) + ({ item }) => needsAttention(item) || isMissingCompletedFile(item) ) .sort(compareQueued); const library = buildDownloadLibrary( diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.html b/libs/portal/downloads/feature/src/lib/download-queue.component.html index 5a7918b57..b1d41b122 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -195,9 +195,7 @@ | translate : { title: row.item.title } " - (click)=" - emitAction('resume', row.item) - " + (click)="emitAction('resume', row.item)" > play_arrow diff --git a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts index a91f76ab6..e2aa41708 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.spec.ts @@ -236,9 +236,7 @@ describe('DownloadQueueComponent', () => { expect(host.querySelector('.download-queue__source')).toBeNull(); actionButton(host, 'redownload').click(); - expect(emitted).toEqual([ - { type: 'redownload', item: viewModel.item }, - ]); + expect(emitted).toEqual([{ type: 'redownload', item: viewModel.item }]); actionButton(host, 'more').click(); fixture.detectChanges(); diff --git a/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts index f06bd6124..aba611dae 100644 --- a/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-source-menu-header.component.ts @@ -1,8 +1,4 @@ -import { - ChangeDetectionStrategy, - Component, - input, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; 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 b313352d6..8f686f99f 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -579,10 +579,9 @@ describe('DownloadsComponent', () => { downloads.set([item]); fixture.detectChanges(); - const attention = - fixture.nativeElement.querySelector( - '[data-test-id="downloads-attention-section"]' - ); + const attention = fixture.nativeElement.querySelector( + '[data-test-id="downloads-attention-section"]' + ); const recover = attention?.querySelector( '[data-test-action="redownload"]' ); @@ -592,9 +591,9 @@ describe('DownloadsComponent', () => { recover?.click(); fixture.detectChanges(); expect(downloadsService.redownloadMissing).toHaveBeenCalledWith(16); - expect( - downloadsService.redownloadMissing.mock.calls[0] - ).toHaveLength(1); + expect(downloadsService.redownloadMissing.mock.calls[0]).toHaveLength( + 1 + ); expect(component.pendingIds().has(16)).toBe(true); recovery.resolve({ success: true }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index 65d0d27ce..922fa5df5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -137,43 +137,47 @@ describe('VodDetailsRouteComponent fallback actions', () => { isEmbeddedPlayer.mockReset().mockReturnValue(true); openResolvedPlayback.mockClear(); startDownload.mockClear(); - isDownloaded.mockReset().mockImplementation( - ( - xtreamId: number, - playlistId: string, - contentType: 'vod' | 'episode' - ) => { - const item = downloads().find( - (download) => - download.xtreamId === xtreamId && - download.playlistId === playlistId && - download.contentType === contentType - ); - return ( - item?.status === 'completed' && - !!item.filePath && - item.fileAvailability !== 'missing' - ); - } - ); - getDownloadedFilePath.mockReset().mockImplementation( - ( - xtreamId: number, - playlistId: string, - contentType: 'vod' | 'episode' - ) => { - const item = downloads().find( - (download) => - download.xtreamId === xtreamId && - download.playlistId === playlistId && - download.contentType === contentType - ); - return item?.status === 'completed' && - item.fileAvailability !== 'missing' - ? item.filePath - : undefined; - } - ); + isDownloaded + .mockReset() + .mockImplementation( + ( + xtreamId: number, + playlistId: string, + contentType: 'vod' | 'episode' + ) => { + const item = downloads().find( + (download) => + download.xtreamId === xtreamId && + download.playlistId === playlistId && + download.contentType === contentType + ); + return ( + item?.status === 'completed' && + !!item.filePath && + item.fileAvailability !== 'missing' + ); + } + ); + getDownloadedFilePath + .mockReset() + .mockImplementation( + ( + xtreamId: number, + playlistId: string, + contentType: 'vod' | 'episode' + ) => { + const item = downloads().find( + (download) => + download.xtreamId === xtreamId && + download.playlistId === playlistId && + download.contentType === contentType + ); + return item?.status === 'completed' && + item.fileAvailability !== 'missing' + ? item.filePath + : undefined; + } + ); playDownload.mockClear(); toggleFavorite.mockClear(); await TestBed.configureTestingModule({ diff --git a/libs/services/src/lib/downloads.models.ts b/libs/services/src/lib/downloads.models.ts new file mode 100644 index 000000000..339be7098 --- /dev/null +++ b/libs/services/src/lib/downloads.models.ts @@ -0,0 +1,26 @@ +import type { ElectronDownloadFileAvailability } from '@iptvnator/shared/interfaces'; + +export type DownloadStatus = + 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; + +export interface DownloadItem { + id: number; + playlistId: string; + xtreamId: number; + contentType: 'vod' | 'episode'; + seriesXtreamId?: number; + seasonNumber?: number; + episodeNumber?: number; + title: string; + url: string; + fileName?: string; + filePath?: string; + fileAvailability?: ElectronDownloadFileAvailability; + posterUrl?: string; + status: DownloadStatus; + bytesDownloaded?: number; + totalBytes?: number; + errorMessage?: string; + createdAt?: string; + updatedAt?: string; +} diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 61cc4731d..f065fe273 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -214,8 +214,8 @@ describe('DownloadsService', () => { it('re-downloads a missing completed file by managed id', async () => { const electron = { downloadsGetList: jest.fn(async () => []), - downloadsRedownloadMissing: jest.fn(async (_downloadId: number) => ({ - success: true, + downloadsRedownloadMissing: jest.fn(async (downloadId: number) => ({ + success: downloadId === 42, })), }; testWindow.electron = electron; @@ -323,9 +323,9 @@ describe('DownloadsService', () => { ]) as unknown as DownloadsService; expect(service.isDownloaded(20, 'playlist-1', 'vod')).toBe(true); - expect( - service.getDownloadedFilePath(20, 'playlist-1', 'vod') - ).toBe('/downloads/available.mp4'); + expect(service.getDownloadedFilePath(20, 'playlist-1', 'vod')).toBe( + '/downloads/available.mp4' + ); expect(service.isDownloaded(21, 'playlist-1', 'vod')).toBe(false); expect( service.getDownloadedFilePath(21, 'playlist-1', 'vod') diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index e68bbd0e2..3bc8bb3ad 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -1,31 +1,9 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; -import type { ElectronDownloadFileAvailability } from '@iptvnator/shared/interfaces'; +import type { DownloadItem } from './downloads.models'; +import { formatDownloadBytes } from './downloads.utils'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; -export type DownloadStatus = - 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; - -export interface DownloadItem { - id: number; - playlistId: string; - xtreamId: number; - contentType: 'vod' | 'episode'; - seriesXtreamId?: number; - seasonNumber?: number; - episodeNumber?: number; - title: string; - url: string; - fileName?: string; - filePath?: string; - fileAvailability?: ElectronDownloadFileAvailability; - posterUrl?: string; - status: DownloadStatus; - bytesDownloaded?: number; - totalBytes?: number; - errorMessage?: string; - createdAt?: string; - updatedAt?: string; -} +export type { DownloadItem, DownloadStatus } from './downloads.models'; @Injectable({ providedIn: 'root' }) export class DownloadsService implements OnDestroy { @@ -551,10 +529,6 @@ export class DownloadsService implements OnDestroy { * Format bytes to human readable string */ formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; + return formatDownloadBytes(bytes); } } diff --git a/libs/services/src/lib/downloads.utils.ts b/libs/services/src/lib/downloads.utils.ts new file mode 100644 index 000000000..ec5257953 --- /dev/null +++ b/libs/services/src/lib/downloads.utils.ts @@ -0,0 +1,11 @@ +export function formatDownloadBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const kilobyte = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const index = Math.floor(Math.log(bytes) / Math.log(kilobyte)); + return ( + parseFloat((bytes / Math.pow(kilobyte, index)).toFixed(1)) + + ' ' + + sizes[index] + ); +} diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 5f76ade69..0ebb4ecf3 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -279,8 +279,7 @@ export class RuntimeCapabilitiesService { private hasElectronMethod(methodName: string): boolean { const bridge = this.electronBridge as - | Record - | undefined; + Record | undefined; return typeof bridge?.[methodName] === 'function'; } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 3512d0101..7b7c4704a 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -138,9 +138,7 @@ export type ElectronBridgeDownloadStatus = (typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES)[keyof typeof ELECTRON_BRIDGE_DOWNLOAD_STATUSES]; export type ElectronDownloadFileAvailability = - | 'available' - | 'missing' - | 'not-applicable'; + 'available' | 'missing' | 'not-applicable'; export const ELECTRON_BRIDGE_APP_UPDATE_STATUSES = { Unsupported: 'unsupported', @@ -391,8 +389,7 @@ export interface ElectronBridgePlaylistRow { } export type ElectronBridgePlaylistUpsertInput = - | Playlist - | ElectronBridgePlaylistInput; + Playlist | ElectronBridgePlaylistInput; export interface ElectronBridgeCategoryRow { id: number; @@ -938,9 +935,7 @@ export interface ElectronBridgeApi { playlistId: string, pins: VodSourcePin[] ) => Promise; - dbClearVodSourcePin: ( - matchKeys: string[] - ) => Promise; + dbClearVodSourcePin: (matchKeys: string[]) => Promise; onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void ) => () => void;