mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
feat(downloads): finish missing-file recovery
This commit is contained in:
1 parent
5808511658
commit
51d80c5c0c
41 files changed
+358
-208
No files matched your search
@@ -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.
|
||||
@@ -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(() => ({
|
||||
|
||||
+4
-4
@@ -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 })),
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "التحكم عن بعد",
|
||||
|
||||
@@ -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": "Аддаленае кіраванне",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Τηλεχειριστήριο",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "リモートコントロール",
|
||||
|
||||
@@ -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": "원격 제어",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Дистанционное управление",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "远程控制",
|
||||
|
||||
@@ -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": "遠端控制",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user