mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
feat(downloads): finish manager MVP
This commit is contained in:
1 parent
eb55c17a05
commit
bcc2e36a05
48 files changed
+4145
-1844
No files matched your search
@@ -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.
|
||||
@@ -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<void> {
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
async function navigateWithinWorkspace(
|
||||
page: Page,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
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<string> {
|
||||
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<number> {
|
||||
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<void>;
|
||||
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<ThrottledRangeServer> {
|
||||
async function createThrottledRangeServer(
|
||||
chunkIntervalMs = 150
|
||||
): Promise<ThrottledRangeServer> {
|
||||
const payload = Buffer.from(
|
||||
Array.from({ length: 96 * 1024 }, (_, index) =>
|
||||
String(index % 10)
|
||||
@@ -85,7 +163,7 @@ async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
}
|
||||
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<ThrottledRangeServer> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<TruncatedDownloadServer> {
|
||||
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<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((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<ReturnType<typeof dialog.showOpenDialog>>;
|
||||
}, 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<HTMLElement>('.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<ReturnType<typeof dialog.showOpenDialog>>;
|
||||
}, 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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.
|
||||
@@ -183,13 +183,13 @@ render:
|
||||
|
||||
```html
|
||||
@if (activeDownloadsCount() > 0) {
|
||||
<span
|
||||
class="download-count-badge"
|
||||
data-test-id="global-download-count"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ activeDownloadsCount() }}
|
||||
</span>
|
||||
<span
|
||||
class="download-count-badge"
|
||||
data-test-id="global-download-count"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ activeDownloadsCount() }}
|
||||
</span>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 DownloadListItemViewModel[]>();
|
||||
readonly attentionItems = input.required<readonly DownloadListItemViewModel[]>();
|
||||
readonly activeItems =
|
||||
input.required<readonly DownloadListItemViewModel[]>();
|
||||
readonly attentionItems =
|
||||
input.required<readonly DownloadListItemViewModel[]>();
|
||||
readonly pendingIds = input<ReadonlySet<number>>(new Set());
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
readonly openRequested = output<DownloadItem>();
|
||||
@@ -971,10 +972,10 @@ The rendered hierarchy is:
|
||||
<div>
|
||||
<h1 id="downloads-title">{{ 'DOWNLOADS.TITLE' | translate }}</h1>
|
||||
@if (model().activeCount > 0) {
|
||||
<span data-test-id="downloads-active-count">
|
||||
{{ 'DOWNLOADS.ACTIVE_COUNT' | translate:
|
||||
{ count: model().activeCount } }}
|
||||
</span>
|
||||
<span data-test-id="downloads-active-count">
|
||||
{{ 'DOWNLOADS.ACTIVE_COUNT' | translate: { count:
|
||||
model().activeCount } }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="downloads__header-actions">
|
||||
@@ -985,14 +986,14 @@ The rendered hierarchy is:
|
||||
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
|
||||
</button>
|
||||
@if (model().hasClearable) {
|
||||
<button mat-stroked-button (click)="clearFinished()">
|
||||
{{ 'DOWNLOADS.CLEAR_FINISHED' | translate }}
|
||||
</button>
|
||||
<button mat-stroked-button (click)="clearFinished()">
|
||||
{{ 'DOWNLOADS.CLEAR_FINISHED' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div data-test-id="downloads-tracked-bytes">
|
||||
{{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} ·
|
||||
{{ formatBytes(model().trackedBytes) }}
|
||||
{{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }} · {{
|
||||
formatBytes(model().trackedBytes) }}
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
@@ -1001,19 +1002,19 @@ The rendered hierarchy is:
|
||||
[attr.aria-label]="'DOWNLOADS.FILTER.LABEL' | translate"
|
||||
>
|
||||
@for (category of categories(); track category.category_id) {
|
||||
<button
|
||||
type="button"
|
||||
[class.is-selected]="
|
||||
<button
|
||||
type="button"
|
||||
[class.is-selected]="
|
||||
selectedCategoryId() === category.category_id
|
||||
"
|
||||
[attr.aria-pressed]="
|
||||
[attr.aria-pressed]="
|
||||
selectedCategoryId() === category.category_id
|
||||
"
|
||||
(click)="setFilter(category.category_id)"
|
||||
>
|
||||
{{ category.category_name }}
|
||||
<span>{{ category.count }}</span>
|
||||
</button>
|
||||
(click)="setFilter(category.category_id)"
|
||||
>
|
||||
{{ category.category_name }}
|
||||
<span>{{ category.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<main class="downloads__content">
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,14 +27,9 @@ const STALKER_PLAYLIST = {
|
||||
} satisfies Playlist;
|
||||
|
||||
type RouterFake = jest.Mocked<Pick<Router, 'navigate'>>;
|
||||
type DatabaseFake = jest.Mocked<
|
||||
Pick<DatabaseService, 'getContentByXtreamId'>
|
||||
>;
|
||||
type DatabaseFake = jest.Mocked<Pick<DatabaseService, 'getContentByXtreamId'>>;
|
||||
type PlaylistsFake = jest.Mocked<
|
||||
Pick<
|
||||
PlaylistsService,
|
||||
'getPlaylistById' | 'getPortalRecentlyViewed'
|
||||
>
|
||||
Pick<PlaylistsService, 'getPlaylistById' | 'getPortalRecentlyViewed'>
|
||||
>;
|
||||
|
||||
function download(overrides: Partial<DownloadItem> = {}): 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'],
|
||||
{
|
||||
|
||||
@@ -93,8 +93,7 @@ export class DownloadLibraryNavigationService {
|
||||
item: DownloadItem,
|
||||
targetId: number
|
||||
): Promise<boolean> {
|
||||
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<boolean> {
|
||||
const fallback =
|
||||
item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const fallback = item.contentType === 'episode' ? 'series' : 'vod';
|
||||
const openRecentItem = await this.stalkerOpenState(
|
||||
item,
|
||||
targetId,
|
||||
|
||||
@@ -1,294 +1,376 @@
|
||||
<div class="download-library__grid">
|
||||
@for (entity of entities(); track entity.key) {
|
||||
@if (entity.kind === 'series') {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
<div class="download-library__artwork">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.OPEN_ARTWORK'
|
||||
| translate: { title: entity.title }
|
||||
"
|
||||
(click)="openSeries(entity)"
|
||||
@if (entities().length > 0) {
|
||||
<section
|
||||
class="download-library"
|
||||
data-test-id="downloads-library-section"
|
||||
aria-labelledby="downloads-library-heading"
|
||||
>
|
||||
<header class="download-library__header">
|
||||
<h2 id="downloads-library-heading">
|
||||
{{ 'DOWNLOADS.READY_TO_WATCH' | translate }}
|
||||
</h2>
|
||||
</header>
|
||||
<div class="download-library__grid">
|
||||
@for (entity of entities(); track entity.key) {
|
||||
@if (entity.kind === 'series') {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markArtworkFailed(entity, $event)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">tv</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true">download_done</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{ 'DOWNLOADS.SERIES' | translate }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_DETAILS' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
(click)="openSeries(entity)"
|
||||
>
|
||||
{{ entity.title }}
|
||||
</button>
|
||||
<div class="download-library__facts">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__episodes-button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_EPISODES' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
(click)="openEpisodes(entity)"
|
||||
<div
|
||||
class="download-library__artwork download-library__artwork-hint"
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.EPISODE_COUNT'
|
||||
| translate
|
||||
: { count: entity.members.length }
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
entity.firstSeason !== undefined &&
|
||||
entity.lastSeason !== undefined
|
||||
) {
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
@if (entity.firstSeason === entity.lastSeason) {
|
||||
{{
|
||||
'DOWNLOADS.SEASON'
|
||||
| translate
|
||||
: {
|
||||
season: entity.firstSeason,
|
||||
}
|
||||
}}
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[class.download-library__artwork-button--disabled]="
|
||||
!canOpenSeries(entity)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
!canOpenSeries(entity) ? 'true' : null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.OPEN_ARTWORK'
|
||||
| translate: { title: entity.title }
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="canOpenSeries(entity)"
|
||||
(click)="openSeries(entity)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="
|
||||
markArtworkFailed(entity, $event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>tv</mat-icon
|
||||
>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true"
|
||||
>download_done</mat-icon
|
||||
>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{ 'DOWNLOADS.SERIES' | translate }}
|
||||
</span>
|
||||
<span class="download-library__title-hint">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
data-test-id="download-library-series-open"
|
||||
[class.download-library__title-button--disabled]="
|
||||
!canOpenSeries(entity)
|
||||
"
|
||||
[attr.aria-disabled]="
|
||||
!canOpenSeries(entity) ? 'true' : null
|
||||
"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_DETAILS' | translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate
|
||||
"
|
||||
[matTooltipDisabled]="canOpenSeries(entity)"
|
||||
(click)="openSeries(entity)"
|
||||
>
|
||||
{{ entity.title }}
|
||||
</button>
|
||||
</span>
|
||||
<div class="download-library__facts">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__episodes-button"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.OPEN_EPISODES'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.title
|
||||
"
|
||||
(click)="openEpisodes(entity)"
|
||||
>
|
||||
{{
|
||||
'DOWNLOADS.SEASON_RANGE'
|
||||
episodeCountKey(entity.members.length)
|
||||
| translate
|
||||
: {
|
||||
first: entity.firstSeason,
|
||||
last: entity.lastSeason,
|
||||
count: entity.members
|
||||
.length,
|
||||
}
|
||||
}}
|
||||
</button>
|
||||
@if (
|
||||
entity.firstSeason !== undefined &&
|
||||
entity.lastSeason !== undefined
|
||||
) {
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
@if (
|
||||
entity.firstSeason ===
|
||||
entity.lastSeason
|
||||
) {
|
||||
{{
|
||||
'DOWNLOADS.SEASON'
|
||||
| translate
|
||||
: {
|
||||
season: entity.firstSeason,
|
||||
}
|
||||
}}
|
||||
} @else {
|
||||
{{
|
||||
'DOWNLOADS.SEASON_RANGE'
|
||||
| translate
|
||||
: {
|
||||
first: entity.firstSeason,
|
||||
last: entity.lastSeason,
|
||||
}
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="download-library__metadata">
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ formatBytes(entity.trackedBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
} @else {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
<div class="download-library__artwork">
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
</div>
|
||||
<div class="download-library__metadata">
|
||||
@if (entity.sourceName.trim()) {
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
}
|
||||
<span>{{
|
||||
formatBytes(entity.trackedBytes)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
} @else {
|
||||
<article
|
||||
class="download-library__card"
|
||||
[attr.data-test-id]="libraryTestId(entity)"
|
||||
>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="markArtworkFailed(entity, $event)"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{
|
||||
entity.kind === 'movie'
|
||||
? 'movie'
|
||||
: 'live_tv'
|
||||
}}
|
||||
</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true">download_done</mat-icon>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{
|
||||
(entity.kind === 'movie'
|
||||
? 'DOWNLOADS.MOVIE'
|
||||
: 'DOWNLOADS.EPISODE'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
{{ entity.item.title }}
|
||||
</button>
|
||||
@if (entity.kind === 'episode' && entity.episodeLabel) {
|
||||
<span class="download-library__episode-label">
|
||||
{{ entity.episodeLabel }}
|
||||
</span>
|
||||
}
|
||||
<div class="download-library__metadata">
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{{ formatBytes(entity.trackedBytes) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="download-library__actions"
|
||||
[attr.aria-label]="entity.item.title"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.PLAY' | translate"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.REVEAL' | translate"
|
||||
(click)="emitAction('reveal', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">folder_open</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matMenuTriggerFor]="itemMenu"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.MORE_ACTIONS' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.MORE_ACTIONS' | translate"
|
||||
>
|
||||
<mat-icon aria-hidden="true">more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #itemMenu="matMenu">
|
||||
<div class="download-library__artwork">
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
class="download-library__artwork-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.COPY_URL' | translate) +
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('copy-url', entity.item)"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
content_copy
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{ 'DOWNLOADS.COPY_URL' | translate }}
|
||||
</span>
|
||||
@if (artworkUrl(entity); as artwork) {
|
||||
<img
|
||||
[src]="artwork"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
(error)="
|
||||
markArtworkFailed(entity, $event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<span
|
||||
class="download-library__placeholder"
|
||||
role="img"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.ARTWORK_UNAVAILABLE'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{
|
||||
entity.kind === 'movie'
|
||||
? 'movie'
|
||||
: 'live_tv'
|
||||
}}
|
||||
</mat-icon>
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
<span class="download-library__offline">
|
||||
<mat-icon aria-hidden="true"
|
||||
>download_done</mat-icon
|
||||
>
|
||||
{{ 'DOWNLOADS.OFFLINE' | translate }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="download-library__body">
|
||||
<span class="download-library__type">
|
||||
{{
|
||||
(entity.kind === 'movie'
|
||||
? 'DOWNLOADS.MOVIE'
|
||||
: 'DOWNLOADS.EPISODE'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
class="download-library__title-button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate) +
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="emitAction('remove', entity.item)"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
remove_circle_outline
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
{{ entity.item.title }}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (
|
||||
entity.kind === 'episode' && entity.episodeLabel
|
||||
) {
|
||||
<span class="download-library__episode-label">
|
||||
{{ entity.episodeLabel }}
|
||||
</span>
|
||||
}
|
||||
<div class="download-library__metadata">
|
||||
@if (entity.sourceName.trim()) {
|
||||
<span>{{ entity.sourceName }}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
}
|
||||
<span>{{
|
||||
formatBytes(entity.trackedBytes)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="download-library__actions"
|
||||
[attr.aria-label]="entity.item.title"
|
||||
>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="'DOWNLOADS.PLAY' | translate"
|
||||
(click)="emitAction('play', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>play_arrow</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REVEAL' | translate
|
||||
"
|
||||
(click)="emitAction('reveal', entity.item)"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>folder_open</mat-icon
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[matMenuTriggerFor]="itemMenu"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.MORE_ACTIONS' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.MORE_ACTIONS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true"
|
||||
>more_vert</mat-icon
|
||||
>
|
||||
</button>
|
||||
<mat-menu #itemMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.COPY_URL' | translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="
|
||||
emitAction('copy-url', entity.item)
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
content_copy
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{
|
||||
'DOWNLOADS.COPY_URL' | translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="isPending(entity.item)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate) +
|
||||
': ' +
|
||||
entity.item.title
|
||||
"
|
||||
(click)="
|
||||
emitAction('remove', entity.item)
|
||||
"
|
||||
>
|
||||
<mat-icon aria-hidden="true">
|
||||
remove_circle_outline
|
||||
</mat-icon>
|
||||
<span>
|
||||
{{
|
||||
'DOWNLOADS.REMOVE_FROM_MANAGER'
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
})
|
||||
export class DownloadLibraryComponent {
|
||||
readonly entities = input.required<readonly DownloadLibraryEntity[]>();
|
||||
readonly availablePlaylistIds = input.required<ReadonlySet<string>>();
|
||||
readonly pendingIds = input<ReadonlySet<number>>(new Set());
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
readonly seriesOpened = output<DownloadItem>();
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ReadonlySet<number>>(new Set());
|
||||
readonly isClearing = signal(false);
|
||||
|
||||
async run(action: DownloadItemAction): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<DownloadOperationResult>,
|
||||
onFailure: (error?: string) => void = (error) =>
|
||||
this.showActionError(error)
|
||||
): Promise<void> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 @@
|
||||
<div class="download-queue__metadata">
|
||||
<span
|
||||
class="download-queue__status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
[class]="
|
||||
'download-queue__status download-queue__status--' +
|
||||
row.item.status
|
||||
|
||||
@@ -61,7 +61,12 @@
|
||||
}
|
||||
|
||||
&--pending {
|
||||
opacity: 0.68;
|
||||
cursor: progress;
|
||||
|
||||
.download-queue__artwork,
|
||||
.download-queue__actions {
|
||||
opacity: 0.68;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +209,9 @@
|
||||
}
|
||||
|
||||
&__bytes {
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@@ -214,8 +222,11 @@
|
||||
}
|
||||
|
||||
&__error {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--mat-sys-error);
|
||||
font-weight: 500;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
&__progress {
|
||||
@@ -268,7 +279,7 @@
|
||||
|
||||
&__actions {
|
||||
grid-column: 2;
|
||||
justify-content: flex-start;
|
||||
justify-content: flex-end;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,9 @@ describe('DownloadQueueComponent', () => {
|
||||
'.download-queue__status'
|
||||
);
|
||||
expect(statusPill?.textContent).toContain(text);
|
||||
expect(statusPill?.getAttribute('role')).toBe('status');
|
||||
expect(statusPill?.getAttribute('aria-live')).toBe('polite');
|
||||
expect(statusPill?.getAttribute('aria-atomic')).toBe('true');
|
||||
expect(
|
||||
statusPill?.querySelector('mat-icon')?.textContent?.trim()
|
||||
).toBe(icon);
|
||||
@@ -296,6 +299,7 @@ describe('DownloadQueueComponent', () => {
|
||||
fixture.componentInstance.itemAction.subscribe(emitted);
|
||||
fixture.componentInstance.openRequested.subscribe(opened);
|
||||
render([viewModel]);
|
||||
expect(row(17).getAttribute('aria-busy')).toBeNull();
|
||||
|
||||
actionButton(row(17), 'more').click();
|
||||
fixture.detectChanges();
|
||||
@@ -311,6 +315,10 @@ describe('DownloadQueueComponent', () => {
|
||||
const itemButtons = Array.from(
|
||||
row(17).querySelectorAll<HTMLButtonElement>('button')
|
||||
);
|
||||
expect(row(17).getAttribute('aria-busy')).toBe('true');
|
||||
expect(
|
||||
row(17).querySelector('.download-queue__metadata')
|
||||
).not.toBeNull();
|
||||
expect(itemButtons).toHaveLength(6);
|
||||
expect(itemButtons.every(({ disabled }) => disabled)).toBe(true);
|
||||
expect(
|
||||
@@ -381,7 +389,11 @@ describe('DownloadQueueComponent', () => {
|
||||
'Download progress for Download 2',
|
||||
'Download progress for Download 4',
|
||||
]);
|
||||
expect(fixture.nativeElement.querySelector('[aria-live]')).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'.download-queue__progress[aria-live]'
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -402,12 +414,14 @@ describe('DownloadQueueComponent', () => {
|
||||
});
|
||||
|
||||
it('renders downloaded/total bytes, episode/source/error metadata, and omits empty sources', () => {
|
||||
const longError =
|
||||
'https://downloads.example.test/a/very/long/unbroken/path/that/must/remain/readable/error-code-connection-reset';
|
||||
const detailed = createRow(21, 'failed', {
|
||||
item: createItem(21, 'failed', {
|
||||
contentType: 'episode',
|
||||
bytesDownloaded: 1.5 * 1024 ** 2,
|
||||
totalBytes: 2 * 1024 ** 2,
|
||||
errorMessage: 'Connection reset',
|
||||
errorMessage: longError,
|
||||
}),
|
||||
episodeLabel: 'S02E04',
|
||||
seriesTitle: 'Example series',
|
||||
@@ -438,9 +452,10 @@ describe('DownloadQueueComponent', () => {
|
||||
.querySelector('.download-queue__source')
|
||||
?.textContent?.trim()
|
||||
).toBe('Alpha Source');
|
||||
expect(
|
||||
row(21).querySelector('.download-queue__error')?.textContent?.trim()
|
||||
).toBe('Connection reset');
|
||||
const error = row(21).querySelector<HTMLElement>(
|
||||
'.download-queue__error'
|
||||
);
|
||||
expect(error?.textContent?.trim()).toBe(longError);
|
||||
expect(row(22).querySelector('.download-queue__source')).toBeNull();
|
||||
expect(
|
||||
row(22).querySelector('.download-queue__bytes')?.textContent
|
||||
@@ -482,31 +497,42 @@ describe('DownloadQueueComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('gives every icon command an item-specific translated accessible name and tooltip', async () => {
|
||||
const viewModel = createRow(41, 'queued', {
|
||||
it('gives every command an item-specific translated accessible name and tooltip', async () => {
|
||||
const queued = createRow(41, 'queued', {
|
||||
item: createItem(41, 'queued', { title: 'Named movie' }),
|
||||
});
|
||||
render([viewModel]);
|
||||
|
||||
expect(actionButton(row(41), 'pause').getAttribute('aria-label')).toBe(
|
||||
'Pause Named movie'
|
||||
);
|
||||
expect(actionButton(row(41), 'cancel').getAttribute('aria-label')).toBe(
|
||||
'Cancel Named movie'
|
||||
);
|
||||
expect(actionButton(row(41), 'more').getAttribute('aria-label')).toBe(
|
||||
'More actions for Named movie'
|
||||
);
|
||||
const paused = createRow(42, 'paused', {
|
||||
item: createItem(42, 'paused', { title: 'Paused episode' }),
|
||||
});
|
||||
const failed = createRow(43, 'failed', {
|
||||
item: createItem(43, 'failed', { title: 'Failed movie' }),
|
||||
});
|
||||
render([queued, paused], [failed]);
|
||||
|
||||
const loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
const pauseTooltip = await loader.getHarness(
|
||||
MatTooltipHarness.with({
|
||||
selector: '[data-test-action="pause"]',
|
||||
})
|
||||
);
|
||||
await pauseTooltip.show();
|
||||
expect(await pauseTooltip.getTooltipText()).toBe('Pause Named movie');
|
||||
await pauseTooltip.hide();
|
||||
const commands = [
|
||||
[41, 'pause', 'Pause Named movie'],
|
||||
[41, 'cancel', 'Cancel Named movie'],
|
||||
[41, 'more', 'More actions for Named movie'],
|
||||
[42, 'resume', 'Resume Paused episode'],
|
||||
[42, 'remove', 'Remove Paused episode from manager'],
|
||||
[43, 'retry', 'Retry Failed movie'],
|
||||
] as const;
|
||||
|
||||
for (const [id, action, accessibleName] of commands) {
|
||||
expect(
|
||||
actionButton(row(id), action).getAttribute('aria-label')
|
||||
).toBe(accessibleName);
|
||||
|
||||
const tooltip = await loader.getHarness(
|
||||
MatTooltipHarness.with({
|
||||
selector: `[data-test-id="download-queue-item-${id}"] [data-test-action="${action}"]`,
|
||||
})
|
||||
);
|
||||
await tooltip.show();
|
||||
expect(await tooltip.getTooltipText()).toBe(accessibleName);
|
||||
await tooltip.hide();
|
||||
}
|
||||
|
||||
actionButton(row(41), 'more').click();
|
||||
fixture.detectChanges();
|
||||
@@ -519,5 +545,17 @@ describe('DownloadQueueComponent', () => {
|
||||
expect(copyButton.getAttribute('aria-label')).toBe(
|
||||
'Copy URL for Named movie'
|
||||
);
|
||||
const documentLoader =
|
||||
TestbedHarnessEnvironment.documentRootLoader(fixture);
|
||||
const copyTooltip = await documentLoader.getHarness(
|
||||
MatTooltipHarness.with({
|
||||
selector: '[data-test-action="copy-url"]',
|
||||
})
|
||||
);
|
||||
await copyTooltip.show();
|
||||
expect(await copyTooltip.getTooltipText()).toBe(
|
||||
'Copy URL for Named movie'
|
||||
);
|
||||
await copyTooltip.hide();
|
||||
});
|
||||
});
|
||||
@@ -6,18 +6,21 @@
|
||||
<h2 mat-dialog-title>{{ group.title }}</h2>
|
||||
<p>
|
||||
{{
|
||||
'DOWNLOADS.EPISODE_COUNT'
|
||||
| translate: { count: group.members.length }
|
||||
episodeCountKey(currentMembers().length)
|
||||
| translate: { count: currentMembers().length }
|
||||
}}
|
||||
<span aria-hidden="true">·</span>
|
||||
{{ group.sourceName }}
|
||||
@if (group.sourceName.trim()) {
|
||||
<span aria-hidden="true">·</span>
|
||||
{{ group.sourceName }}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
mat-dialog-close
|
||||
[attr.aria-label]="'CLOSE' | translate"
|
||||
data-test-id="downloaded-series-close"
|
||||
[aria-label]="'CLOSE' | translate"
|
||||
[matTooltip]="'CLOSE' | translate"
|
||||
>
|
||||
<mat-icon aria-hidden="true">close</mat-icon>
|
||||
@@ -26,10 +29,11 @@
|
||||
|
||||
<mat-dialog-content>
|
||||
<ol class="downloaded-series-dialog__list">
|
||||
@for (member of group.members; track member.id) {
|
||||
@for (member of currentMembers(); track member.id) {
|
||||
<li
|
||||
class="downloaded-series-dialog__episode"
|
||||
[attr.data-test-id]="'downloaded-series-episode-' + member.id"
|
||||
[attr.aria-busy]="isPending(member) ? 'true' : null"
|
||||
>
|
||||
<div class="downloaded-series-dialog__identity">
|
||||
@if (episodeLabel(member); as label) {
|
||||
@@ -49,6 +53,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(member)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.PLAY' | translate) +
|
||||
': ' +
|
||||
@@ -62,6 +67,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(member)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REVEAL' | translate) +
|
||||
': ' +
|
||||
@@ -75,6 +81,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(member)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.COPY_URL' | translate) +
|
||||
': ' +
|
||||
@@ -88,6 +95,7 @@
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
[disabled]="isPending(member)"
|
||||
[attr.aria-label]="
|
||||
('DOWNLOADS.REMOVE_FROM_MANAGER' | translate) +
|
||||
': ' +
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-width: min(680px, calc(100vw - 32px));
|
||||
max-width: 760px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
color: var(--app-body-color);
|
||||
}
|
||||
|
||||
@@ -109,8 +109,11 @@ mat-dialog-content {
|
||||
|
||||
.downloaded-series-dialog__size {
|
||||
color: var(--app-muted-color);
|
||||
font-family: var(--mat-sys-label-small-font);
|
||||
font-family:
|
||||
'JetBrains Mono', 'SFMono-Regular', Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
font-size: 0.72rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.downloaded-series-dialog__actions {
|
||||
@@ -127,11 +130,11 @@ button:focus-visible {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
:host {
|
||||
min-width: min(100%, calc(100vw - 16px));
|
||||
}
|
||||
button:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.downloaded-series-dialog__header {
|
||||
padding: 18px 16px 12px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
@@ -11,7 +12,8 @@ const TRANSLATIONS = {
|
||||
DOWNLOADS: {
|
||||
COPY_URL: 'Copy download URL',
|
||||
DOWNLOADED_EPISODES: 'Downloaded episodes',
|
||||
EPISODE_COUNT: '{{count}} episodes',
|
||||
EPISODE_COUNT_ONE: '1 episode',
|
||||
EPISODE_COUNT_OTHER: '{{count}} episodes',
|
||||
PLAY: 'Play',
|
||||
REMOVE_FROM_MANAGER: 'Remove from manager',
|
||||
REVEAL: 'Show in folder',
|
||||
@@ -122,6 +124,14 @@ describe('DownloadedSeriesDialogComponent', () => {
|
||||
expect(rows()[2].textContent).toContain('S02E01');
|
||||
});
|
||||
|
||||
it('gives the dialog close button an accessible name', () => {
|
||||
const closeButton = fixture.nativeElement.querySelector(
|
||||
'[data-test-id="downloaded-series-close"]'
|
||||
) as HTMLButtonElement | null;
|
||||
|
||||
expect(closeButton?.getAttribute('aria-label')).toBe('Close');
|
||||
});
|
||||
|
||||
it('includes the concrete episode identity in every action name', () => {
|
||||
rows().forEach((row, index) => {
|
||||
const identity = ['S01E01', 'S01E03', 'S02E01'][index];
|
||||
@@ -173,4 +183,63 @@ describe('DownloadedSeriesDialogComponent', () => {
|
||||
{ type: 'remove', item: selected },
|
||||
]);
|
||||
});
|
||||
|
||||
it('disables and guards every command for a pending episode until it settles', async () => {
|
||||
const pendingIds = signal<ReadonlySet<number>>(new Set([32]));
|
||||
fixture.componentRef.setInput('pendingIds', pendingIds);
|
||||
await fixture.whenStable();
|
||||
const actions: unknown[] = [];
|
||||
component.itemAction.subscribe((action) => actions.push(action));
|
||||
|
||||
expect(
|
||||
Array.from(rows()[1].querySelectorAll('button')).every(
|
||||
({ disabled }) => disabled
|
||||
)
|
||||
).toBe(true);
|
||||
expect(rows()[1].getAttribute('aria-busy')).toBe('true');
|
||||
expect(
|
||||
Array.from(rows()[0].querySelectorAll('button')).every(
|
||||
({ disabled }) => !disabled
|
||||
)
|
||||
).toBe(true);
|
||||
await click(
|
||||
button(rows()[1], 'Play: S01E03 — Signal House - S01E03 - Relay')
|
||||
);
|
||||
expect(actions).toEqual([]);
|
||||
(
|
||||
component as unknown as {
|
||||
emitAction(type: 'play', item: DownloadItem): void;
|
||||
}
|
||||
).emitAction('play', MEMBERS[1]);
|
||||
expect(actions).toEqual([]);
|
||||
|
||||
pendingIds.set(new Set());
|
||||
await fixture.whenStable();
|
||||
await click(
|
||||
button(rows()[1], 'Play: S01E03 — Signal House - S01E03 - Relay')
|
||||
);
|
||||
expect(actions).toEqual([{ type: 'play', item: MEMBERS[1] }]);
|
||||
});
|
||||
|
||||
it('removes episodes when the authoritative group membership changes', async () => {
|
||||
const members = signal<readonly DownloadItem[]>(MEMBERS);
|
||||
const pendingIds = signal<ReadonlySet<number>>(new Set([32]));
|
||||
fixture.componentRef.setInput('members', members);
|
||||
fixture.componentRef.setInput('pendingIds', pendingIds);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(rows()).toHaveLength(3);
|
||||
expect(rows()[1].getAttribute('aria-busy')).toBe('true');
|
||||
|
||||
members.set([MEMBERS[0], MEMBERS[2]]);
|
||||
pendingIds.set(new Set());
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(rows().map((row) => row.getAttribute('data-test-id'))).toEqual([
|
||||
'downloaded-series-episode-31',
|
||||
'downloaded-series-episode-33',
|
||||
]);
|
||||
expect(fixture.nativeElement.textContent).toContain('2 episodes');
|
||||
expect(fixture.nativeElement.textContent).not.toContain('S01E03');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
@@ -16,6 +18,8 @@ import type {
|
||||
} from './download-actions';
|
||||
import type { DownloadSeriesCardViewModel } from './download-library.viewmodel';
|
||||
|
||||
const EMPTY_PENDING_IDS: ReadonlySet<number> = new Set();
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloaded-series-dialog',
|
||||
standalone: true,
|
||||
@@ -32,13 +36,32 @@ import type { DownloadSeriesCardViewModel } from './download-library.viewmodel';
|
||||
})
|
||||
export class DownloadedSeriesDialogComponent {
|
||||
readonly group = inject<DownloadSeriesCardViewModel>(MAT_DIALOG_DATA);
|
||||
readonly members = input<() => readonly DownloadItem[]>(
|
||||
() => this.group.members
|
||||
);
|
||||
readonly pendingIds = input<() => ReadonlySet<number>>(
|
||||
() => EMPTY_PENDING_IDS
|
||||
);
|
||||
readonly itemAction = output<DownloadItemAction>();
|
||||
protected readonly currentMembers = computed(() => this.members()());
|
||||
|
||||
protected emitAction(
|
||||
type: DownloadItemActionType,
|
||||
item: DownloadItem
|
||||
): void {
|
||||
this.itemAction.emit({ type, item });
|
||||
if (!this.isPending(item)) {
|
||||
this.itemAction.emit({ type, item });
|
||||
}
|
||||
}
|
||||
|
||||
protected isPending(item: DownloadItem): boolean {
|
||||
return this.pendingIds()().has(item.id);
|
||||
}
|
||||
|
||||
protected episodeCountKey(count: number): string {
|
||||
return count === 1
|
||||
? 'DOWNLOADS.EPISODE_COUNT_ONE'
|
||||
: 'DOWNLOADS.EPISODE_COUNT_OTHER';
|
||||
}
|
||||
|
||||
protected episodeLabel(item: DownloadItem): string {
|
||||
|
||||
@@ -1,96 +1,163 @@
|
||||
<div class="downloads-layout">
|
||||
<div class="downloads">
|
||||
<header class="downloads__header">
|
||||
<div class="downloads__header-left">
|
||||
<div class="settings-group__header-text">
|
||||
<h1>{{ 'DOWNLOADS.TITLE' | translate }}</h1>
|
||||
@if (activeCount() > 0) {
|
||||
<span class="downloads__badge">{{
|
||||
activeCount()
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="downloads__header-actions">
|
||||
@if (isAvailable()) {
|
||||
<div
|
||||
class="downloads__folder-inline"
|
||||
[attr.title]="
|
||||
downloadFolder() ||
|
||||
('DOWNLOADS.NO_FOLDER' | translate)
|
||||
"
|
||||
<section class="downloads" aria-labelledby="downloads-title">
|
||||
<header class="downloads__header" data-test-id="downloads-header">
|
||||
<div class="downloads__heading">
|
||||
<div class="downloads__title-row">
|
||||
<h1 id="downloads-title">
|
||||
{{ 'DOWNLOADS.TITLE' | translate }}
|
||||
</h1>
|
||||
@if (model().activeCount > 0) {
|
||||
<span
|
||||
class="downloads__active-count"
|
||||
data-test-id="downloads-active-count"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
<span class="downloads__folder-inline-path">
|
||||
{{
|
||||
downloadFolder() ||
|
||||
('DOWNLOADS.NO_FOLDER' | translate)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="changeFolder()">
|
||||
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
|
||||
</button>
|
||||
@if (hasClearableDownloads()) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="clearCompleted()"
|
||||
[matTooltip]="'DOWNLOADS.CLEAR_COMPLETED' | translate"
|
||||
[attr.aria-label]="
|
||||
'DOWNLOADS.CLEAR_COMPLETED' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete_sweep</mat-icon>
|
||||
{{ 'DOWNLOADS.CLEAR_COMPLETED' | translate }}
|
||||
</button>
|
||||
}
|
||||
{{
|
||||
'DOWNLOADS.ACTIVE_COUNT'
|
||||
| translate: { count: model().activeCount }
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (isAvailable()) {
|
||||
<div
|
||||
class="downloads__tracked"
|
||||
data-test-id="downloads-tracked-bytes"
|
||||
>
|
||||
<span>{{ 'DOWNLOADS.TRACKED_DOWNLOADS' | translate }}</span>
|
||||
<strong>{{ formatBytes(model().trackedBytes) }}</strong>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isAvailable()) {
|
||||
<div class="downloads__header-actions">
|
||||
<div
|
||||
class="downloads__folder"
|
||||
data-test-id="downloads-folder"
|
||||
[matTooltip]="
|
||||
downloadFolder() || ('DOWNLOADS.NO_FOLDER' | translate)
|
||||
"
|
||||
[attr.title]="downloadFolder() || null"
|
||||
>
|
||||
<mat-icon aria-hidden="true">folder_open</mat-icon>
|
||||
@if (downloadFolder(); as folder) {
|
||||
<span
|
||||
class="downloads__folder-path"
|
||||
[attr.aria-label]="folder"
|
||||
>
|
||||
@if (downloadFolderParts().prefix) {
|
||||
<span
|
||||
class="downloads__folder-prefix"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ downloadFolderParts().prefix }}
|
||||
</span>
|
||||
}
|
||||
<span
|
||||
class="downloads__folder-tail"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ downloadFolderParts().tail }}
|
||||
</span>
|
||||
</span>
|
||||
} @else {
|
||||
<span>{{ 'DOWNLOADS.NO_FOLDER' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="changeFolder()"
|
||||
>
|
||||
{{ 'DOWNLOADS.CHANGE_FOLDER' | translate }}
|
||||
</button>
|
||||
@if (model().hasClearable) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
data-test-id="downloads-clear-finished"
|
||||
[disabled]="isClearing()"
|
||||
(click)="clearFinished()"
|
||||
>
|
||||
<mat-icon aria-hidden="true">delete_sweep</mat-icon>
|
||||
{{ 'DOWNLOADS.CLEAR_FINISHED' | translate }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (isAvailable() && playlistsLoaded() && !hasNoPlaylists()) {
|
||||
<nav
|
||||
class="downloads__filters"
|
||||
data-test-id="downloads-filters"
|
||||
[attr.aria-label]="'DOWNLOADS.FILTER.LABEL' | translate"
|
||||
>
|
||||
@for (category of categories(); track category.category_id) {
|
||||
<button
|
||||
type="button"
|
||||
class="downloads__filter"
|
||||
[class.is-selected]="
|
||||
selectedCategoryId() === category.category_id
|
||||
"
|
||||
[attr.aria-pressed]="
|
||||
selectedCategoryId() === category.category_id
|
||||
"
|
||||
[attr.data-test-id]="
|
||||
'downloads-filter-' + category.category_id
|
||||
"
|
||||
(click)="setFilter(category.category_id)"
|
||||
>
|
||||
<span>{{ category.category_name }}</span>
|
||||
<span class="downloads__filter-count">{{
|
||||
category.count
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
}
|
||||
|
||||
<main class="downloads__content" data-test-id="downloads-content">
|
||||
@if (!isAvailable()) {
|
||||
<div class="downloads__empty">
|
||||
<mat-icon>desktop_access_disabled</mat-icon>
|
||||
<p>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</span>
|
||||
<div class="downloads__message">
|
||||
<mat-icon aria-hidden="true">desktop_access_disabled</mat-icon>
|
||||
<h2>{{ 'DOWNLOADS.NOT_AVAILABLE' | translate }}</h2>
|
||||
<p>{{ 'DOWNLOADS.DESKTOP_ONLY' | translate }}</p>
|
||||
</div>
|
||||
} @else if (!playlistsLoaded()) {
|
||||
<div
|
||||
class="downloads__list-wrapper downloads__list-wrapper--loading"
|
||||
class="downloads__skeleton"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<p class="downloads__loading-label">
|
||||
{{ 'CHANNELS.LOADING' | translate }}
|
||||
</p>
|
||||
<span class="downloads__sr-only">{{
|
||||
'CHANNELS.LOADING' | translate
|
||||
}}</span>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-row" aria-hidden="true">
|
||||
<span class="downloads__skeleton-poster"></span>
|
||||
<span class="downloads__skeleton-copy"></span>
|
||||
<span class="downloads__skeleton-action"></span>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="downloads__list downloads__list--skeleton"
|
||||
class="downloads__skeleton-library"
|
||||
data-test-id="downloads-library-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-item">
|
||||
<div class="downloads__skeleton-poster"></div>
|
||||
<div class="downloads__skeleton-info">
|
||||
<div class="downloads__skeleton-title"></div>
|
||||
<div
|
||||
class="downloads__skeleton-title downloads__skeleton-title--short"
|
||||
></div>
|
||||
<div class="downloads__skeleton-meta">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="downloads__skeleton-progress"></div>
|
||||
<div class="downloads__skeleton-actions">
|
||||
@for (
|
||||
actionSlot of skeletonActionSlots;
|
||||
track actionSlot
|
||||
) {
|
||||
<span></span>
|
||||
}
|
||||
</div>
|
||||
<span class="downloads__skeleton-heading"></span>
|
||||
<div class="downloads__skeleton-cards">
|
||||
@for (card of skeletonCards; track card) {
|
||||
<div class="downloads__skeleton-card">
|
||||
<span
|
||||
class="downloads__skeleton-card-artwork"
|
||||
></span>
|
||||
<span
|
||||
class="downloads__skeleton-card-copy"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (hasNoPlaylists()) {
|
||||
@@ -106,41 +173,38 @@
|
||||
/>
|
||||
} @else if (showDownloadSkeleton()) {
|
||||
<div
|
||||
class="downloads__list-wrapper downloads__list-wrapper--loading"
|
||||
class="downloads__skeleton"
|
||||
aria-live="polite"
|
||||
aria-busy="true"
|
||||
>
|
||||
<p class="downloads__loading-label">
|
||||
{{ 'CHANNELS.LOADING' | translate }}
|
||||
</p>
|
||||
<span class="downloads__sr-only">{{
|
||||
'CHANNELS.LOADING' | translate
|
||||
}}</span>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-row" aria-hidden="true">
|
||||
<span class="downloads__skeleton-poster"></span>
|
||||
<span class="downloads__skeleton-copy"></span>
|
||||
<span class="downloads__skeleton-action"></span>
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="downloads__list downloads__list--skeleton"
|
||||
class="downloads__skeleton-library"
|
||||
data-test-id="downloads-library-skeleton"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@for (slot of skeletonItems; track slot) {
|
||||
<div class="downloads__skeleton-item">
|
||||
<div class="downloads__skeleton-poster"></div>
|
||||
<div class="downloads__skeleton-info">
|
||||
<div class="downloads__skeleton-title"></div>
|
||||
<div
|
||||
class="downloads__skeleton-title downloads__skeleton-title--short"
|
||||
></div>
|
||||
<div class="downloads__skeleton-meta">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<div class="downloads__skeleton-progress"></div>
|
||||
<div class="downloads__skeleton-actions">
|
||||
@for (
|
||||
actionSlot of skeletonActionSlots;
|
||||
track actionSlot
|
||||
) {
|
||||
<span></span>
|
||||
}
|
||||
</div>
|
||||
<span class="downloads__skeleton-heading"></span>
|
||||
<div class="downloads__skeleton-cards">
|
||||
@for (card of skeletonCards; track card) {
|
||||
<div class="downloads__skeleton-card">
|
||||
<span
|
||||
class="downloads__skeleton-card-artwork"
|
||||
></span>
|
||||
<span
|
||||
class="downloads__skeleton-card-copy"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (!hasScopedDownloads()) {
|
||||
@@ -152,334 +216,28 @@
|
||||
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
|
||||
(secondaryActionClicked)="goToDashboard()"
|
||||
/>
|
||||
} @else if (filteredDownloads().length === 0) {
|
||||
<div class="downloads__empty">
|
||||
<mat-icon>search_off</mat-icon>
|
||||
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</p>
|
||||
<span>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</span>
|
||||
} @else if (!hasVisibleDownloads()) {
|
||||
<div class="downloads__message downloads__message--compact">
|
||||
<mat-icon aria-hidden="true">search_off</mat-icon>
|
||||
<h2>{{ 'DOWNLOADS.NO_FILTER_MATCH' | translate }}</h2>
|
||||
<p>{{ 'DOWNLOADS.NO_FILTER_MATCH_HINT' | translate }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="downloads__list-wrapper">
|
||||
<div class="downloads__list">
|
||||
@for (item of filteredDownloads(); track item.id) {
|
||||
<div
|
||||
class="downloads__item"
|
||||
[class]="getStatusColor(item.status)"
|
||||
[class.downloads__item--clickable]="
|
||||
isItemNavigable(item)
|
||||
"
|
||||
[class.downloads__item--unavailable]="
|
||||
!hasSourcePlaylist(item)
|
||||
"
|
||||
[attr.role]="
|
||||
isItemNavigable(item) ? 'button' : null
|
||||
"
|
||||
[attr.tabindex]="isItemNavigable(item) ? 0 : null"
|
||||
[matTooltip]="
|
||||
!hasSourcePlaylist(item)
|
||||
? ('DOWNLOADS.SOURCE_PLAYLIST_MISSING'
|
||||
| translate)
|
||||
: null
|
||||
"
|
||||
(click)="openInLibrary(item)"
|
||||
(keydown)="onItemCardKeydown($event, item)"
|
||||
>
|
||||
<div class="downloads__item-poster">
|
||||
@if (hasPoster(item)) {
|
||||
<img
|
||||
[src]="item.posterUrl"
|
||||
[alt]="item.title"
|
||||
loading="lazy"
|
||||
(error)="markPosterFailed(item)"
|
||||
/>
|
||||
} @else {
|
||||
<mat-icon>{{
|
||||
getPosterPlaceholderIcon(item)
|
||||
}}</mat-icon>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="downloads__item-info">
|
||||
<div class="downloads__item-title">
|
||||
<span class="downloads__item-title-text">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
@if (item.contentType === 'episode') {
|
||||
<span class="downloads__episode-badge">
|
||||
{{ formatEpisodeLabel(item) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="downloads__item-meta">
|
||||
<span
|
||||
class="downloads__item-status"
|
||||
[class]="getStatusColor(item.status)"
|
||||
>
|
||||
<mat-icon>{{
|
||||
getStatusIcon(item.status)
|
||||
}}</mat-icon>
|
||||
{{
|
||||
'DOWNLOADS.STATUS.' +
|
||||
item.status.toUpperCase()
|
||||
| translate
|
||||
}}
|
||||
</span>
|
||||
|
||||
@if (
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'paused' ||
|
||||
item.status === 'completed'
|
||||
) {
|
||||
<span class="downloads__item-size">
|
||||
{{
|
||||
formatBytes(
|
||||
item.bytesDownloaded
|
||||
)
|
||||
}}
|
||||
@if (item.totalBytes) {
|
||||
/
|
||||
{{
|
||||
formatBytes(item.totalBytes)
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
@if (item.errorMessage) {
|
||||
<span class="downloads__item-error">
|
||||
{{ item.errorMessage }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (item.status === 'downloading') {
|
||||
<mat-progress-bar
|
||||
[mode]="
|
||||
item.totalBytes
|
||||
? 'determinate'
|
||||
: 'indeterminate'
|
||||
"
|
||||
[value]="getProgress(item)"
|
||||
></mat-progress-bar>
|
||||
} @else if (
|
||||
item.status === 'paused' && item.totalBytes
|
||||
) {
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[value]="getProgress(item)"
|
||||
></mat-progress-bar>
|
||||
}
|
||||
|
||||
<div class="downloads__item-actions">
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
copyUrl(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.COPY_URL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>content_copy</mat-icon>
|
||||
</button>
|
||||
@switch (item.status) {
|
||||
@case ('queued') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('downloading') {
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
pause(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PAUSE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>pause</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('paused') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
resume(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RESUME' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
cancel(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.CANCEL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('completed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
play(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.PLAY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
reveal(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REVEAL' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>folder_open</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('failed') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RETRY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@case ('canceled') {
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
retry(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.RETRY' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="
|
||||
$event.stopPropagation();
|
||||
remove(item)
|
||||
"
|
||||
[matTooltip]="
|
||||
'DOWNLOADS.REMOVE' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<app-download-queue
|
||||
[activeItems]="model().active"
|
||||
[attentionItems]="model().attention"
|
||||
[pendingIds]="pendingIds()"
|
||||
(itemAction)="runAction($event)"
|
||||
(openRequested)="openInLibrary($event)"
|
||||
/>
|
||||
<app-download-library
|
||||
[entities]="model().library"
|
||||
[availablePlaylistIds]="availablePlaylistIds()"
|
||||
[pendingIds]="pendingIds()"
|
||||
(itemAction)="runAction($event)"
|
||||
(seriesOpened)="openInLibrary($event)"
|
||||
(episodesOpened)="openDownloadedSeries($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<T> {
|
||||
readonly promise: Promise<T>;
|
||||
resolve(value: T): void;
|
||||
}
|
||||
|
||||
interface ConfirmConfig {
|
||||
readonly title: string;
|
||||
readonly message: string;
|
||||
readonly onConfirm: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ExpectedDownloadsComponent {
|
||||
readonly model: () => {
|
||||
readonly active: readonly { readonly item: DownloadItem }[];
|
||||
readonly activeCount: number;
|
||||
readonly trackedBytes: number;
|
||||
};
|
||||
readonly pendingIds: () => ReadonlySet<number>;
|
||||
clearFinished(): void;
|
||||
openDownloadedSeries(group: DownloadSeriesCardViewModel): void;
|
||||
openInLibrary(item: DownloadItem): Promise<void>;
|
||||
runAction(action: {
|
||||
readonly type: string;
|
||||
readonly item: DownloadItem;
|
||||
}): Promise<void> | void;
|
||||
setFilter(filter: string): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((settle) => {
|
||||
resolve = settle;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function download(
|
||||
id: number,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
): 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<DownloadsComponent>;
|
||||
let component: ExpectedDownloadsComponent;
|
||||
let downloads: ReturnType<typeof signal<DownloadItem[]>>;
|
||||
let routeParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
|
||||
let playlistItems: BehaviorSubject<Playlist[]>;
|
||||
let collectionContext: PortalCollectionContextService;
|
||||
let dialogConfigs: ConfirmConfig[];
|
||||
let dialogActionListener:
|
||||
((action: { type: string; item: DownloadItem }) => void) | undefined;
|
||||
let dialogClosed: Subject<void>;
|
||||
let dialogActionUnsubscribe: jest.Mock;
|
||||
let dialogSetInput: jest.Mock;
|
||||
let downloadsService: {
|
||||
readonly downloads: typeof downloads;
|
||||
readonly downloadFolder: ReturnType<typeof signal<string>>;
|
||||
readonly isAvailable: ReturnType<typeof signal<boolean>>;
|
||||
readonly isLoadingDownloads: ReturnType<typeof signal<boolean>>;
|
||||
readonly hasLoadedDownloads: ReturnType<typeof signal<boolean>>;
|
||||
readonly activeCount: ReturnType<typeof computed<number>>;
|
||||
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<DownloadItem[]>([]);
|
||||
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<void>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, true>>({});
|
||||
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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<void> => {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1
-3
@@ -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()
|
||||
|
||||
+7
-9
@@ -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();
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
<div
|
||||
class="workspace-shell"
|
||||
appPlaylistDropZone
|
||||
#dropZone="playlistDropZone"
|
||||
>
|
||||
<div class="workspace-shell" appPlaylistDropZone #dropZone="playlistDropZone">
|
||||
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
|
||||
@if (facade.showXtreamImportOverlay()) {
|
||||
<app-workspace-shell-import-overlay />
|
||||
@@ -54,7 +50,10 @@
|
||||
(accountInfoRequested)="facade.openAccountInfo()"
|
||||
/>
|
||||
|
||||
<div class="workspace-body" [class.single-pane]="!facade.showContextPanel()">
|
||||
<div
|
||||
class="workspace-body"
|
||||
[class.single-pane]="!facade.showContextPanel()"
|
||||
>
|
||||
@if (facade.showContextPanel()) {
|
||||
<app-workspace-shell-context-sidebar
|
||||
[variant]="facade.contextPanel()"
|
||||
@@ -75,9 +74,7 @@
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
(closeClicked)="facade.closeActiveExternalSession()"
|
||||
(artworkClicked)="
|
||||
facade.openActiveExternalSessionTarget()
|
||||
"
|
||||
(artworkClicked)="facade.openActiveExternalSessionTarget()"
|
||||
/>
|
||||
</footer>
|
||||
}
|
||||
|
||||
+1
-7
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user