feat(downloads): finish manager MVP

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent eb55c17a05
commit bcc2e36a05
48 files changed
+4145 -1844

No files matched your search

+8
View File
@@ -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.
+657 -41
View File
@@ -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
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+53
View File
@@ -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": {
+61 -8
View File
@@ -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;
+32 -27
View File
@@ -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;
}
@@ -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()
@@ -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,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';