From bcc2e36a052221e1a47d732c8d6c8c98d1be3e07 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 30 Jul 2026 03:13:42 +0200 Subject: [PATCH] feat(downloads): finish manager MVP --- .changes/downloads-manager-mvp.md | 8 + .../electron-backend-e2e/src/downloads.e2e.ts | 698 ++++++++++++- apps/web/src/assets/i18n/ar.json | 53 + apps/web/src/assets/i18n/ary.json | 53 + apps/web/src/assets/i18n/by.json | 53 + apps/web/src/assets/i18n/de.json | 53 + apps/web/src/assets/i18n/el.json | 53 + apps/web/src/assets/i18n/en.json | 53 + apps/web/src/assets/i18n/es.json | 53 + apps/web/src/assets/i18n/fr.json | 53 + apps/web/src/assets/i18n/hu.json | 53 + apps/web/src/assets/i18n/it.json | 53 + apps/web/src/assets/i18n/ja.json | 53 + apps/web/src/assets/i18n/ko.json | 53 + apps/web/src/assets/i18n/nl.json | 53 + apps/web/src/assets/i18n/pl.json | 53 + apps/web/src/assets/i18n/pt.json | 53 + apps/web/src/assets/i18n/ru.json | 53 + apps/web/src/assets/i18n/tr.json | 53 + apps/web/src/assets/i18n/zh.json | 53 + apps/web/src/assets/i18n/zhtw.json | 53 + docs/architecture/download-manager.md | 69 +- .../plans/2026-07-30-download-manager-mvp.md | 117 +-- .../2026-07-30-download-manager-mvp-design.md | 46 +- ...ownload-library-navigation.service.spec.ts | 31 +- .../download-library-navigation.service.ts | 6 +- .../src/lib/download-library.component.html | 614 +++++++----- .../src/lib/download-library.component.scss | 51 +- .../lib/download-library.component.spec.ts | 82 +- .../src/lib/download-library.component.ts | 15 +- .../lib/download-manager-actions.service.ts | 185 ++++ .../src/lib/download-queue.component.html | 4 + .../src/lib/download-queue.component.scss | 15 +- .../src/lib/download-queue.component.spec.ts | 90 +- .../downloaded-series-dialog.component.html | 20 +- .../downloaded-series-dialog.component.scss | 17 +- ...downloaded-series-dialog.component.spec.ts | 71 +- .../lib/downloaded-series-dialog.component.ts | 25 +- .../feature/src/lib/downloads.component.html | 626 ++++-------- .../feature/src/lib/downloads.component.scss | 918 ++++++++---------- .../src/lib/downloads.component.spec.ts | 637 ++++++++++++ .../feature/src/lib/downloads.component.ts | 516 ++++------ .../src/lib/downloads.service.spec.ts | 19 +- libs/services/src/lib/downloads.service.ts | 59 +- .../workspace-shell-header.component.html | 4 +- .../workspace-shell-header.component.spec.ts | 16 +- .../workspace-shell.component.html | 15 +- .../workspace-shell.component.spec.ts | 8 +- 48 files changed, 4145 insertions(+), 1844 deletions(-) create mode 100644 .changes/downloads-manager-mvp.md create mode 100644 libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts create mode 100644 libs/portal/downloads/feature/src/lib/downloads.component.spec.ts diff --git a/.changes/downloads-manager-mvp.md b/.changes/downloads-manager-mvp.md new file mode 100644 index 000000000..15560f3fb --- /dev/null +++ b/.changes/downloads-manager-mvp.md @@ -0,0 +1,8 @@ +--- +type: feature +area: downloads +--- + +Downloads now separate active and attention queues from a poster-based offline +library. Completed episodes are grouped by series, with scoped filters, tracked +size, and clearer file-retention confirmations. diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts index fe92a4208..e6dda07d9 100644 --- a/apps/electron-backend-e2e/src/downloads.e2e.ts +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'; import { createServer } from 'http'; import type { AddressInfo } from 'net'; import { join } from 'path'; @@ -19,6 +19,75 @@ async function openDownloadsPage(page: Page): Promise { await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); } +async function navigateWithinWorkspace( + page: Page, + path: string +): Promise { + await page.evaluate((target) => { + window.history.pushState(null, '', target); + window.dispatchEvent(new PopStateEvent('popstate')); + }, path); + await page.waitForURL((url) => url.pathname === path); +} + +async function getPlaylistId(page: Page, title: string): Promise { + const playlists = await page.evaluate( + async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? [] + ); + const playlist = playlists.find((entry) => entry.title === title); + expect(playlist, `playlist "${title}" should exist`).toBeDefined(); + return playlist?._id ?? ''; +} + +async function getSeriesTarget( + page: Page, + playlistId: string +): Promise<{ categoryId: number; xtreamId: number }> { + const target = await page.evaluate( + async (sourceId) => + (await window.electron?.dbGetContent?.(sourceId, 'series'))?.[0] ?? + null, + playlistId + ); + expect(target, 'playlist should have an imported series target').not.toBe( + null + ); + if (!target) { + throw new Error('Series target was not imported'); + } + expect(target.xtream_id).toBeGreaterThan(0); + expect(target.category_id).toBeGreaterThan(0); + return { + categoryId: target.category_id, + xtreamId: target.xtream_id, + }; +} + +interface DownloadSeed { + contentType: 'episode' | 'vod'; + downloadFolder: string; + episodeNumber?: number; + playlistId: string; + seasonNumber?: number; + seriesXtreamId?: number; + title: string; + url: string; + xtreamId: number; +} + +async function startDownload(page: Page, seed: DownloadSeed): Promise { + const result = await page.evaluate( + async (payload) => window.electron?.downloadsStart?.(payload), + seed + ); + expect(result?.error ?? null).toBeNull(); + expect(result?.id).toEqual(expect.any(Number)); + if (typeof result?.id !== 'number') { + throw new Error(`Download did not return an id: ${seed.title}`); + } + return result.id; +} + interface RangeServerRequest { ifRange?: string; range?: string; @@ -31,6 +100,13 @@ interface ThrottledRangeServer { url: string; } +interface TruncatedDownloadServer { + close: () => Promise; + payload: Buffer; + totalBytes: number; + url: string; +} + const RANGE_SERVER_ETAG = '"e2e-range-etag"'; /** @@ -38,7 +114,9 @@ const RANGE_SERVER_ETAG = '"e2e-range-etag"'; * window to pause mid-transfer, and answers Range requests with an immediate * 206 so the resumed transfer finishes fast. Records Range/If-Range headers. */ -async function createThrottledRangeServer(): Promise { +async function createThrottledRangeServer( + chunkIntervalMs = 150 +): Promise { const payload = Buffer.from( Array.from({ length: 96 * 1024 }, (_, index) => String(index % 10) @@ -85,7 +163,7 @@ async function createThrottledRangeServer(): Promise { } res.write(payload.subarray(sent, sent + 2 * 1024)); sent += 2 * 1024; - }, 150); + }, chunkIntervalMs); res.on('close', () => clearInterval(timer)); }); @@ -103,6 +181,37 @@ async function createThrottledRangeServer(): Promise { }; } +/** + * Ends a chunked response cleanly while Content-Range advertises a larger + * representation. The runtime therefore retains the valid .part for a Range + * retry instead of treating it as an unusable transport failure. + */ +async function createTruncatedDownloadServer(): Promise { + const payload = Buffer.alloc(16 * 1024, 7); + const totalBytes = payload.length * 4; + const server = createServer((_req, res) => { + res.writeHead(200, { + 'Content-Range': `bytes 0-${payload.length - 1}/${totalBytes}`, + 'Content-Type': 'video/mp4', + ETag: '"e2e-truncated-etag"', + }); + res.end(payload); + }); + + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve) + ); + const { port } = server.address() as AddressInfo; + + return { + close: () => + new Promise((resolve) => server.close(() => resolve())), + payload, + totalBytes, + url: `http://127.0.0.1:${port}/media/e2e-truncated-movie.mp4`, + }; +} + /** * On a cold profile the renderer can query SQLite while the DB worker is * still creating tables ("database is locked" / "no such table" on slow CI @@ -147,9 +256,7 @@ test.describe('Electron Downloads', () => { // First launch runs the IndexedDB→SQLite playlist migration before // the playlists query resolves, so allow extra time here. await expect( - app.mainWindow.getByText( - 'Add a playlist to start downloading' - ) + app.mainWindow.getByText('Add a playlist to start downloading') ).toBeVisible({ timeout: 20000 }); } finally { await closeElectronApp(app); @@ -198,55 +305,549 @@ test.describe('Electron Downloads', () => { .getByRole('button', { name: 'Change Folder' }) .click(); await expect( - app.mainWindow.locator('.downloads__folder-inline-path') + app.mainWindow.getByTestId('downloads-folder') ).toContainText('e2e-downloads'); // Enqueue a download through the same renderer bridge the app uses. + const playlistId = await getPlaylistId( + app.mainWindow, + 'Download Portal' + ); const startResult = await app.mainWindow.evaluate( - async ({ url, folder }) => + async ({ url, folder, sourceId }) => window.electron?.downloadsStart?.({ - playlistId: 'e2e-playlist', + playlistId: sourceId, xtreamId: 4242, contentType: 'vod', title: 'E2E Movie', url, downloadFolder: folder, }), - { url: fileServer.resourceUrl, folder: downloadsDir } + { + url: fileServer.resourceUrl, + folder: downloadsDir, + sourceId: playlistId, + } ); expect(startResult?.error ?? null).toBeNull(); + expect(startResult?.id).toEqual(expect.any(Number)); - const item = app.mainWindow.locator('.downloads__item'); - await expect(item).toHaveCount(1, { timeout: 20000 }); - await expect( - item.locator('.downloads__item-title-text') - ).toHaveText('E2E Movie'); - await expect(item.locator('.downloads__item-status')).toContainText( - 'Completed', - { timeout: 20000 } + const card = app.mainWindow.getByTestId( + `download-library-movie-${startResult?.id}` ); + await expect(card).toBeVisible({ timeout: 20000 }); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Ready to watch', + }) + ).toBeVisible(); // The file landed in the authorized folder. - expect(readdirSync(downloadsDir).length).toBeGreaterThan(0); + const finalPath = join(downloadsDir, 'E2E Movie.mp4'); + expect(readdirSync(downloadsDir)).toEqual(['E2E Movie.mp4']); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); // Completed items expose play / reveal / remove actions. await expect( - item.getByRole('button').filter({ hasText: 'play_arrow' }) + card.getByRole('button', { name: 'Play: E2E Movie' }).last() ).toBeVisible(); await expect( - item.getByRole('button').filter({ hasText: 'folder_open' }) + card.getByRole('button', { + name: 'Show in Folder: E2E Movie', + }) ).toBeVisible(); - await item - .getByRole('button') - .filter({ hasText: 'delete' }) + await card + .getByRole('button', { + name: 'More actions: E2E Movie', + }) .click(); + await app.mainWindow + .getByRole('menuitem', { + name: 'Remove from manager: E2E Movie', + }) + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Remove from manager?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('button', { + name: 'Remove from manager', + exact: true, + }) + .click(); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + expect(readFileSync(finalPath, 'utf8')).toBe( + 'e2e download payload' + ); + } finally { + await closeElectronApp(app); + await fileServer.close(); + } + }); + + test('@downloads @electron keeps global and scoped libraries truthful while filters, search, grouping, and scrolling stay functional', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const fileServer = await createMutableTextServer('library payload', { + contentType: 'video/mp4', + resourcePath: '/media/library-item.mp4', + }); + // Keep one real transfer alive while route scope changes prove that + // the shell badge remains global. + const activeServer = await createThrottledRangeServer(5000); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Library Source A', + username: 'multisrc1', + password: 'multisrc1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await addXtreamPortal(app.mainWindow, { + name: 'Library Source B', + username: 'multisrc2', + password: 'multisrc2', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + const sourceA = await getPlaylistId( + app.mainWindow, + 'Library Source A' + ); + const sourceB = await getPlaylistId( + app.mainWindow, + 'Library Source B' + ); + const { categoryId: seriesCategoryId, xtreamId: seriesXtreamId } = + await getSeriesTarget(app.mainWindow, sourceA); + await navigateWithinWorkspace( + app.mainWindow, + `/workspace/xtreams/${sourceB}/downloads` + ); + + const downloadsDir = join(dataDir, 'e2e-library-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.getByTestId('downloads-folder') + ).toContainText('e2e-library-downloads'); + + const common = { + downloadFolder: downloadsDir, + url: fileServer.resourceUrl, + }; + const alphaMovieId = await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8101, + contentType: 'vod', + title: 'Alpha Movie', + }); + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8201, + contentType: 'episode', + seriesXtreamId, + seasonNumber: 1, + episodeNumber: 1, + title: 'Aurora - S01E01 - Arrival', + }); + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8202, + contentType: 'episode', + seriesXtreamId, + seasonNumber: 1, + episodeNumber: 2, + title: 'Aurora - S01E02 - Signal', + }); + for (let index = 0; index < 6; index += 1) { + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceA, + xtreamId: 8300 + index, + contentType: 'vod', + title: `Alpha Archive ${index + 1}`, + }); + } + const betaMovieId = await startDownload(app.mainWindow, { + ...common, + playlistId: sourceB, + xtreamId: 9101, + contentType: 'vod', + title: 'Beta Movie', + }); + for (let index = 0; index < 4; index += 1) { + await startDownload(app.mainWindow, { + ...common, + playlistId: sourceB, + xtreamId: 9300 + index, + contentType: 'vod', + title: `Beta Archive ${index + 1}`, + }); + } + + const cards = app.mainWindow.locator( + 'article[data-test-id^="download-library-"]' + ); + await expect(cards).toHaveCount(5, { timeout: 30000 }); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${alphaMovieId}` + ) + ).toHaveCount(0); + + const activeMovieId = await startDownload(app.mainWindow, { + downloadFolder: downloadsDir, + playlistId: sourceA, + xtreamId: 8401, + contentType: 'vod', + title: 'Alpha Active Movie', + url: activeServer.url, + }); + const activeItem = app.mainWindow.getByTestId( + `download-queue-item-${activeMovieId}` + ); + const globalBadge = app.mainWindow.getByTestId( + 'global-download-count' + ); + await expect(globalBadge).toHaveText('1', { timeout: 20000 }); + await expect(activeItem).toHaveCount(0); + + await navigateWithinWorkspace( + app.mainWindow, + `/workspace/xtreams/${sourceA}/downloads` + ); + await expect(globalBadge).toHaveText('1'); + await expect(cards).toHaveCount(8); + await expect(activeItem).toBeVisible(); + await expect( + activeItem.locator('.download-queue__status') + ).toContainText('Downloading', { timeout: 20000 }); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${alphaMovieId}` + ) + ).toBeVisible(); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toHaveCount(0); + + await openDownloadsPage(app.mainWindow); + await expect(globalBadge).toHaveText('1'); + await expect(cards).toHaveCount(13); + const seriesCard = app.mainWindow.getByTestId( + `download-library-series-${sourceA}-${seriesXtreamId}` + ); + await expect(seriesCard).toContainText('2 episodes'); + await expect(seriesCard).toContainText('Library Source A'); + + await app.mainWindow + .getByTestId('downloads-filter-in-progress') + .click(); + await expect(cards).toHaveCount(0); + await expect(activeItem).toBeVisible(); + await expect( + app.mainWindow.getByTestId('downloads-filter-in-progress') + ).toContainText('1'); + await app.mainWindow.getByTestId('downloads-filter-all').click(); + await expect(cards).toHaveCount(13); + + await seriesCard + .getByRole('button', { + name: 'Open details: Aurora', + exact: true, + }) + .click(); + const expectedSeriesPath = `/workspace/xtreams/${sourceA}/series/${seriesCategoryId}/${seriesXtreamId}`; + await app.mainWindow.waitForURL((url) => + url.pathname.endsWith(expectedSeriesPath) + ); + await openDownloadsPage(app.mainWindow); + await expect(seriesCard).toBeVisible(); + + const episodeButton = seriesCard.getByRole('button', { + name: 'Open downloaded episodes: Aurora', + }); + await episodeButton.focus(); + await episodeButton.press('Enter'); + await expect( + app.mainWindow.getByRole('heading', { name: 'Aurora' }) + ).toBeVisible(); + await expect( + app.mainWindow.locator( + '[data-test-id^="downloaded-series-episode-"]' + ) + ).toHaveCount(2); + const closeDialog = app.mainWindow.getByTestId( + 'downloaded-series-close' + ); + await expect(closeDialog).toHaveAccessibleName('Close'); + await closeDialog.click(); + + await activeItem + .getByRole('button', { name: 'Pause Alpha Active Movie' }) + .click(); + await expect( + activeItem.locator('.download-queue__status') + ).toContainText('Paused', { timeout: 20000 }); + await expect(globalBadge).toHaveCount(0); + + await app.mainWindow.getByTestId('downloads-filter-series').click(); + await expect(cards).toHaveCount(1); + await expect(seriesCard).toBeVisible(); + await app.mainWindow.getByTestId('downloads-filter-movie').click(); + await expect(cards).toHaveCount(12); + await expect(seriesCard).toHaveCount(0); + await app.mainWindow.getByTestId('downloads-filter-all').click(); + + const search = app.mainWindow.getByRole('searchbox', { + name: 'Search', + }); + await search.fill('Beta Movie'); + await expect + .poll( + () => new URL(app.mainWindow.url()).searchParams.get('q'), + { timeout: 10000 } + ) + .toBe('Beta Movie'); + await expect(cards).toHaveCount(1); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${betaMovieId}` + ) + ).toBeVisible(); + await search.fill(''); + await expect + .poll( + () => new URL(app.mainWindow.url()).searchParams.get('q'), + { timeout: 10000 } + ) + .toBeNull(); + await expect(cards).toHaveCount(13); + + const header = app.mainWindow.getByTestId('downloads-header'); + const filters = app.mainWindow.getByTestId('downloads-filters'); + const content = app.mainWindow.getByTestId('downloads-content'); + const headerBefore = await header.boundingBox(); + const filtersBefore = await filters.boundingBox(); + const scroll = await content.evaluate((element) => { + const outer = + element.closest('.workspace-content'); + element.scrollTop = element.scrollHeight; + return { + clientHeight: element.clientHeight, + outerScrollTop: outer?.scrollTop ?? -1, + scrollHeight: element.scrollHeight, + scrollTop: element.scrollTop, + }; + }); + const headerAfter = await header.boundingBox(); + const filtersAfter = await filters.boundingBox(); + + expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight); + expect(scroll.scrollTop).toBeGreaterThan(0); + expect(scroll.outerScrollTop).toBe(0); + expect(headerAfter?.y).toBe(headerBefore?.y); + expect(filtersAfter?.y).toBe(filtersBefore?.y); + } finally { + await closeElectronApp(app); + await activeServer.close(); + await fileServer.close(); + } + }); + + test('@downloads @electron deletes retained failed partials when removing one item or clearing finished downloads', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const truncatedServer = await createTruncatedDownloadServer(); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Partial Cleanup Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openDownloadsPage(app.mainWindow); + + const downloadsDir = join(dataDir, 'e2e-partial-cleanup'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.getByTestId('downloads-folder') + ).toContainText('e2e-partial-cleanup'); + + const playlistId = await getPlaylistId( + app.mainWindow, + 'Partial Cleanup Portal' + ); + const removeId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9501, + contentType: 'vod', + title: 'Remove Retained Partial', + url: truncatedServer.url, + downloadFolder: downloadsDir, + }); + const clearId = await startDownload(app.mainWindow, { + playlistId, + xtreamId: 9502, + contentType: 'vod', + title: 'Clear Retained Partial', + url: truncatedServer.url, + downloadFolder: downloadsDir, + }); + + for (const id of [removeId, clearId]) { + await expect + .poll( + () => + app.mainWindow.evaluate( + async (downloadId) => + ( + await window.electron?.downloadsGet?.( + downloadId + ) + )?.status ?? null, + id + ), + { timeout: 30000 } + ) + .toBe('failed'); + } + + const failedRows = await app.mainWindow.evaluate( + async (ids) => + Promise.all( + ids.map((id) => window.electron?.downloadsGet?.(id)) + ), + [removeId, clearId] + ); + const partialPaths = failedRows.map((row) => { + expect(row?.filePath).toEqual(expect.any(String)); + expect(row?.bytesDownloaded).toBe( + truncatedServer.payload.length + ); + expect(row?.totalBytes).toBe(truncatedServer.totalBytes); + return `${row?.filePath}.part`; + }); + for (const partialPath of partialPaths) { + expect(existsSync(partialPath)).toBe(true); + expect(statSync(partialPath).size).toBe( + truncatedServer.payload.length + ); + } + + const removeItem = app.mainWindow.getByTestId( + `download-queue-item-${removeId}` + ); + await expect(removeItem).toBeVisible(); + await removeItem + .getByRole('button', { + name: 'Remove Remove Retained Partial from manager', + }) + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Remove partial download?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('button', { + name: 'Remove from manager', + exact: true, + }) + .click(); + await expect(removeItem).toHaveCount(0); + await expect + .poll(() => + app.mainWindow.evaluate( + async (id) => + (await window.electron?.downloadsGet?.(id)) ?? null, + removeId + ) + ) + .toBeNull(); + expect(existsSync(partialPaths[0])).toBe(false); + expect(existsSync(partialPaths[1])).toBe(true); + + await app.mainWindow + .getByTestId('downloads-clear-finished') + .click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Clear finished downloads?', + }) + ).toBeVisible(); + await app.mainWindow + .getByRole('dialog', { + name: 'Clear finished downloads?', + }) + .getByRole('button', { + name: 'Clear finished', + exact: true, + }) + .click(); + await expect( + app.mainWindow.getByTestId(`download-queue-item-${clearId}`) + ).toHaveCount(0); + await expect + .poll(() => + app.mainWindow.evaluate( + async (id) => + (await window.electron?.downloadsGet?.(id)) ?? null, + clearId + ) + ) + .toBeNull(); + expect(existsSync(partialPaths[1])).toBe(false); await expect( app.mainWindow.getByText('No downloads yet') ).toBeVisible(); } finally { await closeElectronApp(app); - await fileServer.close(); + await truncatedServer.close(); } }); @@ -280,26 +881,37 @@ test.describe('Electron Downloads', () => { .getByRole('button', { name: 'Change Folder' }) .click(); await expect( - app.mainWindow.locator('.downloads__folder-inline-path') + app.mainWindow.getByTestId('downloads-folder') ).toContainText('e2e-pause-downloads'); + const playlistId = await getPlaylistId( + app.mainWindow, + 'Pause Portal' + ); const startResult = await app.mainWindow.evaluate( - async ({ url, folder }) => + async ({ url, folder, sourceId }) => window.electron?.downloadsStart?.({ - playlistId: 'e2e-playlist', + playlistId: sourceId, xtreamId: 7373, contentType: 'vod', title: 'E2E Pause Movie', url, downloadFolder: folder, }), - { url: rangeServer.url, folder: downloadsDir } + { + url: rangeServer.url, + folder: downloadsDir, + sourceId: playlistId, + } ); expect(startResult?.error ?? null).toBeNull(); + expect(startResult?.id).toEqual(expect.any(Number)); - const item = app.mainWindow.locator('.downloads__item'); - await expect(item).toHaveCount(1, { timeout: 20000 }); - await expect(item.locator('.downloads__item-status')).toContainText( + const item = app.mainWindow.getByTestId( + `download-queue-item-${startResult?.id}` + ); + await expect(item).toBeVisible({ timeout: 20000 }); + await expect(item.locator('.download-queue__status')).toContainText( 'Downloading', { timeout: 20000 } ); @@ -307,10 +919,9 @@ test.describe('Electron Downloads', () => { // Pause mid-transfer: the row flips to Paused and only a .part // file exists on disk (final file absent, progress retained). await item - .getByRole('button') - .filter({ hasText: 'pause' }) + .getByRole('button', { name: 'Pause E2E Pause Movie' }) .click(); - await expect(item.locator('.downloads__item-status')).toContainText( + await expect(item.locator('.download-queue__status')).toContainText( 'Paused', { timeout: 20000 } ); @@ -325,13 +936,18 @@ test.describe('Electron Downloads', () => { // Resume: the runtime must continue via Range/If-Range instead of // restarting, and the assembled file must match the payload. await item - .getByRole('button') - .filter({ hasText: 'play_arrow' }) + .getByRole('button', { name: 'Resume E2E Pause Movie' }) .click(); - await expect(item.locator('.downloads__item-status')).toContainText( - 'Completed', - { timeout: 30000 } - ); + await expect( + app.mainWindow.getByTestId( + `download-library-movie-${startResult?.id}` + ) + ).toBeVisible({ timeout: 30000 }); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Ready to watch', + }) + ).toBeVisible(); const resumeRequest = rangeServer.requests.find( (entry) => entry.range diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index ff289e07b..2de4ee4f1 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1085,6 +1085,59 @@ "FAILED": "فشل", "CANCELED": "ملغى", "PAUSED": "متوقف مؤقتًا" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 977afedec..0b0c98a22 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1085,6 +1085,59 @@ "FAILED": "فشل", "CANCELED": "تلغى", "PAUSED": "موقوف مؤقتاً" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 67a7f21fc..e91889290 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1085,6 +1085,59 @@ "FAILED": "Няўдала", "CANCELED": "Скасавана", "PAUSED": "На паўзе" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index abe7f8414..5b8dadf6c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1085,6 +1085,59 @@ "FAILED": "Fehlgeschlagen", "CANCELED": "Abgebrochen", "PAUSED": "Pausiert" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a7622308d..175c855f1 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1085,6 +1085,59 @@ "FAILED": "Απέτυχε", "CANCELED": "Ακυρώθηκε", "PAUSED": "Σε παύση" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 42fc881fd..b1423c060 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1085,6 +1085,59 @@ "FAILED": "Failed", "CANCELED": "Canceled", "PAUSED": "Paused" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 88e13f1b5..48426b2c3 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1085,6 +1085,59 @@ "FAILED": "Fallido", "CANCELED": "Cancelado", "PAUSED": "En pausa" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 0579ed2c4..6d58350f2 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1085,6 +1085,59 @@ "FAILED": "Échec", "CANCELED": "Annulé", "PAUSED": "En pause" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index ea101b39c..0a3c8fa7b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1085,6 +1085,59 @@ "FAILED": "Sikertelen", "CANCELED": "Megszakítva", "PAUSED": "Paused" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 721069d00..55cdbfb89 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1085,6 +1085,59 @@ "FAILED": "Non riuscito", "CANCELED": "Annullato", "PAUSED": "In pausa" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 8612ef627..c4d7566a2 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1085,6 +1085,59 @@ "FAILED": "失敗", "CANCELED": "キャンセル済み", "PAUSED": "一時停止中" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index d8f86129d..14e876048 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1085,6 +1085,59 @@ "FAILED": "실패", "CANCELED": "취소됨", "PAUSED": "일시정지됨" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index f6996b795..2337d9d2d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1085,6 +1085,59 @@ "FAILED": "Mislukt", "CANCELED": "Geannuleerd", "PAUSED": "Gepauzeerd" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index eb5b4791b..ec332808e 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1085,6 +1085,59 @@ "FAILED": "Niepowodzenie", "CANCELED": "Anulowane", "PAUSED": "Wstrzymane" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index f6aff24ab..f5277c648 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1085,6 +1085,59 @@ "FAILED": "Falhou", "CANCELED": "Cancelado", "PAUSED": "Pausado" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 430a57b70..bc8334209 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1085,6 +1085,59 @@ "FAILED": "Ошибка", "CANCELED": "Отменено", "PAUSED": "На паузе" + }, + "ACTIVE_COUNT": "{{count}} активн.", + "TRACKED_DOWNLOADS": "Объём загрузок", + "CLEAR_FINISHED": "Очистить завершённые", + "DOWNLOADING_NOW": "Скачиваются сейчас", + "NEEDS_ATTENTION": "Требуют внимания", + "READY_TO_WATCH": "Готово к просмотру", + "OFFLINE": "Офлайн", + "OFFLINE_BADGE": "Офлайн", + "FILTER": { + "LABEL": "Фильтр загрузок", + "ALL": "Все", + "MOVIES": "Фильмы", + "SERIES": "Сериалы", + "IN_PROGRESS": "В процессе" + }, + "REMOVE_FROM_MANAGER": "Убрать из менеджера", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Убрать из менеджера?", + "MESSAGE": "Запись исчезнет из IPTVnator, но скачанный медиафайл останется на диске." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Удалить частичную загрузку?", + "MESSAGE": "Запись и сохранённая часть загрузки будут удалены. Возобновить её больше не получится." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Очистить завершённые загрузки?", + "MESSAGE": "Готовые медиафайлы останутся на диске. Частичные данные неудачных и отменённых загрузок будут удалены." + }, + "ARTWORK_UNAVAILABLE": "Обложка недоступна", + "EPISODE_COUNT_ONE": "1 эпизод", + "EPISODE_COUNT_OTHER": "{{count}} эп.", + "MORE_ACTIONS": "Другие действия", + "MOVIE": "Фильм", + "EPISODE": "Эпизод", + "SERIES": "Сериал", + "OPEN_ARTWORK": "Открыть {{title}}", + "OPEN_DETAILS": "Открыть подробности", + "OPEN_EPISODES": "Открыть скачанные эпизоды", + "SEASON": "Сезон {{season}}", + "SEASON_RANGE": "Сезоны {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Скачанные эпизоды", + "ARIA": { + "OPEN": "Открыть {{title}}", + "POSTER_UNAVAILABLE": "Обложка для {{title}} недоступна", + "PROGRESS": "Ход загрузки {{title}}", + "PAUSE": "Приостановить {{title}}", + "CANCEL": "Отменить {{title}}", + "RESUME": "Возобновить {{title}}", + "REMOVE": "Убрать {{title}} из менеджера", + "RETRY": "Повторить {{title}}", + "MORE_ACTIONS": "Другие действия для {{title}}", + "COPY_URL": "Копировать URL загрузки {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 5baba7d7e..bf3ab6faf 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1085,6 +1085,59 @@ "FAILED": "Başarısız", "CANCELED": "İptal Edildi", "PAUSED": "Duraklatıldı" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4d3162d0c..92c0a3832 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1085,6 +1085,59 @@ "FAILED": "失败", "CANCELED": "已取消", "PAUSED": "已暂停" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f7f479b1e..49185c449 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1085,6 +1085,59 @@ "FAILED": "失敗", "CANCELED": "已取消", "PAUSED": "已暫停" + }, + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE": "Offline", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "LABEL": "Filter downloads", + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + }, + "ARTWORK_UNAVAILABLE": "Artwork unavailable", + "EPISODE_COUNT_ONE": "1 episode", + "EPISODE_COUNT_OTHER": "{{count}} episodes", + "MORE_ACTIONS": "More actions", + "MOVIE": "Movie", + "EPISODE": "Episode", + "SERIES": "Series", + "OPEN_ARTWORK": "Open {{title}}", + "OPEN_DETAILS": "Open details", + "OPEN_EPISODES": "Open downloaded episodes", + "SEASON": "Season {{season}}", + "SEASON_RANGE": "Seasons {{first}}–{{last}}", + "DOWNLOADED_EPISODES": "Downloaded episodes", + "ARIA": { + "OPEN": "Open {{title}}", + "POSTER_UNAVAILABLE": "Artwork unavailable for {{title}}", + "PROGRESS": "Download progress for {{title}}", + "PAUSE": "Pause {{title}}", + "CANCEL": "Cancel {{title}}", + "RESUME": "Resume {{title}}", + "REMOVE": "Remove {{title}} from manager", + "RETRY": "Retry {{title}}", + "MORE_ACTIONS": "More actions for {{title}}", + "COPY_URL": "Copy download URL for {{title}}" } }, "REMOTE_CONTROL": { diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 3217951e6..a41b8c125 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -1,6 +1,11 @@ # Download Manager Architecture -The download manager is a desktop-only feature that layers a curated queue, progress tracking, storage configuration, and playback controls on top of the existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal views. Backend work is handled in the Electron process while the Angular renderer surface exposes a dedicated `/downloads` route, contextual buttons, and theme-aware styling. +The download manager is a desktop-only feature that layers a curated queue, +progress tracking, storage configuration, and playback controls on top of the +existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal +views. Backend work is handled in the Electron process while the Angular +renderer exposes the global `/workspace/downloads` page, source-scoped route +variants, contextual buttons, and theme-aware styling. ## Backend responsibilities @@ -30,10 +35,48 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Renderer architecture - **Downloads service** (`libs/services/src/lib/downloads.service.ts`) - Signals back the current download list while `hasDownloads` and `isAvailable` gates UI rendering. Before each download/resume the service asks the main process for the authorized folder and calls the download IPC command. The backend extracts the file extension from the URL or falls back to `mp4`. `onDownloadsUpdate` updates the signal, while helper methods `pauseDownload`, `resumeDownload`, `retryDownload`, `removeDownload`, `cancelDownload`, and `playDownload` talk to the corresponding IPC commands so retries reuse existing rows and completed items can open the recorded path. -- **Downloads view** (`libs/portal/downloads/feature`) - A standalone page exposes the queue, desktop-only messaging, folder picker, and action buttons. `downloads.component.html` wraps the list inside a scrollable panel (`downloads__list-wrapper`) so long queues stay reachable, and `downloads.component.scss` drives gradient cards with theme-aware styling through Angular Material system CSS variables (`var(--mat-sys-*)`, `var(--app-*)`, `color-mix`) — theming tracks the active Material theme rather than a `body.dark-theme` hook. - Failed/canceled cards show retry/delete controls, queued/downloading cards show pause/cancel controls, paused cards show resume/cancel/delete controls, and completed cards render inline play/open buttons with `mat-icon` cues. Pause/resume/cancel/retry surface backend `success: false` results in a snackbar instead of failing silently. The header also shows the resolved download folder and a `CHANGE FOLDER` action. VOD and episode detail views render a paused download as an active "Resume" button (`DownloadsService.isPaused()` / `resumeDownloadByContent()`) rather than a disabled "Downloading" state. + `DownloadsService.downloads` is the renderer's authoritative **global** list. + `loadDownloads()` therefore always invokes the Electron list IPC without its + legacy optional playlist scope. Route scope, category, and search must never + replace or narrow that signal. Overlapping loads are request-ordered so a + late response cannot replace a newer snapshot. Before each fresh download or + resume the service asks the main process for an authorized folder and calls + the corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a + new global load. +- **Pure manager model** + (`download-manager.viewmodel.ts` and `download-library.viewmodel.ts`) + 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 + `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. +- **Downloads workspace** (`libs/portal/downloads/feature`) + keeps orchestration in `DownloadsComponent`, async mutations in the + component-scoped `DownloadManagerActionsService`, source-route resolution in + `DownloadLibraryNavigationService`, and rendering in the presentational + `DownloadQueueComponent`, `DownloadLibraryComponent`, and + `DownloadedSeriesDialogComponent`. Presentational children emit typed + `DownloadItemAction` values and do not inject the download service, router, + dialogs, or snackbars. + The fixed header and filter row sit above one vertical scroll owner. The + queue uses compact progress rows with status text and icons; completed movies + and grouped series reuse the portal's canonical content grid. All surfaces + use the existing `--app-*` and Material system tokens. 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** + Every asynchronous item command owns a pending id until the IPC result + settles, preventing duplicate dispatch without optimistically changing a + status. Service failures surface through the established snackbar path. + Removing a completed entry explicitly says the finalized media file remains + on disk. Removing a paused, failed, or canceled entry says retained partial + data is deleted. “Clear finished” communicates both outcomes and preserves + playlist scope when the page is opened under a source route. VOD and episode + detail views continue to render a paused download as an active Resume button + (`DownloadsService.isPaused()` / `resumeDownloadByContent()`). ## Global API surface @@ -42,7 +85,11 @@ The download manager is a desktop-only feature that layers a curated queue, prog ## Routing and navigation -- `/downloads` is available under both portal flavors: the Xtream routes already load `DownloadsComponent`, and the Stalker routes now import the same component so the sidebar link can target `/stalker/:id/downloads` without returning to the startup screen. +- The global workspace is `/workspace/downloads`. Source-scoped variants are + available under both portal flavors: + `/workspace/xtreams/:id/downloads` and + `/workspace/stalker/:id/downloads`. All three routes render the same global + store; the `:id` routes derive a view-only playlist scope. - Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page. ## Queuing, persistence, and UX notes @@ -60,6 +107,12 @@ The download manager is a desktop-only feature that layers a curated queue, prog authorized only after native folder selection, and the main process persists that selection under Electron `userData`. Renderer settings may display the path, but they are not trusted as authorization. -- The new UI leverages CSS variables for theme-specific backgrounds/borders, ensures `.downloads__list` can scroll inside its panel, and brings consistent badge/typography treatments to each card. +- The manager's search and All/Movies/Series/In progress filters affect visible + queue and library entities only. The tracked-byte summary deliberately + ignores search/category filtering while honoring route scope, so hiding a + card never makes its disk footprint appear to vanish. -Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work might cover download list filters, cancel-all actions, or integration with upcoming playback analytics. +Keeping the backend queue, IPC handlers, shared schema, and renderer signals +synchronized minimizes drift between platform rules and the UI. Future work +might cover recordings, queue reordering, bulk pause/cancel actions, disk-free +space telemetry, or playback analytics. diff --git a/docs/superpowers/plans/2026-07-30-download-manager-mvp.md b/docs/superpowers/plans/2026-07-30-download-manager-mvp.md index 505ddf420..a11d77f20 100644 --- a/docs/superpowers/plans/2026-07-30-download-manager-mvp.md +++ b/docs/superpowers/plans/2026-07-30-download-manager-mvp.md @@ -183,13 +183,13 @@ render: ```html @if (activeDownloadsCount() > 0) { - + } ``` @@ -280,8 +280,9 @@ Add the same `test` executor/options as Use a `download(overrides)` fixture and cover these exact assertions: ```ts -expect(buildDownloadManagerViewModel(input).active.map((x) => x.item.id)) - .toEqual([oldestId, newestId]); +expect( + buildDownloadManagerViewModel(input).active.map((x) => x.item.id) +).toEqual([oldestId, newestId]); expect(model.attention.map((x) => x.item.id)).toEqual([failedId, canceledId]); expect(model.library.map((x) => x.key)).toEqual([ 'series:playlist-a:42', @@ -402,9 +403,7 @@ export function buildDownloadManagerViewModel(options: { }): DownloadManagerViewModel; export function normalizeDownloadFilter(value: string): DownloadFilterId { - return value === 'movie' || - value === 'series' || - value === 'in-progress' + return value === 'movie' || value === 'series' || value === 'in-progress' ? value : 'all'; } @@ -715,8 +714,10 @@ Expected: FAIL because the component is absent. ```ts export class DownloadQueueComponent { - readonly activeItems = input.required(); - readonly attentionItems = input.required(); + readonly activeItems = + input.required(); + readonly attentionItems = + input.required(); readonly pendingIds = input>(new Set()); readonly itemAction = output(); readonly openRequested = output(); @@ -971,10 +972,10 @@ The rendered hierarchy is:

