feat(downloads): finish missing-file recovery

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent 5808511658
commit 51d80c5c0c
41 files changed
+358 -208

No files matched your search

+5 -5
View File
@@ -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.
+76 -2
View File
@@ -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')
@@ -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');
});
});
@@ -9,10 +9,9 @@ interface DownloadFileRow {
status: ElectronBridgeDownloadStatus;
}
export type DownloadLstat = (filePath: string) => Pick<
Stats,
'isFile' | 'isSymbolicLink'
>;
export type DownloadLstat = (
filePath: string
) => Pick<Stats, 'isFile' | 'isSymbolicLink'>;
export function isAvailableDownloadFile(
filePath: string | null | undefined,
@@ -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(() => ({
@@ -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();
});
});
@@ -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({
@@ -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 })),
+6 -3
View File
@@ -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": "التحكم عن بعد",
+6 -3
View File
@@ -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": "التحكم عن بعد",
+6 -3
View File
@@ -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": "Аддаленае кіраванне",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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": "Τηλεχειριστήριο",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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": "リモートコントロール",
+6 -3
View File
@@ -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": "원격 제어",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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": "Дистанционное управление",
+6 -3
View File
@@ -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",
+6 -3
View File
@@ -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": "远程控制",
+6 -3
View File
@@ -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": "遠端控制",
+30 -7
View File
@@ -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.
@@ -176,8 +176,7 @@
type="button"
[matMenuTriggerFor]="seriesMenu"
[attr.aria-label]="
('DOWNLOADS.MORE_ACTIONS'
| translate) +
('DOWNLOADS.MORE_ACTIONS' | translate) +
': ' +
entity.title
"
@@ -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(
@@ -195,9 +195,7 @@
| translate
: { title: row.item.title }
"
(click)="
emitAction('resume', row.item)
"
(click)="emitAction('resume', row.item)"
>
<mat-icon>play_arrow</mat-icon>
</button>
@@ -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();
@@ -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';
@@ -579,10 +579,9 @@ describe('DownloadsComponent', () => {
downloads.set([item]);
fixture.detectChanges();
const attention =
fixture.nativeElement.querySelector<HTMLElement>(
'[data-test-id="downloads-attention-section"]'
);
const attention = fixture.nativeElement.querySelector<HTMLElement>(
'[data-test-id="downloads-attention-section"]'
);
const recover = attention?.querySelector<HTMLButtonElement>(
'[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 });
@@ -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({
+26
View File
@@ -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;
}
@@ -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')
+4 -30
View File
@@ -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);
}
}
+11
View File
@@ -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]
);
}
@@ -279,8 +279,7 @@ export class RuntimeCapabilitiesService {
private hasElectronMethod(methodName: string): boolean {
const bridge = this.electronBridge as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
return typeof bridge?.[methodName] === 'function';
}
@@ -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<ElectronBridgeResult>;
dbClearVodSourcePin: (
matchKeys: string[]
) => Promise<ElectronBridgeResult>;
dbClearVodSourcePin: (matchKeys: string[]) => Promise<ElectronBridgeResult>;
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => () => void;