{{ 'DOWNLOADS.TITLE' | translate }}

@if (model().activeCount > 0) { - - {{ 'DOWNLOADS.ACTIVE_COUNT' | translate: - { count: model().activeCount } }} - + + {{ 'DOWNLOADS.ACTIVE_COUNT' | translate: { count: + model().activeCount } }} + }
@@ -985,14 +986,14 @@ The rendered hierarchy is: {{ 'DOWNLOADS.CHANGE_FOLDER' | translate }} @if (model().hasClearable) { - + }
- {{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} · - {{ formatBytes(model().trackedBytes) }} + {{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} · {{ + formatBytes(model().trackedBytes) }}
@for (category of categories(); track category.category_id) { - + (click)="setFilter(category.category_id)" + > + {{ category.category_name }} + {{ category.count }} + }
@@ -1046,32 +1047,32 @@ Add consistent keys under `DOWNLOADS` in every locale: ```json { - "ACTIVE_COUNT": "{{count}} active", - "TRACKED_DOWNLOADS": "Tracked downloads", - "CLEAR_FINISHED": "Clear finished", - "DOWNLOADING_NOW": "Downloading now", - "NEEDS_ATTENTION": "Needs attention", - "READY_TO_WATCH": "Ready to watch", - "OFFLINE_BADGE": "Offline", - "FILTER": { - "ALL": "All", - "MOVIES": "Movies", - "SERIES": "Series", - "IN_PROGRESS": "In progress" - }, - "REMOVE_FROM_MANAGER": "Remove from manager", - "REMOVE_COMPLETED_DIALOG": { - "TITLE": "Remove from manager?", - "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." - }, - "REMOVE_PARTIAL_DIALOG": { - "TITLE": "Remove partial download?", - "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." - }, - "CLEAR_FINISHED_DIALOG": { - "TITLE": "Clear finished downloads?", - "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." - } + "ACTIVE_COUNT": "{{count}} active", + "TRACKED_DOWNLOADS": "Tracked downloads", + "CLEAR_FINISHED": "Clear finished", + "DOWNLOADING_NOW": "Downloading now", + "NEEDS_ATTENTION": "Needs attention", + "READY_TO_WATCH": "Ready to watch", + "OFFLINE_BADGE": "Offline", + "FILTER": { + "ALL": "All", + "MOVIES": "Movies", + "SERIES": "Series", + "IN_PROGRESS": "In progress" + }, + "REMOVE_FROM_MANAGER": "Remove from manager", + "REMOVE_COMPLETED_DIALOG": { + "TITLE": "Remove from manager?", + "MESSAGE": "This removes the entry from IPTVnator. The downloaded media file remains on disk." + }, + "REMOVE_PARTIAL_DIALOG": { + "TITLE": "Remove partial download?", + "MESSAGE": "This removes the entry and any retained partial download. It can no longer be resumed." + }, + "CLEAR_FINISHED_DIALOG": { + "TITLE": "Clear finished downloads?", + "MESSAGE": "Completed media files remain on disk. Retained partial data for failed or canceled entries is deleted." + } } ``` diff --git a/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md b/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md index 3bb43b583..153ae5236 100644 --- a/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md +++ b/docs/superpowers/specs/2026-07-30-download-manager-mvp-design.md @@ -121,12 +121,12 @@ authoritative. Missing or invalid timestamps normalize to zero for ordering. ### Filter semantics -| Filter | Queue | Attention | Library | -| --- | --- | --- | --- | -| All | Movies and episodes | Movies and episodes | Movies and grouped series | -| Movies | VOD rows | VOD rows | Completed VOD cards | -| Series | Episode rows | Episode rows | Grouped completed series | -| In progress | Queued, downloading, and paused rows | Hidden | Hidden | +| Filter | Queue | Attention | Library | +| ----------- | ------------------------------------ | ------------------- | ------------------------- | +| All | Movies and episodes | Movies and episodes | Movies and grouped series | +| Movies | VOD rows | VOD rows | Completed VOD cards | +| Series | Episode rows | Episode rows | Grouped completed series | +| In progress | Queued, downloading, and paused rows | Hidden | Hidden | Chip counts are calculated from the scoped, partitioned, and grouped model before text search, so typing in the workspace search does not make category @@ -162,7 +162,7 @@ from different playlists from merging. - Episodes without a positive safe-integer `seriesXtreamId` fall back to individual episode cards. - The group title is derived from the standardized stored title prefix before - ` - SxxExx - `. If a legacy title does not match that form, the first + `- SxxExx -`. If a legacy title does not match that form, the first non-empty member title is used unchanged. - Artwork uses the newest member with a valid poster URL. - The group exposes episode count, downloaded season range, aggregate tracked @@ -234,13 +234,13 @@ Every queue row contains: progress for an active transfer; - status-specific actions. -| Status | Primary actions | -| --- | --- | -| queued | Pause, Cancel | -| downloading | Pause, Cancel | -| paused | Resume, Cancel, Remove from manager | -| failed | Retry, Remove from manager | -| canceled | Retry, Remove from manager | +| Status | Primary actions | +| ----------- | ----------------------------------- | +| queued | Pause, Cancel | +| downloading | Pause, Cancel | +| paused | Resume, Cancel, Remove from manager | +| failed | Retry, Remove from manager | +| canceled | Retry, Remove from manager | Copy URL is placed in an overflow menu rather than competing with the primary transfer controls. Clicking the title/artwork opens the corresponding source @@ -317,15 +317,15 @@ explicit delete and recovery semantics. The handoff's standalone `tokens.css` is a visual reference, not a new runtime token source. Its roles map to existing IPTVnator variables: -| Handoff role | IPTVnator source | -| --- | --- | -| page background | `--app-content-bg` | -| panel/card background | `--app-widget-bg` | -| raised/secondary surface | `--app-widget-header-bg`, `--app-card-hover-bg` | -| primary and secondary text | `--app-heading-color`, `--app-body-color` | -| muted text | `--app-muted-color`, `--app-eyebrow-color` | -| active blue | `--app-selection-color` and `--app-selection-*` surfaces | -| separators | `--app-separator`, `--app-widget-header-border` | +| Handoff role | IPTVnator source | +| -------------------------- | -------------------------------------------------------- | +| page background | `--app-content-bg` | +| panel/card background | `--app-widget-bg` | +| raised/secondary surface | `--app-widget-header-bg`, `--app-card-hover-bg` | +| primary and secondary text | `--app-heading-color`, `--app-body-color` | +| muted text | `--app-muted-color`, `--app-eyebrow-color` | +| active blue | `--app-selection-color` and `--app-selection-*` surfaces | +| separators | `--app-separator`, `--app-widget-header-border` | The implementation reuses DM Sans, JetBrains Mono for compact byte/path metadata, Material icons, and the shared `content-grid` Sass mixin. diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts index 8901f0760..97a73bb84 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.spec.ts @@ -27,14 +27,9 @@ const STALKER_PLAYLIST = { } satisfies Playlist; type RouterFake = jest.Mocked>; -type DatabaseFake = jest.Mocked< - Pick ->; +type DatabaseFake = jest.Mocked>; type PlaylistsFake = jest.Mocked< - Pick< - PlaylistsService, - 'getPlaylistById' | 'getPortalRecentlyViewed' - > + Pick >; function download(overrides: Partial = {}): DownloadItem { @@ -114,9 +109,9 @@ describe('DownloadLibraryNavigationService', () => { }); it('rejects an item whose source playlist is absent', () => { - expect(navigation.canOpen(download(), new Set(['playlist-b']))).toBe( - false - ); + expect( + navigation.canOpen(download(), new Set(['playlist-b'])) + ).toBe(false); }); it.each([ @@ -130,10 +125,7 @@ describe('DownloadLibraryNavigationService', () => { ['unsafe', Number.MAX_SAFE_INTEGER + 1], ])('rejects a %s VOD target ID', (_label, xtreamId) => { expect( - navigation.canOpen( - download({ xtreamId }), - availablePlaylistIds - ) + navigation.canOpen(download({ xtreamId }), availablePlaylistIds) ).toBe(false); }); @@ -205,13 +197,9 @@ describe('DownloadLibraryNavigationService', () => { 'returns false instead of rejecting when %s navigation fails', async (source) => { if (source === 'stalker') { - playlists.getPlaylistById.mockReturnValue( - of(STALKER_PLAYLIST) - ); + playlists.getPlaylistById.mockReturnValue(of(STALKER_PLAYLIST)); } - router.navigate.mockRejectedValue( - new Error('navigation rejected') - ); + router.navigate.mockRejectedValue(new Error('navigation rejected')); await expect(navigation.open(download())).resolves.toBe(false); @@ -306,8 +294,7 @@ describe('DownloadLibraryNavigationService', () => { await expect(navigation.open(download())).resolves.toBe(true); - const expectedId = - idKey === 'stream_id' ? '41' : recent[idKey]; + const expectedId = idKey === 'stream_id' ? '41' : recent[idKey]; expect(router.navigate).toHaveBeenCalledWith( ['/workspace', 'stalker', PLAYLIST_ID, 'recent'], { diff --git a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts index 12933ff1e..d8f03a30d 100644 --- a/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts +++ b/libs/portal/downloads/feature/src/lib/download-library-navigation.service.ts @@ -93,8 +93,7 @@ export class DownloadLibraryNavigationService { item: DownloadItem, targetId: number ): Promise { - const contentType = - item.contentType === 'episode' ? 'series' : 'vod'; + const contentType = item.contentType === 'episode' ? 'series' : 'vod'; const content = await this.db.getContentByXtreamId( targetId, item.playlistId @@ -194,8 +193,7 @@ export class DownloadLibraryNavigationService { item: DownloadItem, targetId: number ): Promise { - const fallback = - item.contentType === 'episode' ? 'series' : 'vod'; + const fallback = item.contentType === 'episode' ? 'series' : 'vod'; const openRecentItem = await this.stalkerOpenState( item, targetId, 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 52a1c3318..35e3185ca 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.html +++ b/libs/portal/downloads/feature/src/lib/download-library.component.html @@ -1,294 +1,376 @@ -
- @for (entity of entities(); track entity.key) { - @if (entity.kind === 'series') { -
-
- - - - {{ 'DOWNLOADS.OFFLINE' | translate }} - -
- -
- - {{ 'DOWNLOADS.SERIES' | translate }} - - -
- - @if ( - entity.firstSeason !== undefined && - entity.lastSeason !== undefined - ) { - - - @if (entity.firstSeason === entity.lastSeason) { - {{ - 'DOWNLOADS.SEASON' - | translate - : { - season: entity.firstSeason, - } - }} + + + + {{ 'DOWNLOADS.OFFLINE' | translate }} + +
+ +
+ + {{ 'DOWNLOADS.SERIES' | translate }} + + + + +
+ + @if ( + entity.firstSeason !== undefined && + entity.lastSeason !== undefined + ) { + + + @if ( + entity.firstSeason === + entity.lastSeason + ) { + {{ + 'DOWNLOADS.SEASON' + | translate + : { + season: entity.firstSeason, + } + }} + } @else { + {{ + 'DOWNLOADS.SEASON_RANGE' + | translate + : { + first: entity.firstSeason, + last: entity.lastSeason, + } + }} + } + } - - } -
- -
-
- } @else { -
-
-
+
+ } @else { +
- @if (artworkUrl(entity); as artwork) { - - } @else { - - - - } - - - - {{ 'DOWNLOADS.OFFLINE' | translate }} - -
- -
- - {{ - (entity.kind === 'movie' - ? 'DOWNLOADS.MOVIE' - : 'DOWNLOADS.EPISODE' - ) | translate - }} - - - @if (entity.kind === 'episode' && entity.episodeLabel) { - - {{ entity.episodeLabel }} - - } - - -
- - - - +
+ + + {{ 'DOWNLOADS.OFFLINE' | translate }} + +
+ +
+ + {{ + (entity.kind === 'movie' + ? 'DOWNLOADS.MOVIE' + : 'DOWNLOADS.EPISODE' + ) | translate + }} + - -
-
- - } - } -
+ @if ( + entity.kind === 'episode' && entity.episodeLabel + ) { + + {{ entity.episodeLabel }} + + } + + +
+ + + + + + + +
+ + + } + } + + +} diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.scss b/libs/portal/downloads/feature/src/lib/download-library.component.scss index 54bd4b0e2..99409edc5 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.scss +++ b/libs/portal/downloads/feature/src/lib/download-library.component.scss @@ -4,6 +4,21 @@ display: block; } +.download-library { + display: grid; + gap: 14px; +} + +.download-library__header { + h2 { + margin: 0; + color: var(--app-heading-color); + font: var(--mat-sys-title-medium); + font-weight: 680; + letter-spacing: -0.015em; + } +} + .download-library__grid { @include grid.content-grid( $min-width: min(100%, 168px), @@ -53,6 +68,11 @@ cursor: pointer; } +.download-library__title-hint { + display: block; + min-width: 0; +} + .download-library__artwork-button { display: block; width: 100%; @@ -72,10 +92,18 @@ transition: transform 180ms ease; } - &:hover img, - &:focus-visible img { + &:hover:not([aria-disabled='true']) img, + &:focus-visible:not([aria-disabled='true']) img { transform: scale(1.025); } + + &--disabled { + cursor: not-allowed; + + img { + filter: saturate(0.72); + } + } } .download-library__placeholder { @@ -144,6 +172,7 @@ .download-library__title-button { display: -webkit-box; + width: 100%; min-height: 2.6em; padding: 0; overflow: hidden; @@ -155,6 +184,11 @@ text-align: left; -webkit-box-orient: vertical; -webkit-line-clamp: 2; + + &--disabled { + color: var(--app-muted-color); + cursor: not-allowed; + } } .download-library__facts, @@ -176,10 +210,21 @@ text-overflow: ellipsis; white-space: nowrap; } + + span:last-child { + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-variant-numeric: tabular-nums; + } } .download-library__episodes-button { - padding: 0; + display: inline-flex; + min-height: 44px; + align-items: center; + margin: -8px -4px; + padding: 8px 4px; background: transparent; color: var(--mat-sys-primary); font-weight: 650; diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts index 0e029418b..6100dda11 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.spec.ts @@ -1,4 +1,7 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatTooltipHarness } from '@angular/material/tooltip/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import type { DownloadItem } from '@iptvnator/services'; @@ -13,7 +16,8 @@ const TRANSLATIONS = { ARTWORK_UNAVAILABLE: 'Artwork unavailable', COPY_URL: 'Copy download URL', EPISODE: 'Episode', - EPISODE_COUNT: '{{count}} episodes', + EPISODE_COUNT_ONE: '1 episode', + EPISODE_COUNT_OTHER: '{{count}} episodes', MORE_ACTIONS: 'More actions', MOVIE: 'Movie', OFFLINE: 'Offline', @@ -21,11 +25,14 @@ const TRANSLATIONS = { OPEN_DETAILS: 'Open details', OPEN_EPISODES: 'Open downloaded episodes', PLAY: 'Play', + READY_TO_WATCH: 'Ready to watch', REMOVE_FROM_MANAGER: 'Remove from manager', REVEAL: 'Show in folder', SEASON: 'Season {{season}}', SEASON_RANGE: 'Seasons {{first}}–{{last}}', SERIES: 'Series', + SOURCE_PLAYLIST_MISSING: + 'This download can no longer open because its source was removed.', }, }; @@ -140,6 +147,10 @@ describe('DownloadLibraryComponent', () => { fixture = TestBed.createComponent(DownloadLibraryComponent); component = fixture.componentInstance; fixture.componentRef.setInput('entities', ENTITIES); + fixture.componentRef.setInput( + 'availablePlaylistIds', + new Set(['playlist-a']) + ); await fixture.whenStable(); }); @@ -184,6 +195,13 @@ describe('DownloadLibraryComponent', () => { } it('renders movie, grouped-series, and fallback-episode identities', () => { + const section = byTestId('downloads-library-section'); + expect(section.getAttribute('aria-labelledby')).toBe( + 'downloads-library-heading' + ); + expect( + section.querySelector('#downloads-library-heading')?.textContent + ).toContain('Ready to watch'); const movieCard = byTestId('download-library-movie-9'); const seriesCard = byTestId('download-library-series-playlist-a-77'); const fallbackEpisodeCard = byTestId('download-library-episode-21'); @@ -198,6 +216,32 @@ describe('DownloadLibraryComponent', () => { expect(fallbackEpisodeCard.textContent).toContain('Episode'); }); + it('omits the named library section when no completed entity is visible', async () => { + fixture.componentRef.setInput('entities', []); + await fixture.whenStable(); + + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-library-section"]' + ) + ).toBeNull(); + }); + + it('uses a singular episode count for a one-file series group', async () => { + fixture.componentRef.setInput('entities', [ + { + ...SERIES, + members: [SERIES_MEMBERS[0]], + representative: SERIES_MEMBERS[0], + }, + ]); + await fixture.whenStable(); + + expect( + byTestId('download-library-series-playlist-a-77').textContent + ).toContain('1 episode'); + }); + it('emits every concrete movie action with the movie item', async () => { const card = byTestId('download-library-movie-9'); const actions: unknown[] = []; @@ -242,6 +286,42 @@ describe('DownloadLibraryComponent', () => { expect(opened).toEqual([SERIES.representative, SERIES.representative]); }); + it('disables orphaned series navigation and explains why while keeping episodes local', async () => { + fixture.componentRef.setInput('availablePlaylistIds', new Set()); + await fixture.whenStable(); + const card = byTestId('download-library-series-playlist-a-77'); + const artwork = button(card, 'Open details: Northwind artwork'); + const title = button(card, 'Open details: Northwind'); + const episodes = button(card, 'Open downloaded episodes: Northwind'); + const opened: DownloadItem[] = []; + component.seriesOpened.subscribe((item) => opened.push(item)); + + expect(artwork.disabled).toBe(false); + expect(title.disabled).toBe(false); + expect(artwork.getAttribute('aria-disabled')).toBe('true'); + expect(title.getAttribute('aria-disabled')).toBe('true'); + expect(episodes.disabled).toBe(false); + + const loader = TestbedHarnessEnvironment.loader(fixture); + const tooltip = await loader.getHarness( + MatTooltipHarness.with({ + selector: '.download-library__artwork-button', + }) + ); + TestBed.inject(FocusMonitor).focusVia(artwork, 'keyboard'); + await fixture.whenStable(); + expect(document.activeElement).toBe(artwork); + expect(await tooltip.isOpen()).toBe(true); + expect(await tooltip.getTooltipText()).toBe( + 'This download can no longer open because its source was removed.' + ); + await tooltip.hide(); + + await click(artwork); + await click(title); + expect(opened).toEqual([]); + }); + it('opens the downloaded episode group from its count control', async () => { const opened: DownloadSeriesCardViewModel[] = []; component.episodesOpened.subscribe((group) => opened.push(group)); diff --git a/libs/portal/downloads/feature/src/lib/download-library.component.ts b/libs/portal/downloads/feature/src/lib/download-library.component.ts index 818b955d0..217cf996b 100644 --- a/libs/portal/downloads/feature/src/lib/download-library.component.ts +++ b/libs/portal/downloads/feature/src/lib/download-library.component.ts @@ -36,6 +36,7 @@ import type { }) export class DownloadLibraryComponent { readonly entities = input.required(); + readonly availablePlaylistIds = input.required>(); readonly pendingIds = input>(new Set()); readonly itemAction = output(); readonly seriesOpened = output(); @@ -80,6 +81,10 @@ export class DownloadLibraryComponent { return this.pendingIds().has(item.id); } + protected canOpenSeries(group: DownloadSeriesCardViewModel): boolean { + return this.availablePlaylistIds().has(group.representative.playlistId); + } + protected emitAction( type: DownloadItemActionType, item: DownloadItem @@ -90,13 +95,21 @@ export class DownloadLibraryComponent { } protected openSeries(group: DownloadSeriesCardViewModel): void { - this.seriesOpened.emit(group.representative); + if (this.canOpenSeries(group)) { + this.seriesOpened.emit(group.representative); + } } protected openEpisodes(group: DownloadSeriesCardViewModel): void { this.episodesOpened.emit(group); } + protected episodeCountKey(count: number): string { + return count === 1 + ? 'DOWNLOADS.EPISODE_COUNT_ONE' + : 'DOWNLOADS.EPISODE_COUNT_OTHER'; + } + protected formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { return '0 B'; diff --git a/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts new file mode 100644 index 000000000..657cc155e --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/download-manager-actions.service.ts @@ -0,0 +1,185 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { type DownloadItem, DownloadsService } from '@iptvnator/services'; +import { DialogService } from '@iptvnator/ui/components'; +import type { DownloadItemAction } from './download-actions'; + +interface DownloadOperationResult { + readonly success: boolean; + readonly error?: string; +} + +@Injectable() +export class DownloadManagerActionsService { + private readonly downloads = inject(DownloadsService); + private readonly dialogs = inject(DialogService); + private readonly translate = inject(TranslateService); + private readonly snackBar = inject(MatSnackBar); + + readonly pendingIds = signal>(new Set()); + readonly isClearing = signal(false); + + async run(action: DownloadItemAction): Promise { + const { item, type } = action; + if (this.pendingIds().has(item.id)) { + return; + } + if (type === 'remove') { + this.confirmRemove(item); + return; + } + if (type === 'copy-url') { + await this.copyUrl(item); + return; + } + if (type === 'play' || type === 'reveal') { + await this.runFileAction(type, item); + return; + } + const operations = { + cancel: () => this.downloads.cancelDownload(item.id), + pause: () => this.downloads.pauseDownload(item.id), + resume: () => this.downloads.resumeDownload(item.id), + retry: () => this.downloads.retryDownload(item.id), + }; + await this.withPending(item.id, operations[type]); + } + + clearFinished(scopePlaylistId?: string): void { + this.dialogs.openConfirmDialog({ + title: this.translate.instant( + 'DOWNLOADS.CLEAR_FINISHED_DIALOG.TITLE' + ), + message: this.translate.instant( + 'DOWNLOADS.CLEAR_FINISHED_DIALOG.MESSAGE' + ), + confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_FINISHED'), + onConfirm: () => this.performClearFinished(scopePlaylistId), + }); + } + + showActionError(error?: string): void { + const base = this.translate.instant('DOWNLOADS.ACTION_FAILED'); + this.snackBar.open(error ? `${base}: ${error}` : base, undefined, { + duration: 4000, + horizontalPosition: 'start', + }); + } + + showMessage(key: string, duration: number): void { + this.snackBar.open(this.translate.instant(key), undefined, { + duration, + horizontalPosition: 'start', + }); + } + + private confirmRemove(item: DownloadItem): void { + const dialogKey = + item.status === 'completed' + ? 'DOWNLOADS.REMOVE_COMPLETED_DIALOG' + : 'DOWNLOADS.REMOVE_PARTIAL_DIALOG'; + this.dialogs.openConfirmDialog({ + title: this.translate.instant(`${dialogKey}.TITLE`), + message: this.translate.instant(`${dialogKey}.MESSAGE`), + confirmLabel: this.translate.instant( + 'DOWNLOADS.REMOVE_FROM_MANAGER' + ), + onConfirm: () => + this.withPending(item.id, () => + this.downloads.removeDownload(item.id) + ), + }); + } + + private async performClearFinished( + scopePlaylistId?: string + ): Promise { + if (this.isClearing()) { + return; + } + this.isClearing.set(true); + try { + const result = await this.downloads.clearCompleted(scopePlaylistId); + if (!result.success) { + this.showActionError(); + } + } catch (error) { + this.showActionError( + error instanceof Error ? error.message : String(error) + ); + } finally { + this.isClearing.set(false); + } + } + + private async runFileAction( + type: 'play' | 'reveal', + item: DownloadItem + ): Promise { + const filePath = item.filePath; + if (!filePath) { + this.showMessage('DOWNLOADS.FILE_NOT_FOUND', 3000); + return; + } + await this.withPending( + item.id, + () => + type === 'play' + ? this.downloads.playDownload(filePath) + : this.downloads.revealFile(filePath), + (error) => this.showFileActionError(error) + ); + } + + private async copyUrl(item: DownloadItem): Promise { + await this.withPending( + item.id, + async () => { + try { + await navigator.clipboard.writeText(item.url); + this.showMessage('DOWNLOADS.URL_COPIED', 2000); + return { success: true }; + } catch { + return { success: false }; + } + }, + () => this.showMessage('DOWNLOADS.URL_COPY_FAILED', 3000) + ); + } + + private async withPending( + itemId: number, + operation: () => Promise, + onFailure: (error?: string) => void = (error) => + this.showActionError(error) + ): Promise { + if (this.pendingIds().has(itemId)) { + return; + } + this.pendingIds.update((ids) => new Set(ids).add(itemId)); + try { + const result = await operation(); + if (!result.success) { + onFailure(result.error); + } + } catch (error) { + onFailure(error instanceof Error ? error.message : String(error)); + } finally { + this.pendingIds.update((ids) => { + const next = new Set(ids); + next.delete(itemId); + return next; + }); + } + } + + private showFileActionError(error?: string): void { + this.showMessage( + error === 'File not found' + ? 'DOWNLOADS.FILE_NOT_FOUND' + : 'DOWNLOADS.FILE_ACTION_ERROR', + 3000 + ); + } +} 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 7f41fc1ec..dcb495a05 100644 --- a/libs/portal/downloads/feature/src/lib/download-queue.component.html +++ b/libs/portal/downloads/feature/src/lib/download-queue.component.html @@ -17,6 +17,7 @@ [class.download-queue__item--pending]=" isPending(row.item.id) " + [attr.aria-busy]="isPending(row.item.id) ? 'true' : null" [attr.data-test-id]="'download-queue-item-' + row.item.id" role="listitem" > @@ -84,6 +85,9 @@ - @if (hasClearableDownloads()) { - - } + {{ + 'DOWNLOADS.ACTIVE_COUNT' + | translate: { count: model().activeCount } + }} + } - + @if (isAvailable()) { +
+ {{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} + {{ formatBytes(model().trackedBytes) }} +
+ } + + + @if (isAvailable()) { +
+
+ + @if (downloadFolder(); as folder) { + + @if (downloadFolderParts().prefix) { + + } + + + } @else { + {{ 'DOWNLOADS.NO_FOLDER' | translate }} + } +
+ + @if (model().hasClearable) { + + } +
+ } + + + @if (isAvailable() && playlistsLoaded() && !hasNoPlaylists()) { + + } + +
@if (!isAvailable()) { -
- desktop_access_disabled -

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

- {{ 'DOWNLOADS.DESKTOP_ONLY' | translate }} +
+ +

{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}

+

{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}

} @else if (!playlistsLoaded()) {
-

- {{ 'CHANNELS.LOADING' | translate }} -

+ {{ + 'CHANNELS.LOADING' | translate + }} + @for (slot of skeletonItems; track slot) { + + } } @else if (hasNoPlaylists()) { @@ -106,41 +173,38 @@ /> } @else if (showDownloadSkeleton()) {
-

- {{ 'CHANNELS.LOADING' | translate }} -

+ {{ + 'CHANNELS.LOADING' | translate + }} + @for (slot of skeletonItems; track slot) { + + } } @else if (!hasScopedDownloads()) { @@ -152,334 +216,28 @@ secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD" (secondaryActionClicked)="goToDashboard()" /> - } @else if (filteredDownloads().length === 0) { -
- search_off -

{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}

- {{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }} + } @else if (!hasVisibleDownloads()) { +
+ +

{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}

+

{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}

} @else { -
-
- @for (item of filteredDownloads(); track item.id) { -
-
- @if (hasPoster(item)) { - - } @else { - {{ - getPosterPlaceholderIcon(item) - }} - } -
- -
-
- - {{ item.title }} - - @if (item.contentType === 'episode') { - - {{ formatEpisodeLabel(item) }} - - } -
- -
- - {{ - getStatusIcon(item.status) - }} - {{ - 'DOWNLOADS.STATUS.' + - item.status.toUpperCase() - | translate - }} - - - @if ( - item.status === 'downloading' || - item.status === 'paused' || - item.status === 'completed' - ) { - - {{ - formatBytes( - item.bytesDownloaded - ) - }} - @if (item.totalBytes) { - / - {{ - formatBytes(item.totalBytes) - }} - } - - } - - @if (item.errorMessage) { - - {{ item.errorMessage }} - - } -
- - @if (item.status === 'downloading') { - - } @else if ( - item.status === 'paused' && item.totalBytes - ) { - - } - -
- - @switch (item.status) { - @case ('queued') { - - - } - @case ('downloading') { - - - } - @case ('paused') { - - - - } - @case ('completed') { - - - - } - @case ('failed') { - - - } - @case ('canceled') { - - - } - } -
-
-
- } -
-
+ + } -
-
+
+ diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.scss b/libs/portal/downloads/feature/src/lib/downloads.component.scss index a0b21fd79..89f93feb6 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.scss +++ b/libs/portal/downloads/feature/src/lib/downloads.component.scss @@ -1,586 +1,448 @@ :host { - width: 100%; display: flex; + width: 100%; + height: 100%; min-width: 0; min-height: 0; -} - -%downloads-skeleton-shimmer { - position: relative; overflow: hidden; - background: color-mix( - in srgb, - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 56%, - var(--app-body-color, var(--mat-sys-on-surface-variant)) 44% - ); - - &::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 100deg, - transparent 8%, - color-mix( - in srgb, - var(--app-heading-color, var(--mat-sys-on-surface)) 18%, - transparent - ) - 44%, - transparent 74% - ); - transform: translateX(-120%); - animation: downloads-skeleton-shimmer 1.15s ease-in-out infinite; - } -} - -.downloads-layout { - display: flex; - flex: 1; - width: 100%; - min-width: 0; - min-height: 0; } .downloads { - flex: 1; display: flex; + flex: 1; flex-direction: column; height: 100%; min-width: 0; min-height: 0; overflow: hidden; + color: var(--app-body-color, var(--mat-sys-on-surface)); + background: var(--app-content-bg, var(--mat-sys-surface)); + container-type: inline-size; +} - &__header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 1rem; - padding: 24px 16px 12px; - border-bottom: 1px solid var(--mat-sys-outline-variant); +.downloads__header { + position: relative; + z-index: 2; + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: clamp(18px, 2.2vw, 28px) clamp(16px, 2.6vw, 34px) 16px; + border-bottom: 1px solid + var(--app-separator, var(--mat-sys-outline-variant)); + background: color-mix( + in srgb, + var(--mat-sys-surface) 94%, + var(--mat-sys-surface-container) 6% + ); +} - &-left { - display: flex; - align-items: center; - gap: 16px; +.downloads__heading { + display: grid; + gap: 7px; + min-width: 0; +} - .settings-group__header-text { - display: flex; - align-items: center; - gap: 0.75rem; +.downloads__title-row { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; - h1 { - margin: 0; - font-size: 1.2rem; - font-weight: 650; - letter-spacing: -0.02em; - } - } + h1 { + margin: 0; + font-size: clamp(1.25rem, 1.8vw, 1.6rem); + font-weight: 680; + line-height: 1.15; + letter-spacing: -0.035em; + } +} - .back-btn { - margin-right: 0.5rem; - } - } +.downloads__active-count { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 2px 9px; + border-radius: 999px; + color: var(--mat-sys-on-primary-container); + background: var(--mat-sys-primary-container); + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; +} - &-actions { - display: flex; - align-items: center; - justify-content: flex-end; - flex-wrap: wrap; - gap: 0.5rem; - } +.downloads__tracked { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-size: 0.78rem; + + strong { + color: var(--app-heading-color, var(--mat-sys-on-surface)); + font-family: + 'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', + monospace; + font-variant-numeric: tabular-nums; + font-weight: 650; + } +} + +.downloads__header-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; + + button { + flex: none; + } +} + +.downloads__folder { + display: inline-flex; + align-items: center; + gap: 7px; + min-width: 0; + max-width: min(32vw, 360px); + padding: 8px 10px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 10px; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + + mat-icon { + width: 18px; + height: 18px; + flex: none; + color: var(--mat-sys-primary); + font-size: 18px; } - &__badge { - background: var(--mat-sys-primary); - color: var(--mat-sys-on-primary); - padding: 0.125rem 0.5rem; - border-radius: 12px; - font-size: 0.75rem; - font-weight: 600; + > span { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace; + font-size: 0.74rem; } +} - &__folder-inline { - display: inline-flex; - align-items: center; - gap: 8px; - max-width: min(42vw, 460px); - padding: 7px 10px; - background: color-mix( +.downloads__folder-path { + display: flex; + min-width: 0; + max-width: 100%; + white-space: nowrap; +} + +.downloads__folder-prefix, +.downloads__folder-tail { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.downloads__folder-prefix { + min-width: 0; +} + +.downloads__folder-tail { + max-width: 100%; + flex: none; +} + +.downloads__folder-prefix + .downloads__folder-tail { + max-width: 65%; +} + +.downloads__filters { + position: relative; + z-index: 1; + display: flex; + flex: none; + gap: 8px; + padding: 12px clamp(16px, 2.6vw, 34px); + overflow-x: auto; + border-bottom: 1px solid + color-mix( in srgb, - var(--mat-sys-surface-container-high) 86%, + var(--app-separator, var(--mat-sys-outline-variant)) 74%, transparent ); - border: 1px solid var(--mat-sys-outline-variant); - border-radius: 8px; - box-shadow: inset 0 1px 0 - color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent); + scrollbar-width: none; - mat-icon { - width: 16px; - height: 16px; - font-size: 16px; - color: var(--mat-sys-primary); - flex-shrink: 0; - } + &::-webkit-scrollbar { + display: none; + } +} + +.downloads__filter { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 34px; + padding: 6px 11px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 999px; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); + font: inherit; + font-size: 0.79rem; + font-weight: 620; + white-space: nowrap; + cursor: pointer; + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease, + transform 150ms ease; + + &:hover { + color: var(--app-heading-color, var(--mat-sys-on-surface)); + border-color: var(--mat-sys-outline); } - &__folder-inline-path { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, - monospace; - font-size: 0.78rem; - color: var(--mat-sys-on-surface); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &:active { + transform: scale(0.98); } - &__empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex: 1; - text-align: center; - color: var(--mat-sys-on-surface-variant); - - mat-icon { - font-size: 4rem; - width: 4rem; - height: 4rem; - margin-bottom: 1rem; - opacity: 0.4; - } - - p { - margin: 0 0 0.5rem; - font-size: 1.05rem; - font-weight: 520; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - } - - span { - font-size: 0.875rem; - } + &:focus-visible { + outline: 2px solid var(--mat-sys-primary); + outline-offset: 2px; } - &__list-wrapper { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 0 16px; - container-type: inline-size; - - &--loading { - display: flex; - flex-direction: column; - gap: 12px; - } - } - - &__list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); - gap: 12px; - padding-bottom: 24px; - align-content: start; - - &--skeleton { - pointer-events: none; - } - } - - &__loading-label { - margin: 0; - padding: 2px 4px 0; - font-size: 0.86rem; - font-weight: 600; - letter-spacing: -0.01em; - color: var(--mat-sys-on-surface); - } - - &__skeleton-item { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - align-items: stretch; - gap: 1rem; - min-width: 0; - min-height: 124px; - padding: 1rem; - border-radius: 14px; - background: color-mix( + &.is-selected { + color: var(--mat-sys-on-primary-container); + border-color: color-mix( in srgb, - var(--app-widget-bg, var(--mat-sys-surface-container-high)) 88%, - var(--mat-sys-surface-container) 12% + var(--mat-sys-primary) 52%, + transparent ); - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - box-shadow: var(--app-widget-shadow, none); - } - - &__skeleton-poster { - @extend %downloads-skeleton-shimmer; - width: 64px; - height: 90px; - border-radius: 10px; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - } - - &__skeleton-info { - display: flex; - flex-direction: column; - gap: 0.55rem; - min-width: 0; - min-height: 90px; - } - - &__skeleton-title { - @extend %downloads-skeleton-shimmer; - width: min(70%, 280px); - height: 13px; - border-radius: 999px; - - &--short { - width: min(48%, 190px); - } - } - - &__skeleton-meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - - span { - @extend %downloads-skeleton-shimmer; - width: 88px; - height: 20px; - border-radius: 999px; - - &:last-child { - width: 132px; - } - } - } - - &__skeleton-progress { - @extend %downloads-skeleton-shimmer; - width: 100%; - height: 6px; - border-radius: 999px; - margin-top: 0.05rem; - } - - &__skeleton-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.35rem; - margin-top: auto; - padding-top: 0.65rem; - border-top: 1px solid - color-mix( - in srgb, - var(--app-separator, var(--mat-sys-outline-variant)) 72%, - transparent - ); - - span { - @extend %downloads-skeleton-shimmer; - width: 32px; - height: 32px; - border-radius: 9px; - } - } - - &__item { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - align-items: stretch; - gap: 1rem; - padding: 1rem; - min-width: 0; - background: var( - --app-widget-bg, - var(--mat-sys-surface-container-high) - ); - border-radius: 14px; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - box-shadow: var(--app-widget-shadow, none); - transition: - background 0.18s ease, - border-color 0.18s ease, - box-shadow 0.2s ease, - transform 0.2s ease; - - &--clickable { - cursor: pointer; - - &:focus-visible { - outline: 2px solid var(--mat-sys-primary); - outline-offset: 2px; - } - } - - &:hover { - background: var(--app-selection-surface); - border-color: color-mix( - in srgb, - var(--app-selection-border) 72%, - transparent - ); - } - - &--unavailable { - cursor: default; - } - - &-poster { - width: 64px; - height: 90px; - border-radius: 10px; - overflow: hidden; - flex-shrink: 0; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); - display: flex; - align-items: center; - justify-content: center; - border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); - - img { - width: 100%; - height: 100%; - object-fit: cover; - } - - mat-icon { - font-size: 2rem; - width: 2rem; - height: 2rem; - color: var(--mat-sys-on-surface-variant); - opacity: 0.5; - } - } - - &-info { - flex: 1; - min-width: 0; - min-height: 90px; - display: flex; - flex-direction: column; - gap: 0.5rem; - } - - &-title { - font-size: 1.05rem; - font-weight: 600; - color: var(--mat-sys-on-surface); - display: flex; - align-items: flex-start; - gap: 0.5rem; - min-width: 0; - } - - &-title-text { - min-width: 0; - display: -webkit-box; - overflow: hidden; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - line-height: 1.3; - } - - &-meta { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; - font-size: 0.85rem; - color: var(--mat-sys-on-surface-variant); - } - - &-status { - display: flex; - align-items: center; - gap: 0.35rem; - font-weight: 500; - padding: 2px 8px; - border-radius: 12px; - background: var( - --app-widget-header-bg, - var(--mat-sys-surface-container-high) - ); - - mat-icon { - font-size: 1.1rem; - width: 1.1rem; - height: 1.1rem; - } - - &.status-downloading { - color: #2196f3; - background: color-mix(in srgb, #2196f3 12%, transparent); - } - - &.status-completed { - color: #4caf50; - background: color-mix(in srgb, #4caf50 12%, transparent); - } - - &.status-failed { - color: var(--mat-sys-error); - background: color-mix( - in srgb, - var(--mat-sys-error) 12%, - transparent - ); - } - - &.status-queued { - color: var(--mat-sys-on-surface-variant); - } - - &.status-paused { - color: #f59e0b; - background: color-mix(in srgb, #f59e0b 14%, transparent); - } - } - - &-size { - font-family: - 'JetBrains Mono', 'SFMono-Regular', Menlo, Monaco, Consolas, - monospace; - background: transparent; - padding: 2px 8px; - border-radius: 12px; - } - - &-error { - color: var(--mat-sys-error); - font-size: 0.8rem; - background: color-mix( - in srgb, - var(--mat-sys-error) 12%, - transparent - ); - padding: 2px 8px; - border-radius: 12px; - } - - &-actions { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.2rem; - margin-top: auto; - padding-top: 0.65rem; - border-top: 1px solid - color-mix( - in srgb, - var(--app-separator, var(--mat-sys-outline-variant)) 72%, - transparent - ); - - button { - width: 32px; - height: 32px; - min-width: 32px; - min-height: 32px; - padding: 0; - border-radius: 9px; - --mdc-icon-button-state-layer-size: 32px; - transition: - background-color 0.15s ease, - transform 0.15s ease, - opacity 0.15s ease; - opacity: 0.86; - - &:hover { - background: var(--app-selection-surface); - transform: translateY(-1px); - opacity: 1; - } - } - - mat-icon { - width: 18px; - height: 18px; - font-size: 18px; - } - } - - mat-progress-bar { - margin-top: 0.15rem; - border-radius: 4px; - } - } - - &__episode-badge { - background: var(--mat-sys-primary); - color: var(--mat-sys-on-primary); - padding: 0.125rem 0.5rem; - border-radius: 6px; - font-size: 0.7rem; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; + background: var(--mat-sys-primary-container); } } -.sidebar-header h2 { - margin: 0; +.downloads__filter-count { + display: inline-grid; + min-width: 19px; + min-height: 19px; + place-items: center; + padding: 0 5px; + border-radius: 999px; + color: inherit; + background: color-mix(in srgb, currentColor 10%, transparent); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; } -@media (max-width: 980px) { - .downloads { - &__header { - align-items: flex-start; - gap: 12px; - } +.downloads__content { + display: flex; + flex: 1; + flex-direction: column; + gap: clamp(24px, 3vw, 38px); + min-width: 0; + min-height: 0; + padding: clamp(18px, 2.5vw, 32px) clamp(16px, 2.6vw, 34px) + clamp(28px, 4vw, 48px); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; +} - &__header-actions { - width: 100%; - justify-content: flex-start; - } +.downloads__message { + display: grid; + flex: 1; + place-content: center; + justify-items: center; + min-height: 260px; + padding: 32px; + text-align: center; - &__folder-inline { - max-width: 100%; - } + mat-icon { + width: 46px; + height: 46px; + margin-bottom: 12px; + color: var(--mat-sys-primary); + font-size: 46px; + opacity: 0.72; + } - &__list { - grid-template-columns: 1fr; - } + h2 { + margin: 0; + color: var(--app-heading-color, var(--mat-sys-on-surface)); + font-size: 1rem; + font-weight: 670; + letter-spacing: -0.015em; + } + + p { + max-width: 460px; + margin: 7px 0 0; + color: var(--app-body-color, var(--mat-sys-on-surface-variant)); + font-size: 0.86rem; + line-height: 1.5; + } + + &--compact { + min-height: 220px; + } +} + +.downloads__skeleton { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 10px; +} + +.downloads__skeleton-row { + display: grid; + grid-template-columns: 46px minmax(0, 1fr) 32px; + align-items: center; + gap: 13px; + min-height: 78px; + padding: 11px 13px; + border: 1px solid var(--app-separator, var(--mat-sys-outline-variant)); + border-radius: 12px; + background: var(--app-widget-bg, var(--mat-sys-surface-container-low)); +} + +.downloads__skeleton-poster, +.downloads__skeleton-copy, +.downloads__skeleton-action, +.downloads__skeleton-heading, +.downloads__skeleton-card-artwork, +.downloads__skeleton-card-copy { + position: relative; + display: block; + overflow: hidden; + border-radius: 8px; + background: var( + --app-widget-header-bg, + var(--mat-sys-surface-container-high) + ); + + &::after { + position: absolute; + inset: 0; + background: linear-gradient( + 105deg, + transparent 20%, + color-mix(in srgb, var(--mat-sys-on-surface) 12%, transparent) 48%, + transparent 76% + ); + transform: translateX(-110%); + animation: downloads-shimmer 1.2s ease-in-out infinite; + content: ''; + } +} + +.downloads__skeleton-poster { + width: 46px; + height: 62px; +} + +.downloads__skeleton-copy { + width: min(78%, 260px); + height: 12px; +} + +.downloads__skeleton-action { + width: 32px; + height: 32px; +} + +.downloads__skeleton-library { + display: grid; + grid-column: 1 / -1; + gap: 12px; + margin-top: 18px; +} + +.downloads__skeleton-heading { + width: 142px; + height: 18px; +} + +.downloads__skeleton-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); + gap: clamp(12px, 1.5vw, 20px); +} + +.downloads__skeleton-card { + display: grid; + gap: 10px; + min-width: 0; + padding-bottom: 12px; +} + +.downloads__skeleton-card-artwork { + width: 100%; + aspect-ratio: 2 / 3; + border-radius: 14px; +} + +.downloads__skeleton-card-copy { + width: 76%; + height: 12px; + margin-inline: 8px; +} + +.downloads__sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@keyframes downloads-shimmer { + to { + transform: translateX(110%); } } @container (max-width: 760px) { - .downloads__item { - align-items: start; + .downloads__header { + align-items: stretch; + flex-direction: column; + gap: 14px; } - .downloads__skeleton-item { - align-items: start; + .downloads__header-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .downloads__folder { + max-width: 100%; + flex: 1 1 240px; + } +} + +@container (max-width: 480px) { + .downloads__header-actions button { + flex: 1 1 auto; + } + + .downloads__content { + padding-inline: 12px; + } + + .downloads__filters { + padding-inline: 12px; } } @media (prefers-reduced-motion: reduce) { - .downloads__skeleton-poster, - .downloads__skeleton-title, - .downloads__skeleton-meta span, - .downloads__skeleton-progress, - .downloads__skeleton-actions span { - &::after { - animation: none; - transform: none; - } + .downloads__filter { + transition: none; } -} -@keyframes downloads-skeleton-shimmer { - 100% { - transform: translateX(120%); + .downloads__skeleton-poster::after, + .downloads__skeleton-copy::after, + .downloads__skeleton-action::after { + animation: none; } } diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts new file mode 100644 index 000000000..14a0904ec --- /dev/null +++ b/libs/portal/downloads/feature/src/lib/downloads.component.spec.ts @@ -0,0 +1,637 @@ +import { computed, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + type DownloadItem, + DownloadsService, + PlaylistsService, +} from '@iptvnator/services'; +import { + PORTAL_SHELL_ACTIONS, + PortalCollectionContextService, +} from '@iptvnator/portal/shared/util'; +import type { Playlist } from '@iptvnator/shared/interfaces'; +import { DialogService } from '@iptvnator/ui/components'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { DownloadLibraryNavigationService } from './download-library-navigation.service'; +import { DownloadManagerActionsService } from './download-manager-actions.service'; +import type { DownloadSeriesCardViewModel } from './download-manager.viewmodel'; +import { DownloadsComponent } from './downloads.component'; + +interface Deferred { + readonly promise: Promise; + resolve(value: T): void; +} + +interface ConfirmConfig { + readonly title: string; + readonly message: string; + readonly onConfirm: () => Promise | void; +} + +interface ExpectedDownloadsComponent { + readonly model: () => { + readonly active: readonly { readonly item: DownloadItem }[]; + readonly activeCount: number; + readonly trackedBytes: number; + }; + readonly pendingIds: () => ReadonlySet; + clearFinished(): void; + openDownloadedSeries(group: DownloadSeriesCardViewModel): void; + openInLibrary(item: DownloadItem): Promise; + runAction(action: { + readonly type: string; + readonly item: DownloadItem; + }): Promise | void; + setFilter(filter: string): void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((settle) => { + resolve = settle; + }); + return { promise, resolve }; +} + +function download( + id: number, + overrides: Partial = {} +): DownloadItem { + return { + id, + playlistId: 'playlist-a', + xtreamId: 1000 + id, + contentType: 'vod', + title: `Download ${id}`, + url: `https://example.com/${id}.mp4`, + status: 'completed', + bytesDownloaded: id * 10, + filePath: `/downloads/${id}.mp4`, + createdAt: `2026-07-${String(id).padStart(2, '0')}T12:00:00Z`, + ...overrides, + }; +} + +function playlist(id: string, title: string): Playlist { + return { _id: id, title } as Playlist; +} + +describe('DownloadsComponent', () => { + let fixture: ComponentFixture; + let component: ExpectedDownloadsComponent; + let downloads: ReturnType>; + let routeParams: BehaviorSubject>; + let queryParams: BehaviorSubject>; + let playlistItems: BehaviorSubject; + let collectionContext: PortalCollectionContextService; + let dialogConfigs: ConfirmConfig[]; + let dialogActionListener: + ((action: { type: string; item: DownloadItem }) => void) | undefined; + let dialogClosed: Subject; + let dialogActionUnsubscribe: jest.Mock; + let dialogSetInput: jest.Mock; + let downloadsService: { + readonly downloads: typeof downloads; + readonly downloadFolder: ReturnType>; + readonly isAvailable: ReturnType>; + readonly isLoadingDownloads: ReturnType>; + readonly hasLoadedDownloads: ReturnType>; + readonly activeCount: ReturnType>; + loadDownloads: jest.Mock; + cancelDownload: jest.Mock; + pauseDownload: jest.Mock; + resumeDownload: jest.Mock; + retryDownload: jest.Mock; + removeDownload: jest.Mock; + playDownload: jest.Mock; + revealFile: jest.Mock; + selectFolder: jest.Mock; + clearCompleted: jest.Mock; + formatBytes: jest.Mock; + }; + let navigation: { + canOpen: jest.Mock; + open: jest.Mock; + }; + let snackBar: { open: jest.Mock }; + let matDialog: { open: jest.Mock }; + + const success = async () => ({ success: true }); + + beforeEach(async () => { + downloads = signal([]); + routeParams = new BehaviorSubject(convertToParamMap({})); + queryParams = new BehaviorSubject(convertToParamMap({})); + playlistItems = new BehaviorSubject([ + playlist('playlist-a', 'Alpha source'), + playlist('playlist-b', 'Beta source'), + ]); + dialogConfigs = []; + dialogClosed = new Subject(); + dialogActionUnsubscribe = jest.fn(); + dialogSetInput = jest.fn(); + dialogActionListener = undefined; + snackBar = { open: jest.fn() }; + navigation = { + canOpen: jest.fn(() => true), + open: jest.fn(async () => true), + }; + matDialog = { + open: jest.fn(() => ({ + componentRef: { setInput: dialogSetInput }, + componentInstance: { + itemAction: { + subscribe: jest.fn( + ( + listener: (action: { + type: string; + item: DownloadItem; + }) => void + ) => { + dialogActionListener = listener; + return { + unsubscribe: dialogActionUnsubscribe, + }; + } + ), + }, + }, + afterClosed: () => dialogClosed.asObservable(), + })), + }; + downloadsService = { + downloads, + downloadFolder: signal('/downloads'), + isAvailable: signal(true), + isLoadingDownloads: signal(false), + hasLoadedDownloads: signal(true), + activeCount: computed( + () => + downloads().filter( + ({ status }) => + status === 'queued' || status === 'downloading' + ).length + ), + loadDownloads: jest.fn(async () => undefined), + cancelDownload: jest.fn(success), + pauseDownload: jest.fn(success), + resumeDownload: jest.fn(success), + retryDownload: jest.fn(success), + removeDownload: jest.fn(success), + playDownload: jest.fn(success), + revealFile: jest.fn(success), + selectFolder: jest.fn(async () => '/downloads'), + clearCompleted: jest.fn(success), + formatBytes: jest.fn((bytes: number) => `${bytes} B`), + }; + + await TestBed.configureTestingModule({ + imports: [ + DownloadsComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: ActivatedRoute, + useValue: { + paramMap: routeParams.asObservable(), + queryParamMap: queryParams.asObservable(), + snapshot: { + params: {}, + paramMap: convertToParamMap({}), + queryParamMap: convertToParamMap({}), + }, + }, + }, + { + provide: Router, + useValue: { navigate: jest.fn(async () => true) }, + }, + { + provide: DownloadsService, + useValue: downloadsService, + }, + { + provide: PlaylistsService, + useValue: { + getAllPlaylists: () => playlistItems.asObservable(), + }, + }, + { + provide: DialogService, + useValue: { + openConfirmDialog: (config: ConfirmConfig) => + dialogConfigs.push(config), + }, + }, + { provide: MatDialog, useValue: matDialog }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: PORTAL_SHELL_ACTIONS, + useValue: { openAddPlaylistDialog: jest.fn() }, + }, + PortalCollectionContextService, + ], + }) + .overrideComponent(DownloadsComponent, { + set: { + providers: [ + { + provide: DownloadLibraryNavigationService, + useValue: navigation, + }, + { provide: MatDialog, useValue: matDialog }, + DownloadManagerActionsService, + ], + }, + }) + .compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + DOWNLOADS: { + TITLE: 'Downloads', + ACTIVE_COUNT: '{{count}} active', + TRACKED_DOWNLOADS: 'Tracked downloads', + CHANGE_FOLDER: 'Change folder', + CLEAR_FINISHED: 'Clear finished', + FILTER: { + LABEL: 'Filter downloads', + ALL: 'All', + MOVIES: 'Movies', + SERIES: 'Series', + IN_PROGRESS: 'In progress', + }, + REMOVE_COMPLETED_DIALOG: { + TITLE: 'Remove from manager?', + MESSAGE: 'The downloaded media file remains on disk.', + }, + REMOVE_PARTIAL_DIALOG: { + TITLE: 'Remove partial download?', + MESSAGE: 'Retained partial download data will be deleted.', + }, + CLEAR_FINISHED_DIALOG: { + TITLE: 'Clear finished downloads?', + MESSAGE: + 'Completed files remain; retained partial data is deleted.', + }, + ACTION_FAILED: 'Action failed', + FILE_NOT_FOUND: 'File not found', + FILE_ACTION_ERROR: 'File action failed', + SOURCE_PLAYLIST_MISSING: 'Source playlist missing', + URL_COPIED: 'URL copied', + URL_COPY_FAILED: 'URL copy failed', + REMOVE_FROM_MANAGER: 'Remove from manager', + }, + CHANNELS: { LOADING: 'Loading' }, + WORKSPACE: { + SHELL: { + GO_TO_SOURCES: 'Go to sources', + GO_TO_DASHBOARD: 'Go to dashboard', + }, + }, + HOME: { PLAYLISTS: { ADD_YOUR_FIRST: 'Add playlist' } }, + }); + translate.use('en'); + + collectionContext = TestBed.inject(PortalCollectionContextService); + fixture = TestBed.createComponent(DownloadsComponent); + component = + fixture.componentInstance as unknown as ExpectedDownloadsComponent; + fixture.detectChanges(); + TestBed.flushEffects(); + }); + + afterEach(() => { + fixture?.destroy(); + dialogClosed.complete(); + }); + + it('loads the authoritative global list without a playlist argument', () => { + expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1); + expect(downloadsService.loadDownloads).toHaveBeenCalledWith(); + }); + + it('keeps the folder tail visible while exposing the complete path', () => { + const folder = '/Users/viewer/Media/Very Long Folder/Downloads'; + downloadsService.downloadFolder.set(folder); + fixture.detectChanges(); + + const path = fixture.nativeElement.querySelector( + '.downloads__folder-path' + ) as HTMLElement; + expect(path.getAttribute('aria-label')).toBe(folder); + expect( + path.querySelector('.downloads__folder-prefix')?.textContent?.trim() + ).toBe('/Users/viewer/Media/Very Long Folder'); + expect( + path.querySelector('.downloads__folder-tail')?.textContent?.trim() + ).toBe('/Downloads'); + expect( + fixture.nativeElement + .querySelector('[data-test-id="downloads-folder"]') + ?.getAttribute('title') + ).toBe(folder); + }); + + it('shapes the loading state as both queue rows and poster cards', () => { + downloadsService.hasLoadedDownloads.set(false); + downloadsService.isLoadingDownloads.set(true); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelectorAll('.downloads__skeleton-row') + ).toHaveLength(6); + expect( + fixture.nativeElement.querySelectorAll('.downloads__skeleton-card') + ).toHaveLength(5); + expect( + fixture.nativeElement.querySelector( + '[data-test-id="downloads-library-skeleton"]' + ) + ).not.toBeNull(); + }); + + it('derives route scope without mutating the global service signal', () => { + const globalItems = [ + download(1, { status: 'downloading' }), + download(2, { + playlistId: 'playlist-b', + status: 'downloading', + }), + ]; + downloads.set(globalItems); + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + fixture.detectChanges(); + + expect(component.model().active.map(({ item }) => item.id)).toEqual([ + 1, + ]); + expect(component.model().activeCount).toBe(1); + expect(downloadsService.activeCount()).toBe(2); + expect(downloads()).toBe(globalItems); + }); + + it('combines the shared category and q search in the pure model', () => { + downloads.set([ + download(1, { + title: 'Needle movie', + status: 'downloading', + }), + download(2, { + contentType: 'episode', + title: 'Needle series - S01E01 - Pilot', + seriesXtreamId: 20, + seasonNumber: 1, + episodeNumber: 1, + status: 'downloading', + }), + download(3, { title: 'Other movie', status: 'downloading' }), + ]); + collectionContext.setCategoryId('series'); + queryParams.next(convertToParamMap({ q: 'needle' })); + fixture.detectChanges(); + + expect(component.model().active.map(({ item }) => item.id)).toEqual([ + 2, + ]); + }); + + it('keeps inline filters synchronized with the shell context', () => { + component.setFilter('in-progress'); + + expect(collectionContext.selectedCategoryId()).toBe('in-progress'); + }); + + it('keeps tracked bytes scoped but independent from search and filter', () => { + downloads.set([ + download(1, { bytesDownloaded: 10 }), + download(2, { + playlistId: 'playlist-b', + bytesDownloaded: 100, + }), + download(3, { bytesDownloaded: 20 }), + ]); + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + collectionContext.setCategoryId('series'); + queryParams.next(convertToParamMap({ q: 'missing' })); + fixture.detectChanges(); + + expect(component.model().trackedBytes).toBe(30); + expect(component.model().active).toEqual([]); + }); + + it.each([ + [ + 'completed', + 'Remove from manager?', + 'downloaded media file remains on disk', + ], + [ + 'paused', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + [ + 'failed', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + [ + 'canceled', + 'Remove partial download?', + 'Retained partial download data will be deleted', + ], + ] as const)( + 'uses truthful %s removal confirmation copy', + async (status, title, message) => { + const item = download(7, { status }); + + component.runAction({ type: 'remove', item }); + + expect(dialogConfigs).toHaveLength(1); + expect(dialogConfigs[0]).toEqual( + expect.objectContaining({ title }) + ); + expect(dialogConfigs[0].message).toContain(message); + expect(downloadsService.removeDownload).not.toHaveBeenCalled(); + + await dialogConfigs[0].onConfirm(); + expect(downloadsService.removeDownload).toHaveBeenCalledWith(7); + } + ); + + it('describes both finalized and retained-partial outcomes when clearing', async () => { + component.clearFinished(); + + expect(dialogConfigs[0]).toEqual( + expect.objectContaining({ + title: 'Clear finished downloads?', + message: + 'Completed files remain; retained partial data is deleted.', + }) + ); + + await dialogConfigs[0].onConfirm(); + expect(downloadsService.clearCompleted).toHaveBeenCalledWith(undefined); + }); + + it('passes playlist scope when clearing a scoped route', async () => { + routeParams.next(convertToParamMap({ id: 'playlist-a' })); + fixture.detectChanges(); + + component.clearFinished(); + await dialogConfigs[0].onConfirm(); + + expect(downloadsService.clearCompleted).toHaveBeenCalledWith( + 'playlist-a' + ); + }); + + it('holds an item pending until the authoritative operation settles', async () => { + const operation = deferred<{ success: boolean }>(); + downloadsService.pauseDownload.mockReturnValueOnce(operation.promise); + const item = download(8, { status: 'downloading' }); + + const action = component.runAction({ type: 'pause', item }); + + expect(component.pendingIds().has(8)).toBe(true); + operation.resolve({ success: true }); + await action; + expect(component.pendingIds().has(8)).toBe(false); + }); + + it('does not dispatch the same pending item twice', async () => { + const operation = deferred<{ success: boolean }>(); + downloadsService.pauseDownload.mockReturnValue(operation.promise); + const item = download(9, { status: 'downloading' }); + + const first = component.runAction({ type: 'pause', item }); + const second = component.runAction({ type: 'pause', item }); + + expect(downloadsService.pauseDownload).toHaveBeenCalledTimes(1); + operation.resolve({ success: true }); + await Promise.all([first, second]); + }); + + it('reports operation failures through the existing snackbar path', async () => { + downloadsService.retryDownload.mockResolvedValueOnce({ + success: false, + error: 'network offline', + }); + + await component.runAction({ + type: 'retry', + item: download(10, { status: 'failed' }), + }); + + expect(snackBar.open).toHaveBeenCalledWith( + 'Action failed: network offline', + undefined, + expect.objectContaining({ duration: 4000 }) + ); + }); + + it('routes dialog episode actions through the shared dispatcher and cleans up', async () => { + const item = download(11, { + contentType: 'episode', + episodeNumber: 1, + seasonNumber: 1, + seriesXtreamId: 44, + title: 'Series - S01E01 - Pilot', + }); + const group = { + kind: 'series', + key: 'series:playlist-a:44', + newestTimestamp: 1, + sourceName: 'Alpha source', + trackedBytes: 10, + representative: item, + members: [item], + seriesXtreamId: 44, + title: 'Series', + } satisfies DownloadSeriesCardViewModel; + downloads.set([item]); + + component.openDownloadedSeries(group); + dialogActionListener?.({ type: 'play', item }); + await fixture.whenStable(); + + expect(matDialog.open).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + data: group, + maxWidth: 'min(720px, calc(100vw - 32px))', + width: 'min(720px, calc(100vw - 32px))', + }) + ); + expect(dialogSetInput).toHaveBeenCalledWith( + 'pendingIds', + component.pendingIds + ); + const membersInput = dialogSetInput.mock.calls.find( + ([name]) => name === 'members' + )?.[1] as (() => readonly DownloadItem[]) | undefined; + expect(membersInput?.()).toEqual([item]); + expect(downloadsService.playDownload).toHaveBeenCalledWith( + item.filePath + ); + + downloads.set([]); + expect(membersInput?.()).toEqual([]); + + dialogClosed.next(); + expect(dialogActionUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('uses the isolated navigation seam and reports a rejected route', async () => { + const item = download(12); + navigation.open.mockResolvedValueOnce(false); + + await component.openInLibrary(item); + + expect(navigation.canOpen).toHaveBeenCalledWith( + item, + new Set(['playlist-a', 'playlist-b']) + ); + expect(navigation.open).toHaveBeenCalledWith(item); + expect(snackBar.open).toHaveBeenCalledWith( + 'Action failed', + undefined, + expect.objectContaining({ duration: 4000 }) + ); + }); + + it('does not navigate an item whose source playlist is missing', async () => { + const item = download(13, { playlistId: 'removed-playlist' }); + navigation.canOpen.mockReturnValueOnce(false); + + await component.openInLibrary(item); + + expect(navigation.open).not.toHaveBeenCalled(); + expect(snackBar.open).toHaveBeenCalledWith( + 'Source playlist missing', + undefined, + expect.objectContaining({ duration: 3000 }) + ); + }); + + it('renders one scroll owner with fixed controls and both child surfaces', () => { + downloads.set([download(14, { status: 'downloading' }), download(15)]); + fixture.detectChanges(); + + const root = fixture.nativeElement as HTMLElement; + expect(root.querySelectorAll('.downloads__content')).toHaveLength(1); + expect( + root.querySelector('[data-test-id="downloads-filters"]') + ).toBeTruthy(); + expect(root.querySelector('app-download-queue')).toBeTruthy(); + expect(root.querySelector('app-download-library')).toBeTruthy(); + }); +}); diff --git a/libs/portal/downloads/feature/src/lib/downloads.component.ts b/libs/portal/downloads/feature/src/lib/downloads.component.ts index c57ac4daf..9276f205f 100644 --- a/libs/portal/downloads/feature/src/lib/downloads.component.ts +++ b/libs/portal/downloads/feature/src/lib/downloads.component.ts @@ -1,59 +1,68 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, computed, effect, inject, - signal, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatIcon } from '@angular/material/icon'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { DialogService } from '@iptvnator/ui/components'; -import { map, startWith } from 'rxjs'; import { type DownloadItem, DownloadsService, PlaylistsService, } from '@iptvnator/services'; import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; -import { queryParamSignal } from '@iptvnator/portal/shared/util'; -import { createPortalCollectionContext } from '@iptvnator/portal/shared/util'; import { - buildStandardCollectionCategories, - filterCollectionBucket, PORTAL_SHELL_ACTIONS, + PortalCollectionContextService, + buildStandardCollectionCategories, + createPortalCollectionContext, + queryParamSignal, } from '@iptvnator/portal/shared/util'; -import { PortalCollectionContextService } from '@iptvnator/portal/shared/util'; -import { Playlist } from '@iptvnator/shared/interfaces'; +import type { Playlist, XtreamCategory } from '@iptvnator/shared/interfaces'; +import { map, startWith, take } from 'rxjs'; +import type { DownloadItemAction } from './download-actions'; import { DownloadLibraryNavigationService } from './download-library-navigation.service'; +import { DownloadLibraryComponent } from './download-library.component'; +import { DownloadManagerActionsService } from './download-manager-actions.service'; +import { + type DownloadSeriesCardViewModel, + buildDownloadManagerViewModel, + normalizeDownloadFilter, +} from './download-manager.viewmodel'; +import { DownloadQueueComponent } from './download-queue.component'; +import { DownloadedSeriesDialogComponent } from './downloaded-series-dialog.component'; -const DOWNLOAD_COLLECTION_LABELS = { - all: 'All', - movie: 'Movies', - live: 'Live TV', - series: 'Series', -}; +const FILTER_KEYS = { + all: 'DOWNLOADS.FILTER.ALL', + movie: 'DOWNLOADS.FILTER.MOVIES', + series: 'DOWNLOADS.FILTER.SERIES', + inProgress: 'DOWNLOADS.FILTER.IN_PROGRESS', +} as const; @Component({ selector: 'app-downloads', templateUrl: './downloads.component.html', - styleUrls: [ - './downloads.component.scss', - '../../../../shared/ui/src/lib/styles/portal-sidebar.scss', - ], + styleUrl: './downloads.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [DownloadLibraryNavigationService], + providers: [ + DownloadLibraryNavigationService, + DownloadManagerActionsService, + ], imports: [ + DownloadLibraryComponent, + DownloadQueueComponent, EmptyStateComponent, MatButtonModule, + MatDialogModule, MatIcon, - MatProgressBarModule, MatTooltip, TranslatePipe, ], @@ -62,45 +71,103 @@ export class DownloadsComponent { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly playlistsService = inject(PlaylistsService); - private readonly libraryNavigation = inject( - DownloadLibraryNavigationService - ); private readonly collectionCtx = inject(PortalCollectionContextService); - private readonly dialogService = inject(DialogService); + private readonly dialog = inject(MatDialog); + private readonly navigation = inject(DownloadLibraryNavigationService); + private readonly actions = inject(DownloadManagerActionsService); private readonly translate = inject(TranslateService); - private readonly snackBar = inject(MatSnackBar); private readonly shellActions = inject(PORTAL_SHELL_ACTIONS); + private readonly destroyRef = inject(DestroyRef); readonly downloadsService = inject(DownloadsService); - readonly downloads = this.downloadsService.downloads; + readonly pendingIds = this.actions.pendingIds; + readonly isClearing = this.actions.isClearing; readonly downloadFolder = this.downloadsService.downloadFolder; + readonly downloadFolderParts = computed(() => { + const path = this.downloadFolder(); + const separatorIndex = Math.max( + path.lastIndexOf('/'), + path.lastIndexOf('\\') + ); + return separatorIndex < 0 + ? { prefix: '', tail: path } + : { + prefix: path.slice(0, separatorIndex), + tail: path.slice(separatorIndex), + }; + }); readonly isAvailable = this.downloadsService.isAvailable; readonly isLoadingDownloads = this.downloadsService.isLoadingDownloads; readonly hasLoadedDownloads = this.downloadsService.hasLoadedDownloads; - readonly activeCount = this.downloadsService.activeCount; readonly playlistId = toSignal( this.route.paramMap.pipe(map((params) => params.get('id') ?? '')), { initialValue: this.route.snapshot.params['id'] ?? '' } ); readonly searchTerm = queryParamSignal(this.route, 'q', (value) => - (value ?? '').trim().toLowerCase() + (value ?? '').trim() ); readonly playlists = toSignal( this.playlistsService.getAllPlaylists().pipe(startWith(null)), - { - initialValue: null as Playlist[] | null, - } + { initialValue: null as Playlist[] | null } + ); + readonly languageChange = toSignal( + this.translate.onLangChange.pipe(startWith(null)), + { initialValue: null } ); - readonly playlistsLoaded = computed(() => this.playlists() !== null); readonly playlistItems = computed(() => this.playlists() ?? []); + readonly playlistsLoaded = computed(() => this.playlists() !== null); readonly hasNoPlaylists = computed( () => this.playlistsLoaded() && this.playlistItems().length === 0 ); - readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); - readonly skeletonActionSlots = Array.from( - { length: 4 }, - (_, index) => index + readonly availablePlaylistIds = computed( + () => new Set(this.playlistItems().map(({ _id }) => _id)) ); + readonly selectedCategoryId = this.collectionCtx.selectedCategoryId; + readonly model = computed(() => + buildDownloadManagerViewModel({ + downloads: this.downloadsService.downloads(), + playlists: this.playlistItems(), + scopePlaylistId: this.playlistId() || undefined, + filter: this.selectedCategoryId(), + searchTerm: this.searchTerm(), + }) + ); + readonly categories = computed(() => this.buildCategories()); + readonly collectionContext = createPortalCollectionContext({ + ctx: this.collectionCtx, + categories: this.categories, + }); + readonly hasScopedDownloads = computed( + () => this.model().scopedItems.length > 0 + ); + readonly hasVisibleDownloads = computed(() => { + const model = this.model(); + return ( + model.active.length + + model.attention.length + + model.library.length > + 0 + ); + }); + readonly showDownloadSkeleton = computed( + () => + !this.hasScopedDownloads() && + (!this.hasLoadedDownloads() || this.isLoadingDownloads()) + ); + readonly skeletonItems = Array.from({ length: 6 }, (_, index) => index); + readonly skeletonCards = Array.from({ length: 5 }, (_, index) => index); + + constructor() { + void this.downloadsService.loadDownloads(); + effect(() => { + this.playlistId(); + this.collectionContext.setCategoryId('all'); + }); + } + + setFilter(filter: string): void { + this.collectionContext.setCategoryId(normalizeDownloadFilter(filter)); + } addPlaylist(): void { this.shellActions.openAddPlaylistDialog(); @@ -114,327 +181,86 @@ export class DownloadsComponent { void this.router.navigate(['/workspace', 'dashboard']); } - readonly scopedDownloads = computed(() => { - const playlistId = this.playlistId(); - const downloads = this.downloads(); - return playlistId - ? downloads.filter((item) => item.playlistId === playlistId) - : downloads; - }); - readonly hasScopedDownloads = computed( - () => this.scopedDownloads().length > 0 - ); - readonly showDownloadSkeleton = computed( - () => - !this.hasScopedDownloads() && - (!this.hasLoadedDownloads() || this.isLoadingDownloads()) - ); - - readonly categories = computed(() => { - const downloads = this.scopedDownloads(); - const moviesCount = downloads.filter( - (item) => item.contentType === 'vod' - ).length; - const seriesCount = downloads.filter( - (item) => item.contentType === 'episode' - ).length; - - return buildStandardCollectionCategories({ - labels: DOWNLOAD_COLLECTION_LABELS, - counts: { - all: downloads.length, - movie: moviesCount, - series: seriesCount, - }, - }); - }); - readonly collectionContext = createPortalCollectionContext({ - ctx: this.collectionCtx, - categories: this.categories, - }); - readonly selectedCategoryId = this.collectionContext.selectedCategoryId; - readonly failedPosterKeys = signal>({}); - - /** Filter downloads for current playlist and sort by newest first */ - readonly filteredDownloads = computed(() => { - const term = this.searchTerm(); - const downloads = this.scopedDownloads(); - const filteredByTerm = filterCollectionBucket({ - selectedCategoryId: this.selectedCategoryId(), - allItems: downloads, - buckets: { - movie: downloads.filter((item) => item.contentType === 'vod'), - series: downloads.filter( - (item) => item.contentType === 'episode' - ), - }, - searchTerm: term, - textOf: (item) => `${item.title ?? ''} ${item.errorMessage ?? ''}`, - }); - - // Sort by createdAt descending (newest first) - return [...filteredByTerm].sort((a, b) => { - const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; - const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; - return dateB - dateA; - }); - }); - readonly hasClearableDownloads = computed(() => - this.scopedDownloads().some( - (item) => - item.status === 'completed' || - item.status === 'failed' || - item.status === 'canceled' - ) - ); - readonly availablePlaylistIds = computed( - () => new Set(this.playlistItems().map((playlist) => playlist._id)) - ); - - constructor() { - effect(() => { - this.playlistId(); - this.collectionContext.setCategoryId('all'); - void this.downloadsService.loadDownloads(); - }); - } - - getProgress(item: DownloadItem): number { - return this.downloadsService.getProgressPercent(item); - } - - formatBytes(bytes: number | undefined): string { - if (!bytes) return '0 B'; - return this.downloadsService.formatBytes(bytes); - } - - getStatusIcon(status: string): string { - switch (status) { - case 'queued': - return 'schedule'; - case 'downloading': - return 'downloading'; - case 'paused': - return 'pause_circle'; - case 'completed': - return 'check_circle'; - case 'failed': - return 'error'; - case 'canceled': - return 'cancel'; - default: - return 'help'; - } - } - - getStatusColor(status: string): string { - switch (status) { - case 'queued': - return 'status-queued'; - case 'downloading': - return 'status-downloading'; - case 'paused': - return 'status-paused'; - case 'completed': - return 'status-completed'; - case 'failed': - case 'canceled': - return 'status-failed'; - default: - return ''; - } - } - - async copyUrl(item: DownloadItem): Promise { - try { - await navigator.clipboard.writeText(item.url); - this.snackBar.open( - this.translate.instant('DOWNLOADS.URL_COPIED'), - undefined, - { duration: 2000, horizontalPosition: 'start' } - ); - } catch { - this.snackBar.open( - this.translate.instant('DOWNLOADS.URL_COPY_FAILED'), - undefined, - { duration: 3000, horizontalPosition: 'start' } - ); - } - } - - async cancel(item: DownloadItem) { - const result = await this.downloadsService.cancelDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async pause(item: DownloadItem) { - const result = await this.downloadsService.pauseDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async resume(item: DownloadItem) { - const result = await this.downloadsService.resumeDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async retry(item: DownloadItem) { - const result = await this.downloadsService.retryDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async remove(item: DownloadItem) { - const result = await this.downloadsService.removeDownload(item.id); - if (!result.success) { - this.showActionError(result.error); - } - } - - async play(item: DownloadItem) { - if (item.filePath) { - const result = await this.downloadsService.playDownload( - item.filePath - ); - if (!result.success) { - this.handleFileActionError(result.error); - } - } - } - - async reveal(item: DownloadItem) { - if (item.filePath) { - const result = await this.downloadsService.revealFile( - item.filePath - ); - if (!result.success) { - this.handleFileActionError(result.error); - } - } - } - - async changeFolder() { + async changeFolder(): Promise { await this.downloadsService.selectFolder(); } - async clearCompleted() { - this.dialogService.openConfirmDialog({ - title: this.translate.instant( - 'DOWNLOADS.CLEAR_COMPLETED_DIALOG.TITLE' - ), - message: this.translate.instant( - 'DOWNLOADS.CLEAR_COMPLETED_DIALOG.MESSAGE' - ), - confirmLabel: this.translate.instant('DOWNLOADS.CLEAR_COMPLETED'), - onConfirm: async (): Promise => { - await this.downloadsService.clearCompleted(this.playlistId()); - }, - }); - } - - formatEpisodeLabel(item: DownloadItem): string { - if (item.contentType !== 'episode') return ''; - const s = item.seasonNumber?.toString().padStart(2, '0') || '00'; - const e = item.episodeNumber?.toString().padStart(2, '0') || '00'; - return `S${s}E${e}`; - } - - hasPoster(item: DownloadItem): boolean { - return ( - !!item.posterUrl && - !this.failedPosterKeys()[this.getPosterKey(item)] - ); - } - - markPosterFailed(item: DownloadItem): void { - const key = this.getPosterKey(item); - this.failedPosterKeys.update((state) => { - if (state[key]) { - return state; - } - - return { - ...state, - [key]: true, - }; - }); - } - - getPosterPlaceholderIcon(item: DownloadItem): string { - return item.contentType === 'episode' ? 'video_library' : 'movie'; - } - - hasSourcePlaylist(item: DownloadItem): boolean { - return this.availablePlaylistIds().has(item.playlistId); - } - - isItemNavigable(item: DownloadItem): boolean { - return this.libraryNavigation.canOpen( - item, - this.availablePlaylistIds() - ); - } - - onItemCardKeydown(event: KeyboardEvent, item: DownloadItem): void { - if (event.target !== event.currentTarget) { - return; + formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; } + return this.downloadsService.formatBytes(bytes); + } - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } + async runAction(action: DownloadItemAction): Promise { + await this.actions.run(action); + } - event.preventDefault(); - void this.openInLibrary(item); + clearFinished(): void { + this.actions.clearFinished(this.playlistId() || undefined); + } + + openDownloadedSeries(group: DownloadSeriesCardViewModel): void { + const liveMembers = computed( + () => + this.model().library.find( + (entity): entity is DownloadSeriesCardViewModel => + entity.kind === 'series' && entity.key === group.key + )?.members ?? [] + ); + const ref = this.dialog.open(DownloadedSeriesDialogComponent, { + data: group, + width: 'min(720px, calc(100vw - 32px))', + maxWidth: 'min(720px, calc(100vw - 32px))', + maxHeight: 'min(760px, calc(100vh - 32px))', + }); + ref.componentRef?.setInput('members', liveMembers); + ref.componentRef?.setInput('pendingIds', this.pendingIds); + const actionSubscription = ref.componentInstance.itemAction.subscribe( + (action) => void this.runAction(action) + ); + const cleanup = () => actionSubscription.unsubscribe(); + ref.afterClosed() + .pipe(take(1), takeUntilDestroyed(this.destroyRef)) + .subscribe(cleanup); + this.destroyRef.onDestroy(cleanup); } async openInLibrary(item: DownloadItem): Promise { - if (!this.hasSourcePlaylist(item)) { - this.snackBar.open( - this.translate.instant('DOWNLOADS.SOURCE_PLAYLIST_MISSING'), - undefined, - { duration: 3000, horizontalPosition: 'start' } - ); + if (this.pendingIds().has(item.id)) { return; } - - if (!this.isItemNavigable(item)) { + if (!this.availablePlaylistIds().has(item.playlistId)) { + this.actions.showMessage('DOWNLOADS.SOURCE_PLAYLIST_MISSING', 3000); return; } - - await this.libraryNavigation.open(item); + if (!this.navigation.canOpen(item, this.availablePlaylistIds())) { + return; + } + if (!(await this.navigation.open(item))) { + this.actions.showActionError(); + } } - private getPosterKey(item: DownloadItem): string { - return `${item.id}:${item.posterUrl ?? ''}`; - } - - private showActionError(error?: string): void { - const message = error - ? `${this.translate.instant('DOWNLOADS.ACTION_FAILED')}: ${error}` - : this.translate.instant('DOWNLOADS.ACTION_FAILED'); - - this.snackBar.open(message, undefined, { - duration: 4000, - horizontalPosition: 'start', + private buildCategories(): XtreamCategory[] { + this.languageChange(); + const counts = this.model().counts; + const categories = buildStandardCollectionCategories({ + labels: { + all: this.translate.instant(FILTER_KEYS.all), + movie: this.translate.instant(FILTER_KEYS.movie), + live: '', + series: this.translate.instant(FILTER_KEYS.series), + }, + counts, }); - } - - private handleFileActionError(error?: string): void { - const message = - error === 'File not found' - ? this.translate.instant('DOWNLOADS.FILE_NOT_FOUND') - : this.translate.instant('DOWNLOADS.FILE_ACTION_ERROR'); - - this.snackBar.open(message, undefined, { - duration: 3000, - horizontalPosition: 'start', + categories.push({ + id: 4, + category_id: 'in-progress', + category_name: this.translate.instant(FILTER_KEYS.inProgress), + count: counts.inProgress, + parent_id: 0, }); + return categories; } } diff --git a/libs/services/src/lib/downloads.service.spec.ts b/libs/services/src/lib/downloads.service.spec.ts index 8ae813d17..60c72f43e 100644 --- a/libs/services/src/lib/downloads.service.spec.ts +++ b/libs/services/src/lib/downloads.service.spec.ts @@ -7,10 +7,7 @@ import { runInInjectionContext, signal, } from '@angular/core'; -import { - DownloadItem, - DownloadsService, -} from './downloads.service'; +import { DownloadItem, DownloadsService } from './downloads.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; type TestDownloadsService = { @@ -51,7 +48,10 @@ describe('DownloadsService', () => { jest.restoreAllMocks(); }); - function createDownload(id: number, playlistId = 'playlist-1'): DownloadItem { + function createDownload( + id: number, + playlistId = 'playlist-1' + ): DownloadItem { return { id, playlistId, @@ -203,10 +203,7 @@ describe('DownloadsService', () => { await expect(service.resumeDownload(42)).resolves.toEqual({ success: true, }); - expect(electron.downloadsResume).toHaveBeenCalledWith( - 42, - '/downloads' - ); + expect(electron.downloadsResume).toHaveBeenCalledWith(42, '/downloads'); }); it('marks downloads as loaded after a failed request while preserving existing data', async () => { @@ -275,9 +272,7 @@ describe('DownloadsService', () => { pausedItem, createDownload(8), ]) as unknown as DownloadsService; - const resumeDownload = jest - .fn() - .mockResolvedValue({ success: true }); + const resumeDownload = jest.fn().mockResolvedValue({ success: true }); (service as unknown as { resumeDownload: jest.Mock }).resumeDownload = resumeDownload; diff --git a/libs/services/src/lib/downloads.service.ts b/libs/services/src/lib/downloads.service.ts index 07c46a4b7..9d5a3504e 100644 --- a/libs/services/src/lib/downloads.service.ts +++ b/libs/services/src/lib/downloads.service.ts @@ -2,12 +2,7 @@ import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; export type DownloadStatus = - | 'queued' - | 'downloading' - | 'paused' - | 'completed' - | 'failed' - | 'canceled'; + 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'canceled'; export interface DownloadItem { id: number; @@ -169,7 +164,8 @@ export class DownloadsService implements OnDestroy { episodeNumber?: number; // Playlist info for auto-creation if needed (Stalker playlists) playlistName?: string; - playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; + playlistType?: + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url'; serverUrl?: string; portalUrl?: string; macAddress?: string; @@ -287,10 +283,7 @@ export class DownloadsService implements OnDestroy { try { return await window.electron.downloadsRetry(downloadId, folder); } catch (error) { - console.error( - '[DownloadsService] Error retrying download:', - error - ); + console.error('[DownloadsService] Error retrying download:', error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -311,10 +304,7 @@ export class DownloadsService implements OnDestroy { try { return await window.electron.downloadsRemove(downloadId); } catch (error) { - console.error( - '[DownloadsService] Error removing download:', - error - ); + console.error('[DownloadsService] Error removing download:', error); return { success: false, error: error instanceof Error ? error.message : String(error), @@ -379,10 +369,7 @@ export class DownloadsService implements OnDestroy { } return folder; } catch (error) { - console.error( - '[DownloadsService] Error selecting folder:', - error - ); + console.error('[DownloadsService] Error selecting folder:', error); return null; } } @@ -390,9 +377,7 @@ export class DownloadsService implements OnDestroy { /** * Clear completed/failed downloads */ - async clearCompleted( - playlistId?: string - ): Promise<{ success: boolean }> { + async clearCompleted(playlistId?: string): Promise<{ success: boolean }> { if (!this.isAvailable()) { return { success: false }; } @@ -444,7 +429,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); return download?.status === 'completed' && !!download.filePath; } @@ -456,7 +445,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); return ( download?.status === 'downloading' || download?.status === 'queued' || @@ -472,7 +465,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): boolean { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); return download?.status === 'paused'; } @@ -484,7 +481,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): Promise<{ success: boolean; error?: string }> { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); if (!download || download.status !== 'paused') { return { success: false, error: 'No paused download found' }; } @@ -499,7 +500,11 @@ export class DownloadsService implements OnDestroy { playlistId: string, contentType: 'vod' | 'episode' ): string | undefined { - const download = this.getDownloadByContent(xtreamId, playlistId, contentType); + const download = this.getDownloadByContent( + xtreamId, + playlistId, + contentType + ); if (download?.status === 'completed') { return download.filePath; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html index 1cd3c0f6c..082312550 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.html @@ -137,9 +137,7 @@ [class.is-active]="isDownloadsView()" [class.has-activity]="hasActiveDownloads()" (click)="onDownloadsRequested()" - [matTooltip]=" - 'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate - " + [matTooltip]="'WORKSPACE.SHELL.RAIL_DOWNLOADS' | translate" [attr.aria-label]=" ('WORKSPACE.SHELL.OPEN_DOWNLOADS' | translate) + (hasActiveDownloads() diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts index 9819beaef..006231654 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/components/workspace-shell-header/workspace-shell-header.component.spec.ts @@ -1,10 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - Component, - input, - output, -} from '@angular/core'; +import { Component, input, output } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatIconButton } from '@angular/material/button'; @@ -86,8 +82,9 @@ describe('WorkspaceShellHeaderComponent', () => { it('emits search input changes as user types', () => { const emitted: string[] = []; component.searchChanged.subscribe((value) => emitted.push(value)); - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input[type="search"]'); + const input: HTMLInputElement = fixture.nativeElement.querySelector( + 'input[type="search"]' + ); input.value = 'matrix'; input.dispatchEvent(new Event('input')); @@ -96,8 +93,9 @@ describe('WorkspaceShellHeaderComponent', () => { }); it('focuses and selects the search input on request', () => { - const input: HTMLInputElement = - fixture.nativeElement.querySelector('input[type="search"]'); + const input: HTMLInputElement = fixture.nativeElement.querySelector( + 'input[type="search"]' + ); input.value = 'matrix'; input.blur(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 7ed2c5569..9d23b1d30 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -1,8 +1,4 @@ -
+
@if (facade.showXtreamImportOverlay()) { @@ -54,7 +50,10 @@ (accountInfoRequested)="facade.openAccountInfo()" /> -
+
@if (facade.showContextPanel()) { } diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts index d3858e130..330eaab96 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.spec.ts @@ -1,10 +1,4 @@ -import { - Component, - Directive, - input, - output, - signal, -} from '@angular/core'; +import { Component, Directive, input, output, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { RouterOutlet, provideRouter } from '@angular/router'; import { By } from '@angular/platform-browser';