feat(sources): rebuild the Sources page on the shared source row

- Side list (UI-kit side list): title with the playlist count and an Add
  button, a Type group, and on desktop a Status group (Any / Responding /
  Not responding). The two filters combine and persist; each group counts
  within the other's choice. The chosen row uses one selected-row token
  (soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
  header bar; Clean up and the sort menu are secondary buttons. Clean up
  shows how many sources do not respond. Sort adds Last updated, Most
  channels and (desktop) Not responding first, and keeps Name Z-A and
  Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
  row keeps Retry in view. "No matches" offers Reset filters. Dragging in
  custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
  runs when the toast closes (its close button commits at once). Deleting
  the active source switches to the next row. A large Xtream delete shows
  its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
  performance drivers follow the new markup and delete flow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-12 22:34:22 +02:00
1 parent 028a3db880
commit 401295c092
70 files changed
+3837 -2023

No files matched your search

@@ -1073,7 +1073,10 @@ export async function importM3uPlaylistFromUrl(
playlistUrl
);
if (userAgent !== undefined) {
await setInputValue(dialog.getByRole('textbox', { name: 'User agent', exact: true }), userAgent);
await setInputValue(
dialog.getByRole('textbox', { name: 'User agent', exact: true }),
userAgent
);
}
await dialog.getByRole('button', { name: /Add playlist/i }).click();
await dialog.waitFor({ state: 'detached' });
@@ -1187,7 +1190,10 @@ export async function createMutableTextServer(
return;
}
if (options.requiredUserAgent && req.headers['user-agent'] !== options.requiredUserAgent) {
if (
options.requiredUserAgent &&
req.headers['user-agent'] !== options.requiredUserAgent
) {
res.writeHead(403);
res.end('User-Agent required');
return;
@@ -1469,20 +1475,23 @@ export async function clickCategoryByNameExact(
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({
return page.locator('app-workspace-sources app-source-row').filter({
hasText: flexibleTextPattern(title),
});
}
export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) =>
elements
.map((element) => {
const titleElement = element.querySelector('.playlist-title');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
return page
.locator('app-workspace-sources app-source-row')
.evaluateAll((elements) =>
elements
.map((element) => {
const titleElement =
element.querySelector('.source-row__name');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
);
}
export async function selectSourceTypeFilter(
@@ -1497,8 +1506,10 @@ export async function selectSourceSort(
sortLabel:
| 'Date added (Newest first)'
| 'Date added (Oldest first)'
| 'Last updated'
| 'Name (A-Z)'
| 'Name (Z-A)'
| 'Most channels'
| 'Custom order'
): Promise<void> {
const sortTrigger = page.locator('app-workspace-sources .sort-trigger');
@@ -1523,10 +1534,10 @@ export async function dragSourceBefore(
sourceTitle: string,
targetTitle: string
): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
const target = sourceRowByTitle(page, targetTitle).locator(
'.playlist-item'
const source = sourceRowByTitle(page, sourceTitle).locator(
'.source-row__handle'
);
const target = sourceRowByTitle(page, targetTitle).locator('.source-row');
await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible();
@@ -1560,15 +1571,18 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.edit-btn').click();
await row.hover();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
// Material applies its initial focus after the opening transition.
await dialog.evaluate(async (element) => {
await Promise.all(element.getAnimations({ subtree: true }).map((animation) =>
animation.finished.catch(() => undefined)
));
await Promise.all(
element
.getAnimations({ subtree: true })
.map((animation) => animation.finished.catch(() => undefined))
);
});
return dialog;
}
@@ -1678,12 +1692,24 @@ export async function expectSourceDialogValues(
}
}
/**
* Deletes a source from its ⋯ menu. The row leaves at once; the delete
* itself runs when the Undo toast closes, so this waits for the toast to go
* before the caller checks persisted state.
*/
export async function deleteSource(page: Page, title: string): Promise<void> {
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.delete-btn').click();
await confirmDialog(page, 'Remove playlist');
await row.hover();
await row.locator('.source-row__more').click();
await page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
const toast = page.locator('mat-snack-bar-container.app-toast');
await expect(toast).toContainText('Removed');
await expect(toast).toBeHidden({ timeout: 15000 });
}
export async function refreshSource(
@@ -1697,7 +1723,8 @@ export async function refreshSource(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await row.locator('.refresh-btn').click();
await row.hover();
await row.locator('.source-row__refresh').click();
if (confirm) {
await confirmDialog(page, 'Refresh playlist');
@@ -1711,10 +1738,10 @@ export async function waitForSourceRowIdle(
const row = sourceRowByTitle(page, title).first();
await expect(row).toBeVisible();
await expect(row.locator('.busy-state__message')).toHaveCount(0, {
await expect(row.locator('.source-row__busy')).toHaveCount(0, {
timeout: 60000,
});
await expect(row.locator('.action-spinner')).toHaveCount(0, {
await expect(row.locator('mat-spinner')).toHaveCount(0, {
timeout: 60000,
});
}
@@ -70,7 +70,7 @@ test.describe('Electron Native Playlist Import', () => {
});
await openSources(app.mainWindow);
await expect(
app.mainWindow.locator('app-playlist-item')
app.mainWindow.locator('app-workspace-sources app-source-row')
).toHaveCount(2, { timeout: 30000 });
} finally {
await closeElectronApp(app);
@@ -550,19 +550,21 @@ test.describe('Keyboard focus ring', () => {
// Sources: the type filters, then the list's row actions.
await openSources(page);
await expect(
page.locator('app-playlist-item').first()
page.locator('app-workspace-sources app-source-row').first()
).toBeVisible();
// Add, the Type and Status rows, Clean up, Sort, then the first
// row and its actions; Check again lives in the row's menu.
await walk(
'sources list',
page.locator('app-workspace-shell'),
page
.locator('app-workspace-sources-filters-panel button')
.first(),
12
16
);
expect(
report['sources list (dark)'].map((stop) => stop.label)
).toContain('Check again');
).toContain('Playlist actions');
// The live EPG list: its rows are Tab stops (`role="button"`)
// with their own inset ring, then the row's info button.
@@ -590,7 +590,7 @@ test('launch options target the workspace source cards and the shared sentinel',
assert.equal(options.splashId, 'initial-splash');
assert.equal(options.routeFragment, '/workspace');
assert.match(options.cardSelector, /dashboard-recent-sources-rail-card/);
assert.match(options.cardSelector, /app-playlist-item/);
assert.match(options.cardSelector, /app-source-row/);
assert.deepEqual(options.settle, {
capMs: JOURNEY_SETTLE_CAP_MS,
quietMs: JOURNEY_SETTLE_QUIET_MS,
@@ -921,7 +921,7 @@ test('open-source options start at the portal card and end on the source route',
options.startClick?.selector ?? '',
/dashboard-recent-sources-rail-card/
);
assert.match(options.startClick?.selector ?? '', /app-playlist-item/);
assert.match(options.startClick?.selector ?? '', /app-source-row/);
assert.equal(options.routeFragment, '/workspace/xtreams/');
assert.match(options.cardSelector, /app-grid-list mat-card/);
assert.match(options.cardSelector, /channel-item/);
@@ -57,7 +57,7 @@ export const JOURNEY_PLAYBACK_START_SELECTOR =
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row';
/**
* Bridge method used for the sentinels. The preload emits the trace event
* before it forwards the call, and `SOURCE_HEALTH_CANCEL` only looks the id
@@ -862,7 +862,7 @@ export function createLaunchJourneyProbeOptions(
): JourneyRendererProbeOptions {
return {
cardSelector:
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item',
'[data-test-id="dashboard-recent-sources-rail-card"], app-source-row',
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
...(idleWindowMs === null
? {}
@@ -262,7 +262,7 @@ async function runIteration(
await renderer.markOperationStart();
const row = sourceRowByTitle(app.mainWindow, SOURCE_TITLE).first();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await renderer.waitForCancelClick();
await Promise.all([
renderer.waitForTerminal(),
@@ -301,7 +301,7 @@ async function installRendererProbe(
const epoch = (): number =>
performance.timeOrigin + performance.now();
const sourceRow = (): Element | undefined =>
Array.from(document.querySelectorAll('app-playlist-item')).find(
Array.from(document.querySelectorAll('app-source-row')).find(
(element) => element.textContent?.includes(title)
);
const state = {
@@ -152,11 +152,11 @@ export async function installXtreamRendererProbe(
);
const sourceRowAbsent = (): boolean =>
!Array.from(
document.querySelectorAll('app-playlist-item')
document.querySelectorAll('app-source-row')
).some(
(row) =>
row
.querySelector('.playlist-title')
.querySelector('.source-row__name')
?.textContent?.trim() === state.sourceTitle
);
const terminalDomGate = (): boolean => {
@@ -32,14 +32,14 @@ describe('Xtream scenario driver source wiring', () => {
"'#serverUrl'",
"'#username'",
"'#password'",
"'app-playlist-item'",
"'.refresh-btn'",
"'.delete-btn'",
"'app-workspace-sources app-source-row'",
"'.source-row__refresh'",
"'.source-row__more'",
"'Refresh playlist'",
"'Remove playlist'",
"'.busy-state__message'",
"'.action-spinner'",
"'.cancel-btn'",
"'.source-row__busy'",
"'mat-spinner'",
"'.source-row__cancel'",
"'.workspace-loading-overlay'",
]) {
assert.ok(ui.includes(selector), `missing ${selector}`);
@@ -157,15 +157,18 @@ describe('Xtream Playwright scenario driver', () => {
});
it('does not require a misleading portal-status badge before refresh or delete', async () => {
for (const [scenarioId, selector, confirmLabel] of [
for (const [scenarioId, selector, confirmClick] of [
[
XTREAM_SCENARIO_ID.REFRESH_LARGE,
'.refresh-btn',
'Refresh playlist',
'.source-row__refresh',
'name=Refresh playlist;exact=true',
],
[
XTREAM_SCENARIO_ID.DELETE_LARGE,
'.source-row__more',
'.source-removal-toast__close',
],
[XTREAM_SCENARIO_ID.DELETE_LARGE, '.delete-btn', 'Remove playlist'],
] as const) {
const confirmClick = `name=${confirmLabel};exact=true`;
const fake = new FakePage();
const prepared = await prepareXtreamScenario(
fake.asPage(),
@@ -68,28 +68,42 @@ export async function prepareXtreamSourceActionTrigger(
const row = xtreamSourceRow(page, sourceTitle);
await row.waitFor({ state: 'visible' });
for (const selector of [
'.busy-state__message',
'.action-spinner',
'.cancel-btn',
'.source-row__busy',
'mat-spinner',
'.source-row__cancel',
]) {
await row.locator(selector).waitFor({ state: 'detached' });
}
const actionSelector =
action === 'refresh' ? '.refresh-btn' : '.delete-btn';
const actionButton = row.locator(actionSelector);
await assertTriggerReady(actionButton, `xtream-source-${action}`);
await actionButton.click();
if (action === 'refresh') {
const refresh = row.locator('.source-row__refresh');
await assertTriggerReady(refresh, 'xtream-source-refresh');
await refresh.click();
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.last();
await assertTriggerReady(confirmation, 'xtream-source-refresh-confirm');
return confirmation;
}
const dialog = page.locator('mat-dialog-container').last();
await dialog.waitFor({ state: 'visible' });
const confirmation = dialog
.getByRole('button', {
name: action === 'refresh' ? 'Refresh playlist' : 'Remove playlist',
exact: true,
})
.last();
await assertTriggerReady(confirmation, `xtream-source-${action}-confirm`);
return confirmation;
// Delete leaves the list at once and offers Undo; the toast's close
// button commits it without waiting out the Undo window, so the
// measurement starts when the delete does.
const more = row.locator('.source-row__more');
await assertTriggerReady(more, 'xtream-source-delete-menu');
await more.click();
const remove = page
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.first();
await assertTriggerReady(remove, 'xtream-source-delete');
await remove.click();
const commit = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__close'
);
await assertTriggerReady(commit, 'xtream-source-delete-confirm');
return commit;
}
export async function waitForXtreamCatalogTerminal(page: Page): Promise<void> {
@@ -129,7 +143,10 @@ export async function waitForXtreamDeleteTerminal(
page: Page,
sourceTitle: string
): Promise<void> {
await xtreamSourceRow(page, sourceTitle).waitFor({ state: 'detached' });
const row = xtreamSourceRow(page, sourceTitle);
// The row returns as "Removing…" once the delete starts, then leaves.
await row.waitFor({ state: 'attached' });
await row.waitFor({ state: 'detached' });
}
async function selectXtreamMethod(dialog: Locator): Promise<void> {
@@ -157,9 +174,9 @@ async function selectXtreamMethod(dialog: Locator): Promise<void> {
function xtreamSourceRow(page: Page, sourceTitle: string): Locator {
const exactTitle = new RegExp(`^\\s*${escapeRegex(sourceTitle)}\\s*$`, 'u');
return page
.locator('app-playlist-item')
.locator('app-workspace-sources app-source-row')
.filter({
has: page.locator('.playlist-title', {
has: page.locator('.source-row__name', {
hasText: exactTitle,
}),
})
@@ -178,7 +178,7 @@ class SearchCompletionFakePage {
}
this.searchGateObserved =
/searchParams\.get\((['"])q\1\)/u.test(source) &&
source.includes('app-playlist-item') &&
source.includes('app-source-row') &&
source.includes('no-results-state');
if (this.searchGateObserved) {
this.searchResultsApplied = true;
@@ -154,14 +154,14 @@ export async function runXtreamBackgroundUiActions(
);
const resultRows = Array.from(
document.querySelectorAll(
'app-workspace-sources app-playlist-item'
'app-workspace-sources app-source-row'
)
);
const resultPainted =
resultRows.length > 0
? resultRows.every((row) =>
(
row.querySelector('.playlist-title')
row.querySelector('.source-row__name')
?.textContent ?? ''
)
.trim()
@@ -31,7 +31,7 @@ type RowRing = {
function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => {
const focused = document.activeElement as HTMLElement;
const row = focused.closest('.playlist-item') as HTMLElement;
const row = focused.closest('.source-row') as HTMLElement;
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
row.append(probe);
@@ -93,16 +93,19 @@ test.describe('Electron Sources keyboard', () => {
timeout: 30_000,
});
await openSources(page);
await expect(page.locator('app-playlist-item')).toHaveCount(2, {
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(2, {
timeout: 30_000,
});
// Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.playlist-item')).not.toHaveClass(
/\bselected\b/
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
'aria-current',
'true'
);
const title = (
await row.locator('.playlist-title').textContent()
await row.locator('.source-row__name').textContent()
)?.trim();
expect(title).toBeTruthy();
@@ -153,7 +156,7 @@ test.describe('Electron Sources keyboard', () => {
await expect(open).not.toBeFocused();
expect(
await row
.locator('.playlist-item')
.locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none');
await page.keyboard.press('Shift+Tab');
+15 -13
View File
@@ -717,14 +717,14 @@ https://streams.example.test/url-omega.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream').locator(
'app-source-health-indicator [data-state]'
'.source-tile__dot[data-state]'
)
).toHaveAttribute('data-state', 'active', { timeout: 20000 });
).toHaveAttribute('data-state', 'ok', { timeout: 20000 });
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
@@ -822,8 +822,8 @@ https://streams.example.test/original-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, refreshLocalSourceDisplayName)
.first()
.locator('.refresh-btn')
).toBeVisible();
.locator('.source-row__refresh')
).toBeAttached();
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
@@ -865,8 +865,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
@@ -876,9 +876,11 @@ https://streams.example.test/refreshed-url.m3u8
'refresh-url-source.m3u'
).first();
await expect(
refreshedUrlRow.locator('.playlist-item')
).not.toHaveClass(/is-busy/);
await expect(refreshedUrlRow.locator('.refresh-btn')).toBeEnabled();
refreshedUrlRow.locator('.source-row')
).not.toHaveClass(/source-row--busy/);
await expect(
refreshedUrlRow.locator('.source-row__refresh')
).toBeEnabled();
await refreshedUrlRow
.getByText('refresh-url-source.m3u', { exact: true })
.click();
@@ -904,8 +906,8 @@ https://streams.example.test/refreshed-url.m3u8
await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first()
.locator('.meta')
).toContainText('Updated:');
.locator('.source-row__meta')
).toContainText('updated');
} finally {
await closeElectronApp(app);
await urlServer.close();
@@ -25,7 +25,7 @@ const dbWorkerBatchDelayMs = '20';
const dbWorkerRowsPerTransaction = '100';
function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`;
return `app-workspace-sources app-source-row:has-text("${name}")`;
}
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
@@ -287,14 +287,13 @@ test.describe('Electron Xtream Responsiveness', () => {
);
await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Remove playlist', exact: true })
// Delete from the row menu; the delete starts when the Undo
// toast closes, and the row comes back to show its progress.
await playlistRow.hover();
await playlistRow.locator('.source-row__more').click();
await app.mainWindow
.locator('.cdk-overlay-pane [mat-menu-item]')
.filter({ hasText: 'Remove playlist' })
.click();
await waitForDbOperationEvent(app.mainWindow, {
@@ -303,10 +302,10 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000,
});
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
const cancel = playlistRow.locator('.cancel-btn');
await expect(playlistRow.locator('.source-row__busy')).toBeVisible({
timeout: 20000,
});
const cancel = playlistRow.locator('.source-row__cancel');
await expect(cancel).toBeVisible();
// The row's action buttons inherit its color; the cancel action
// must still read as destructive on the selected row.
+2 -6
View File
@@ -111,16 +111,12 @@ test('@a11y icon-only buttons are named in Sources and the playlist info dialog'
}) => {
await addM3uUrlPlaylist(page, M3U_TITLE);
// Sources list: refresh, details and remove on each row.
// Sources list: refresh, edit and the actions menu on each row.
await openSources(page);
const row = sourceRowByTitle(page, M3U_TITLE);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
for (const name of [
'Refresh playlist',
'Show playlist details',
'Remove playlist',
]) {
for (const name of ['Refresh playlist', 'Edit…', 'Playlist actions']) {
await expect(row.getByRole('button', { name })).toBeAttached();
}
await expectNamedButtons(page, 3);
+1 -1
View File
@@ -259,7 +259,7 @@ test('@self-hosted M3U User-Agent reaches the provider on import and refresh', a
channel = 'Refreshed UA Channel';
const row = sourceRowByTitle(page, 'Protected M3U');
await row.hover();
await row.locator('.refresh-btn').click();
await row.locator('.source-row__refresh').click();
await expect(
page.locator('.mat-mdc-snack-bar-label').last()
).toContainText('updated');
+5 -2
View File
@@ -190,7 +190,10 @@ export async function openSources(page: Page): Promise<void> {
}
export function sourceRowByTitle(page: Page, title: string): Locator {
return page.locator('app-playlist-item').filter({ hasText: title }).first();
return page
.locator('app-workspace-sources app-source-row')
.filter({ hasText: title })
.first();
}
export async function openSourceEditor(
@@ -200,7 +203,7 @@ export async function openSourceEditor(
const row = sourceRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 15_000 });
await row.hover();
await row.locator('.edit-btn').click();
await row.locator('.source-row__edit').click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
return dialog;
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)"
"CUSTOM_ORDER": "ترتيب مخصص (سحب وإفلات)",
"UPDATED": "آخر تحديث",
"CHANNELS": "الأكثر قنوات",
"FAILING_FIRST": "غير المستجيبة أولاً"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -1915,7 +1918,16 @@
"EDIT": "تعديل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "فتح"
}
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "تستجيب",
"STATUS_BAD": "لا تستجيب",
"NO_MATCHES": "لا توجد قوائم تطابق عوامل التصفية هذه",
"RESET_FILTERS": "إعادة تعيين عوامل التصفية",
"REMOVED": "تمت إزالة «{{name}}»",
"REMOVED_SWITCHED": "تمت إزالة «{{name}}»، والانتقال إلى «{{next}}»",
"REMOVE_FAILED": "تعذّرت إزالة «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "الاسم (ي-أ)",
"NEWEST": "تاريخ الإضافة (الأحدث أولاً)",
"OLDEST": "تاريخ الإضافة (الأقدم أولاً)",
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)"
"CUSTOM_ORDER": "ترتيب مخصص (جرّ وحط)",
"UPDATED": "آخر تحديث",
"CHANNELS": "اللي فيها كتر القنوات",
"FAILING_FIRST": "اللي ما كيجاوبوش اللولين"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (محلي، رابط، نص)",
@@ -1915,7 +1918,16 @@
"EDIT": "بدّل…",
"COPY_URL": "نسخ الرابط",
"OPEN": "حلّ"
}
},
"STATUS": "الحالة",
"STATUS_ANY": "أي حالة",
"STATUS_OK": "كيجاوبو",
"STATUS_BAD": "ما كيجاوبوش",
"NO_MATCHES": "حتى ليستة ما كتوافق هاد الفلاتر",
"RESET_FILTERS": "رجّع الفلاتر",
"REMOVED": "تحيّدات «{{name}}»",
"REMOVED_SWITCHED": "تحيّدات «{{name}}»، دوّزنا لـ «{{next}}»",
"REMOVE_FAILED": "ما قدرناش نحيّدو «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "الفئات",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Назва (Я-А)",
"NEWEST": "Дата дадання (спачатку новыя)",
"OLDEST": "Дата дадання (спачатку старыя)",
"CUSTOM_ORDER": "Уласны парадак (перацягванне)"
"CUSTOM_ORDER": "Уласны парадак (перацягванне)",
"UPDATED": "Апошняе абнаўленне",
"CHANNELS": "Колькасць каналаў",
"FAILING_FIRST": "Спачатку тыя, што не адказваюць"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (лакальны, URL, тэкст)",
@@ -1915,7 +1918,16 @@
"EDIT": "Змяніць…",
"COPY_URL": "Скапіяваць URL",
"OPEN": "Адкрыць"
}
},
"STATUS": "Статус",
"STATUS_ANY": "Любы",
"STATUS_OK": "Адказваюць",
"STATUS_BAD": "Не адказваюць",
"NO_MATCHES": "Няма плэйлістоў пад гэтыя фільтры",
"RESET_FILTERS": "Скінуць фільтры",
"REMOVED": "Выдалены «{{name}}»",
"REMOVED_SWITCHED": "Выдалены «{{name}}», адкрыты «{{next}}»",
"REMOVE_FAILED": "Не ўдалося выдаліць «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Катэгорыі",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Hinzugefügt (Neueste zuerst)",
"OLDEST": "Hinzugefügt (Älteste zuerst)",
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)"
"CUSTOM_ORDER": "Benutzerdefinierte Reihenfolge (Drag & Drop)",
"UPDATED": "Zuletzt aktualisiert",
"CHANNELS": "Meiste Sender",
"FAILING_FIRST": "Nicht erreichbare zuerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokal, URL, Text)",
@@ -1915,7 +1918,16 @@
"EDIT": "Bearbeiten…",
"COPY_URL": "URL kopieren",
"OPEN": "Öffnen"
}
},
"STATUS": "Status (Erreichbarkeit)",
"STATUS_ANY": "Alle",
"STATUS_OK": "Erreichbar",
"STATUS_BAD": "Nicht erreichbar",
"NO_MATCHES": "Keine Playlists passen zu diesen Filtern",
"RESET_FILTERS": "Filter zurücksetzen",
"REMOVED": "„{{name}}“ entfernt",
"REMOVED_SWITCHED": "„{{name}}“ entfernt, zu „{{next}}“ gewechselt",
"REMOVE_FAILED": "„{{name}}“ konnte nicht entfernt werden"
},
"CONTEXT": {
"CATEGORIES": "Kategorien",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Όνομα (Z-A)",
"NEWEST": "Ημερομηνία προσθήκης (Πρώτα τα νεότερα)",
"OLDEST": "Ημερομηνία προσθήκης (Πρώτα το παλαιότερο)",
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)"
"CUSTOM_ORDER": "Προσαρμοσμένη σειρά (drag & drop)",
"UPDATED": "Τελευταία ενημέρωση",
"CHANNELS": "Περισσότερα κανάλια",
"FAILING_FIRST": "Πρώτα όσα δεν αποκρίνονται"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (τοπικό, διεύθυνση url, κείμενο)",
@@ -1915,7 +1918,16 @@
"EDIT": "Επεξεργασία…",
"COPY_URL": "Αντιγραφή URL",
"OPEN": "Άνοιγμα"
}
},
"STATUS": "Κατάσταση",
"STATUS_ANY": "Οποιαδήποτε",
"STATUS_OK": "Αποκρίνονται",
"STATUS_BAD": "Δεν αποκρίνονται",
"NO_MATCHES": "Καμία λίστα δεν ταιριάζει με αυτά τα φίλτρα",
"RESET_FILTERS": "Επαναφορά φίλτρων",
"REMOVED": "Καταργήθηκε το «{{name}}»",
"REMOVED_SWITCHED": "Καταργήθηκε το «{{name}}», μετάβαση στο «{{next}}»",
"REMOVE_FAILED": "Δεν ήταν δυνατή η κατάργηση του «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Κατηγορίες",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Name (Z-A)",
"NEWEST": "Date added (Newest first)",
"OLDEST": "Date added (Oldest first)",
"CUSTOM_ORDER": "Custom order (Drag & Drop)"
"CUSTOM_ORDER": "Custom order (Drag & Drop)",
"UPDATED": "Last updated",
"CHANNELS": "Most channels",
"FAILING_FIRST": "Not responding first"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, text)",
@@ -1915,7 +1918,16 @@
"EDIT": "Edit…",
"COPY_URL": "Copy URL",
"OPEN": "Open"
}
},
"STATUS": "Status",
"STATUS_ANY": "Any",
"STATUS_OK": "Responding",
"STATUS_BAD": "Not responding",
"NO_MATCHES": "No playlists match these filters",
"RESET_FILTERS": "Reset filters",
"REMOVED": "Removed “{{name}}”",
"REMOVED_SWITCHED": "Removed “{{name}}”, switched to “{{next}}”",
"REMOVE_FAILED": "Could not remove “{{name}}”"
},
"CONTEXT": {
"CATEGORIES": "Categories",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nombre (Z-A)",
"NEWEST": "Fecha de adición (más recientes primero)",
"OLDEST": "Fecha de adición (más antiguas primero)",
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)"
"CUSTOM_ORDER": "Orden personalizado (arrastrar y soltar)",
"UPDATED": "Última actualización",
"CHANNELS": "Más canales",
"FAILING_FIRST": "Primero las que no responden"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -1915,7 +1918,16 @@
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir"
}
},
"STATUS": "Estado",
"STATUS_ANY": "Cualquiera",
"STATUS_OK": "Responden",
"STATUS_BAD": "No responden",
"NO_MATCHES": "Ninguna lista coincide con estos filtros",
"RESET_FILTERS": "Restablecer filtros",
"REMOVED": "Se eliminó «{{name}}»",
"REMOVED_SWITCHED": "Se eliminó «{{name}}» y se cambió a «{{next}}»",
"REMOVE_FAILED": "No se pudo eliminar «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Categorías",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nom (Z-A)",
"NEWEST": "Date d'ajout (Plus récent)",
"OLDEST": "Date d'ajout (Plus ancien)",
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)"
"CUSTOM_ORDER": "Ordre personnalisé (glisser-déposer)",
"UPDATED": "Dernière mise à jour",
"CHANNELS": "Plus de chaînes",
"FAILING_FIRST": "Celles qui ne répondent pas d'abord"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, URL, texte)",
@@ -1915,7 +1918,16 @@
"EDIT": "Modifier…",
"COPY_URL": "Copier l’URL",
"OPEN": "Ouvrir"
}
},
"STATUS": "État",
"STATUS_ANY": "Tous",
"STATUS_OK": "Répondent",
"STATUS_BAD": "Ne répondent pas",
"NO_MATCHES": "Aucune playlist ne correspond à ces filtres",
"RESET_FILTERS": "Réinitialiser les filtres",
"REMOVED": "« {{name}} » supprimée",
"REMOVED_SWITCHED": "« {{name}} » supprimée, passage à « {{next}} »",
"REMOVE_FAILED": "Impossible de supprimer « {{name}} »"
},
"CONTEXT": {
"CATEGORIES": "Catégories",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Név szerint (Z–A)",
"NEWEST": "Hozzáadás dátuma (legújabb elöl)",
"OLDEST": "Hozzáadás dátuma (legrégebbi elöl)",
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)"
"CUSTOM_ORDER": "Egyéni sorrend (húzás és ejtés)",
"UPDATED": "Legutóbbi frissítés",
"CHANNELS": "Legtöbb csatorna",
"FAILING_FIRST": "Nem válaszolók elöl"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (helyi fájl, URL vagy szöveg)",
@@ -1915,7 +1918,16 @@
"EDIT": "Szerkesztés…",
"COPY_URL": "URL másolása",
"OPEN": "Megnyitás"
}
},
"STATUS": "Állapot",
"STATUS_ANY": "Bármelyik",
"STATUS_OK": "Válaszolnak",
"STATUS_BAD": "Nem válaszolnak",
"NO_MATCHES": "Egyik lejátszási lista sem felel meg ezeknek a szűrőknek",
"RESET_FILTERS": "Szűrők törlése",
"REMOVED": "„{{name}}” eltávolítva",
"REMOVED_SWITCHED": "„{{name}}” eltávolítva, váltás erre: „{{next}}”",
"REMOVE_FAILED": "„{{name}}” nem távolítható el"
},
"CONTEXT": {
"CATEGORIES": "Kategóriák",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data aggiunta (prima più recenti)",
"OLDEST": "Data aggiunta (prima più vecchi)",
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)"
"CUSTOM_ORDER": "Ordine personalizzato (trascina e rilascia)",
"UPDATED": "Ultimo aggiornamento",
"CHANNELS": "Più canali",
"FAILING_FIRST": "Prima quelle che non rispondono"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (locale, URL, testo)",
@@ -1915,7 +1918,16 @@
"EDIT": "Modifica…",
"COPY_URL": "Copia URL",
"OPEN": "Apri"
}
},
"STATUS": "Stato",
"STATUS_ANY": "Qualsiasi",
"STATUS_OK": "Rispondono",
"STATUS_BAD": "Non rispondono",
"NO_MATCHES": "Nessuna playlist corrisponde a questi filtri",
"RESET_FILTERS": "Reimposta filtri",
"REMOVED": "«{{name}}» rimossa",
"REMOVED_SWITCHED": "«{{name}}» rimossa, passaggio a «{{next}}»",
"REMOVE_FAILED": "Impossibile rimuovere «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Categorie",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名前(Z-A)",
"NEWEST": "追加日(新しい順)",
"OLDEST": "追加日(古い順)",
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)"
"CUSTOM_ORDER": "カスタム順(ドラッグ&ドロップ)",
"UPDATED": "最終更新",
"CHANNELS": "チャンネル数",
"FAILING_FIRST": "応答なしを先頭に"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(ローカル、URL、テキスト)",
@@ -1915,7 +1918,16 @@
"EDIT": "編集…",
"COPY_URL": "URLをコピー",
"OPEN": "開く"
}
},
"STATUS": "状態",
"STATUS_ANY": "すべて",
"STATUS_OK": "応答あり",
"STATUS_BAD": "応答なし",
"NO_MATCHES": "この条件に一致するプレイリストはありません",
"RESET_FILTERS": "フィルターをリセット",
"REMOVED": "「{{name}}」を削除しました",
"REMOVED_SWITCHED": "「{{name}}」を削除し、「{{next}}」に切り替えました",
"REMOVE_FAILED": "「{{name}}」を削除できませんでした"
},
"CONTEXT": {
"CATEGORIES": "カテゴリー",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "이름 (Z-A)",
"NEWEST": "추가 날짜 (최신순)",
"OLDEST": "추가 날짜 (오래된순)",
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)"
"CUSTOM_ORDER": "사용자 지정 순서(드래그 앤 드롭)",
"UPDATED": "최근 업데이트",
"CHANNELS": "채널 많은 순",
"FAILING_FIRST": "응답 없는 항목 먼저"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (로컬, URL, 텍스트)",
@@ -1915,7 +1918,16 @@
"EDIT": "편집…",
"COPY_URL": "URL 복사",
"OPEN": "열기"
}
},
"STATUS": "상태",
"STATUS_ANY": "전체",
"STATUS_OK": "응답함",
"STATUS_BAD": "응답 없음",
"NO_MATCHES": "이 필터와 일치하는 재생목록이 없습니다",
"RESET_FILTERS": "필터 초기화",
"REMOVED": "'{{name}}' 삭제됨",
"REMOVED_SWITCHED": "'{{name}}' 삭제됨, '{{next}}'(으)로 전환됨",
"REMOVE_FAILED": "'{{name}}'을(를) 삭제할 수 없습니다"
},
"CONTEXT": {
"CATEGORIES": "카테고리",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Naam (Z-A)",
"NEWEST": "Datum toegevoegd (Nieuwste eerst)",
"OLDEST": "Datum toegevoegd (Oudste eerst)",
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)"
"CUSTOM_ORDER": "Aangepaste volgorde (slepen en neerzetten)",
"UPDATED": "Laatst bijgewerkt",
"CHANNELS": "Meeste zenders",
"FAILING_FIRST": "Niet-reagerende eerst"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokaal, URL, tekst)",
@@ -1915,7 +1918,16 @@
"EDIT": "Bewerken…",
"COPY_URL": "URL kopiëren",
"OPEN": "Openen"
}
},
"STATUS": "Bereikbaarheid",
"STATUS_ANY": "Alle",
"STATUS_OK": "Reageren",
"STATUS_BAD": "Reageren niet",
"NO_MATCHES": "Geen afspeellijsten voldoen aan deze filters",
"RESET_FILTERS": "Filters wissen",
"REMOVED": "‘{{name}}’ verwijderd",
"REMOVED_SWITCHED": "‘{{name}}’ verwijderd, overgeschakeld naar ‘{{next}}’",
"REMOVE_FAILED": "‘{{name}}’ kon niet worden verwijderd"
},
"CONTEXT": {
"CATEGORIES": "Categorieën",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nazwa (Z-A)",
"NEWEST": "Data dodania (najnowsze pierwsze)",
"OLDEST": "Data dodania (najstarsze pierwsze)",
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)"
"CUSTOM_ORDER": "Własna kolejność (przeciągnij i upuść)",
"UPDATED": "Ostatnia aktualizacja",
"CHANNELS": "Najwięcej kanałów",
"FAILING_FIRST": "Najpierw nieodpowiadające"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (lokalny, URL, tekst)",
@@ -1915,7 +1918,16 @@
"EDIT": "Edytuj…",
"COPY_URL": "Kopiuj URL",
"OPEN": "Otwórz"
}
},
"STATUS": "Stan",
"STATUS_ANY": "Dowolny",
"STATUS_OK": "Odpowiadają",
"STATUS_BAD": "Nie odpowiadają",
"NO_MATCHES": "Żadna playlista nie pasuje do tych filtrów",
"RESET_FILTERS": "Wyczyść filtry",
"REMOVED": "Usunięto „{{name}}”",
"REMOVED_SWITCHED": "Usunięto „{{name}}”, przełączono na „{{next}}”",
"REMOVE_FAILED": "Nie udało się usunąć „{{name}}”"
},
"CONTEXT": {
"CATEGORIES": "Kategorie",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Nome (Z-A)",
"NEWEST": "Data de adição (Mais recente primeiro)",
"OLDEST": "Data de adição (Mais antigo primeiro)",
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)"
"CUSTOM_ORDER": "Ordem personalizada (arrastar e soltar)",
"UPDATED": "Última atualização",
"CHANNELS": "Mais canais",
"FAILING_FIRST": "Primeiro as que não respondem"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (local, url, texto)",
@@ -1915,7 +1918,16 @@
"EDIT": "Editar…",
"COPY_URL": "Copiar URL",
"OPEN": "Abrir"
}
},
"STATUS": "Estado",
"STATUS_ANY": "Qualquer",
"STATUS_OK": "Respondem",
"STATUS_BAD": "Não respondem",
"NO_MATCHES": "Nenhuma lista corresponde a estes filtros",
"RESET_FILTERS": "Repor filtros",
"REMOVED": "«{{name}}» removida",
"REMOVED_SWITCHED": "«{{name}}» removida, mudou para «{{next}}»",
"REMOVE_FAILED": "Não foi possível remover «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Categorias",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "Имени (Я-А)",
"NEWEST": "Дате добавления (сначала новые)",
"OLDEST": "Дате добавления (сначала старые)",
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)"
"CUSTOM_ORDER": "Пользовательский порядок (перетаскивание)",
"UPDATED": "Последнему обновлению",
"CHANNELS": "Числу каналов",
"FAILING_FIRST": "Сначала неактивные"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (локально, URL, текст)",
@@ -1915,7 +1918,16 @@
"EDIT": "Изменить…",
"COPY_URL": "Копировать URL",
"OPEN": "Открыть"
}
},
"STATUS": "Статус",
"STATUS_ANY": "Любой",
"STATUS_OK": "Отвечают",
"STATUS_BAD": "Не отвечают",
"NO_MATCHES": "Нет плейлистов с такими фильтрами",
"RESET_FILTERS": "Сбросить фильтры",
"REMOVED": "Удалён «{{name}}»",
"REMOVED_SWITCHED": "Удалён «{{name}}», открыт «{{next}}»",
"REMOVE_FAILED": "Не удалось удалить «{{name}}»"
},
"CONTEXT": {
"CATEGORIES": "Категории",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "İsim (Z-A)",
"NEWEST": "Eklenme tarihi (En yeni önce)",
"OLDEST": "Eklenme tarihi (En eski önce)",
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)"
"CUSTOM_ORDER": "Özel sıra (sürükle ve bırak)",
"UPDATED": "Son güncelleme",
"CHANNELS": "En çok kanal",
"FAILING_FIRST": "Önce yanıt vermeyenler"
},
"PLAYLIST_TYPES": {
"M3U": "M3U (yerel, url, metin)",
@@ -1915,7 +1918,16 @@
"EDIT": "Düzenle…",
"COPY_URL": "URL'yi kopyala",
"OPEN": "Aç"
}
},
"STATUS": "Durum",
"STATUS_ANY": "Tümü",
"STATUS_OK": "Yanıt veriyor",
"STATUS_BAD": "Yanıt vermiyor",
"NO_MATCHES": "Bu filtrelere uyan oynatma listesi yok",
"RESET_FILTERS": "Filtreleri sıfırla",
"REMOVED": "“{{name}}” kaldırıldı",
"REMOVED_SWITCHED": "“{{name}}” kaldırıldı, “{{next}}” açıldı",
"REMOVE_FAILED": "“{{name}}” kaldırılamadı"
},
"CONTEXT": {
"CATEGORIES": "Kategoriler",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名称(Z-A)",
"NEWEST": "添加日期(最新优先)",
"OLDEST": "添加日期(最旧优先)",
"CUSTOM_ORDER": "自定义顺序(拖放)"
"CUSTOM_ORDER": "自定义顺序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "频道数量",
"FAILING_FIRST": "无响应的优先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本地、URL、文本)",
@@ -1915,7 +1918,16 @@
"EDIT": "编辑…",
"COPY_URL": "复制 URL",
"OPEN": "打开"
}
},
"STATUS": "状态",
"STATUS_ANY": "全部",
"STATUS_OK": "有响应",
"STATUS_BAD": "无响应",
"NO_MATCHES": "没有符合这些筛选条件的播放列表",
"RESET_FILTERS": "重置筛选",
"REMOVED": "已删除“{{name}}”",
"REMOVED_SWITCHED": "已删除“{{name}}”,已切换到“{{next}}”",
"REMOVE_FAILED": "无法删除“{{name}}”"
},
"CONTEXT": {
"CATEGORIES": "分类",
+14 -2
View File
@@ -260,7 +260,10 @@
"NAME_DESC": "名稱(Z-A)",
"NEWEST": "新增日期(最新優先)",
"OLDEST": "新增日期(最舊優先)",
"CUSTOM_ORDER": "自訂順序(拖放)"
"CUSTOM_ORDER": "自訂順序(拖放)",
"UPDATED": "最近更新",
"CHANNELS": "頻道數量",
"FAILING_FIRST": "無回應的優先"
},
"PLAYLIST_TYPES": {
"M3U": "M3U(本機、網址、文字)",
@@ -1915,7 +1918,16 @@
"EDIT": "編輯…",
"COPY_URL": "複製 URL",
"OPEN": "開啟"
}
},
"STATUS": "狀態",
"STATUS_ANY": "全部",
"STATUS_OK": "有回應",
"STATUS_BAD": "無回應",
"NO_MATCHES": "沒有符合這些篩選條件的播放清單",
"RESET_FILTERS": "重設篩選",
"REMOVED": "已移除「{{name}}」",
"REMOVED_SWITCHED": "已移除「{{name}}」,已切換到「{{next}}」",
"REMOVE_FAILED": "無法移除「{{name}}」"
},
"CONTEXT": {
"CATEGORIES": "類別",
+1
View File
@@ -10,3 +10,4 @@ export * from './lib/xtream-refresh-flow.service';
export * from './lib/source-health/source-cleanup-dialog.component';
export * from './lib/source-row/source-row.component';
export * from './lib/source-row/source-tile.component';
export * from './lib/source-removal/source-removal.service';
@@ -1,45 +1,52 @@
@if (playlistsData(); as data) {
@if (data.playlists.length === 0) {
@if (data.totalCount === 0) {
<!-- Welcome state: no playlists at all -->
<app-empty-state
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
} @else {
<!-- No results state: search/filter returned nothing -->
<app-empty-state [type]="'no-results'" />
}
} @else {
<div
class="playlists-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSortActive()"
(cdkDropListDropped)="drop($event, data.playlists)"
>
@for (
item of data.playlists;
track item._id;
let last = $last
) {
<app-playlist-item
[item]="item"
[isSelected]="activePlaylistId() === item._id"
[showActions]="!sidebarMode()"
[isDraggable]="isCustomSortActive()"
[isRefreshing]="isRefreshPending(item._id)"
[isDeleting]="isDeletePending(item._id)"
[busyMessage]="getBusyMessage(item)"
[busyProgress]="getBusyProgress(item._id)"
[canCancelBusyAction]="canCancelBusyOperation(item)"
(editPlaylistClicked)="openInfoDialog($event)"
(playlistClicked)="getPlaylist(item)"
(refreshClicked)="refreshPlaylist($event)"
(removeClicked)="removeClicked(item)"
(cancelBusyActionClicked)="cancelBusyOperation(item)"
/>
}
</div>
@if (playlists().length === 0) {
@if (allPlaylistsLoaded()) {
<app-empty-state
[type]="'welcome-sources'"
[showElectronOnlyValueProps]="isElectron"
(addPlaylistClicked)="onAddPlaylist($event)"
/>
}
} @else if (visiblePlaylists().length === 0) {
<div class="sources-empty no-results-state" role="status">
<p class="sources-empty__message">
{{ 'WORKSPACE.SOURCES.NO_MATCHES' | translate }}
</p>
@if (hasFilters()) {
<button
mat-button
type="button"
class="sources-empty__reset"
(click)="resetFilters()"
>
<mat-icon>filter_alt_off</mat-icon>
{{ 'WORKSPACE.SOURCES.RESET_FILTERS' | translate }}
</button>
}
</div>
} @else {
<div
class="sources-list"
cdkDropList
[cdkDropListDisabled]="!isCustomSort()"
(cdkDropListDropped)="drop($event)"
>
@for (item of visiblePlaylists(); track item._id) {
<app-source-row
[playlist]="item"
[current]="activePlaylistId() === item._id"
[draggable]="isCustomSort()"
[canRefresh]="refresh.canRefresh(item)"
[refreshing]="refresh.isRefreshing(item._id)"
[deleting]="removal.progress.isDeleting(item._id)"
[busyMessage]="busyMessage(item)"
[busyProgress]="busyProgress(item)"
[canCancelBusy]="canCancelBusy(item)"
(activated)="open(item)"
(refreshRequested)="refresh.refresh(item)"
(editRequested)="edit(item)"
(removeRequested)="remove(item)"
(cancelBusyRequested)="cancelBusy(item)"
/>
}
</div>
}
@@ -1,7 +1,8 @@
@use '../../../../../../ui/styles/app-controls' as controls;
:host {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
min-height: 0;
@@ -15,35 +16,40 @@
min-height: 0;
}
.playlists-list {
padding: 0 6px;
.sources-list {
display: flex;
flex-direction: column;
width: 100%;
overflow-x: hidden;
box-sizing: border-box;
}
#skeleton-container {
padding-top: 10px;
width: 100%;
.skeleton-item {
display: flex;
padding: 0 20px;
height: 72px;
.text {
flex: 1;
align-self: center;
padding-left: 6px;
flex-direction: column;
display: flex;
}
}
// A 1px line between rows, hidden around the hovered row so its fill
// reads as one shape.
app-source-row + app-source-row {
border-top: 1px solid var(--app-widget-header-border);
}
@media only screen and (max-width: 480px) {
.favorites-icon {
padding-left: 0 !important;
display: none;
}
app-source-row:hover,
app-source-row:hover + app-source-row,
app-source-row:focus-within,
app-source-row:focus-within + app-source-row {
border-top-color: transparent;
}
.sources-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 64px 16px;
text-align: center;
}
.sources-empty__message {
margin: 0;
font-size: 13px;
color: var(--app-body-color);
}
.sources-empty__reset {
@include controls.ghost-button(sm);
}
@@ -1,742 +1,225 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject } from 'rxjs';
import {
PlaylistActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
PlaylistContextFacade,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
DataService,
DbOperationEvent,
PlaybackPositionService,
PlaylistDeleteActionService,
PlaylistRefreshService,
RuntimeCapabilitiesService,
SortBy,
SortOptions,
SortOrder,
SortService,
} from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PLAYLIST_UPDATE,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { PlaylistMeta, SourceStatus } from '@iptvnator/shared/interfaces';
import { SourceRemovalService } from '../source-removal/source-removal.service';
import { RecentPlaylistsComponent } from './recent-playlists.component';
import { SourceListRefreshService } from './source-list-refresh.service';
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
const source = (
id: string,
fields: Partial<PlaylistMeta> = {}
): PlaylistMeta => ({
_id: id,
title: id,
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
...fields,
});
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
const M3U = source('m3u', { url: 'https://cdn.example/list.m3u', position: 0 });
const XTREAM = source('xtream', {
serverUrl: 'http://x.example',
username: 'u',
password: 'p',
position: 1,
});
const FILE = source('file', { position: 2 });
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAbortError(): Error {
const error = new Error('Cancelled');
error.name = 'AbortError';
return error;
}
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('RecentPlaylistsComponent busy state', () => {
describe('RecentPlaylistsComponent', () => {
let fixture: ComponentFixture<RecentPlaylistsComponent>;
let component: RecentPlaylistsComponent;
let databaseService: {
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let playlistRefreshService: {
cancelRefresh: jest.Mock;
refreshPlaylist: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: MockStore;
let sort: BehaviorSubject<SortOptions>;
let statuses: Map<string, SourceStatus>;
let pending: ReturnType<typeof signal<ReadonlySet<string>>>;
let removal: {
pendingIds: typeof pending;
remove: jest.Mock;
isPending: jest.Mock;
progress: object;
};
let viewState: SourcesViewStateService;
beforeEach(async () => {
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deletePlaylist: jest.fn(),
deleteXtreamPlaylistContent: jest.fn(),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
beforeEach(() => {
localStorage.clear();
sort = new BehaviorSubject<SortOptions>({
by: SortBy.CUSTOM,
order: SortOrder.ASC,
});
statuses = new Map([
['m3u', { reachability: 'ok' }],
['xtream', { reachability: 'bad' }],
]);
pending = signal<ReadonlySet<string>>(new Set());
removal = {
pendingIds: pending,
remove: jest.fn(),
isPending: jest.fn((id: string) => pending().has(id)),
progress: {
isDeleting: () => false,
message: () => '',
progress: () => null,
canCancel: () => false,
},
};
dialogService = {
openConfirmDialog: jest.fn(),
};
dataService = {
sendIpcEvent: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
playlistRefreshService = {
cancelRefresh: jest.fn().mockResolvedValue(undefined),
refreshPlaylist: jest.fn().mockResolvedValue({
id: 'playlist-1',
items: [],
}),
};
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
router = {
navigate: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [RecentPlaylistsComponent],
TestBed.configureTestingModule({
imports: [RecentPlaylistsComponent, TranslateModule.forRoot()],
providers: [
provideMockStore({
selectors: [
{ selector: selectPlaylistsLoadingFlag, value: true },
{
selector: selectAllPlaylistsMeta,
value: [],
},
{
selector: selectActiveTypeFilters,
value: ['xtream', 'm3u', 'stalker'],
value: [M3U, XTREAM, FILE],
},
],
}),
{
provide: DatabaseService,
useValue: databaseService,
provide: SortService,
useValue: {
getSortOptions: () => sort.asObservable(),
sortPlaylists: new SortService().sortPlaylists,
},
},
{
provide: DialogService,
useValue: dialogService,
provide: SourceStatusService,
useValue: {
supportsStatusFilter: true,
statusOf: (p: PlaylistMeta) =>
statuses.get(p._id) ?? null,
tracks: (p: PlaylistMeta) => statuses.has(p._id),
ensureChecked: jest.fn(),
recheck: jest.fn(),
},
},
{ provide: SourceRemovalService, useValue: removal },
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
provide: PlaylistContextFacade,
useValue: { resolvedPlaylistId: signal('xtream') },
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: signal<string | null>(null),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Router,
useValue: router,
},
{
provide: SortService,
useValue: {
getSortOptions: jest.fn(() =>
of({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
})
),
sortPlaylists: jest.fn(
(playlists: PlaylistMeta[]) => playlists
),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
},
},
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: Router, useValue: { navigate: jest.fn() } },
],
}).compileComponents();
component = TestBed.createComponent(
RecentPlaylistsComponent
).componentInstance;
});
TestBed.overrideComponent(RecentPlaylistsComponent, {
set: {
providers: [
{
provide: SourceListRefreshService,
useValue: {
isRefreshing: () => false,
canRefresh: () => true,
message: () => '',
progress: () => null,
canCancel: () => false,
},
},
],
},
});
fixture = TestBed.createComponent(RecentPlaylistsComponent);
component = fixture.componentInstance;
store = TestBed.inject(MockStore);
viewState = TestBed.inject(SourcesViewStateService);
jest.spyOn(store, 'dispatch');
fixture.detectChanges();
});
afterEach(() => {
setPerformanceHook(null);
jest.restoreAllMocks();
localStorage.clear();
});
const ids = () => component.visiblePlaylists().map((p) => p._id);
it('tracks delete progress and clears the busy row after completion', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const item = createPlaylistMeta({ _id: 'playlist-delete-1' });
const deletion = createDeferred<boolean>();
playlistDeleteAction.deletePlaylist.mockImplementation(
(
_playlist: PlaylistMeta,
options?: {
onEvent?: (event: DbOperationEvent) => void;
}
) => {
options?.onEvent?.({
operation: 'delete-playlist',
operationId: 'playlist-delete-op',
status: 'progress',
phase: 'deleting-content',
current: 25,
total: 100,
});
return deletion.promise;
}
);
const removalPromise = component.removePlaylist(item);
expect(component.isDeletePending(item._id)).toBe(true);
expect(component.getBusyMessage(item)).toBe(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
expect(component.getBusyProgress(item._id)).toBe(25);
expect(component.canCancelBusyOperation(item)).toBe(true);
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, {
onEvent: expect.any(Function),
});
await component.cancelBusyOperation(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'playlist-delete-op'
);
deletion.resolve(true);
await removalPromise;
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
undefined,
{ duration: 2000 }
);
it('lists every source as a row in the chosen order', () => {
expect(ids()).toEqual(['m3u', 'xtream', 'file']);
expect(
events.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
items: metadata?.items,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
items: 1,
outcome: undefined,
phase: 'store.xtream-delete-row',
},
{
boundary: 'end',
items: 1,
outcome: 'success',
phase: 'store.xtream-delete-row',
},
]);
(fixture.nativeElement as HTMLElement).querySelectorAll(
'app-source-row'
).length
).toBe(3);
});
it('delegates playlist deletion and updates local UI state after success', async () => {
const events: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => events.push(event));
const item = createPlaylistMeta({
_id: 'pwa-playlist-1',
serverUrl: undefined,
username: undefined,
password: undefined,
url: 'https://example.com/playlist.m3u',
});
playlistDeleteAction.deletePlaylist.mockResolvedValue(true);
it('applies the type and status filters together', () => {
viewState.setType('m3u');
expect(ids()).toEqual(['m3u', 'file']);
viewState.setStatus('ok');
expect(ids()).toEqual(['m3u']);
viewState.setType('all');
viewState.setStatus('bad');
expect(ids()).toEqual(['xtream']);
});
await component.removePlaylist(item);
it('offers to reset filters when nothing matches', () => {
viewState.setType('stalker');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('WORKSPACE.SOURCES.NO_MATCHES');
element
.querySelector<HTMLButtonElement>('.sources-empty__reset')
?.click();
expect(viewState.filter()).toEqual({ type: 'all', status: 'all' });
});
expect(playlistDeleteAction.deletePlaylist).toHaveBeenCalledWith(item, {
onEvent: expect.any(Function),
it('puts failing sources first on request', () => {
sort.next({
by: SortBy.NAME,
order: SortOrder.ASC,
failingFirst: true,
});
expect(databaseService.deletePlaylist).not.toHaveBeenCalled();
expect(ids()).toEqual(['xtream', 'file', 'm3u']);
});
it('hides sources whose delete is pending', () => {
pending.set(new Set(['m3u']));
expect(ids()).toEqual(['xtream', 'file']);
});
it('hands the active source over to the next row on delete', () => {
component.remove(XTREAM);
expect(removal.remove).toHaveBeenCalledWith(XTREAM, FILE);
component.remove(FILE);
expect(removal.remove).toHaveBeenLastCalledWith(FILE, XTREAM);
});
it('reorders the shown rows without moving the hidden ones', () => {
viewState.setType('m3u');
component.drop({ previousIndex: 1, currentIndex: 0 } as never);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({ playlistId: item._id })
);
expect(snackBar.open).toHaveBeenCalledWith(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS',
undefined,
{ duration: 2000 }
);
expect(events).toEqual([]);
});
it('records dispatch failure and still clears delete-row busy state', async () => {
const events: RendererPerformancePhaseEvent[] = [];
const item = createPlaylistMeta({ _id: 'dispatch-failure' });
const dispatchError = new Error('dispatch failed');
setPerformanceHook((event) => events.push(event));
(store.dispatch as jest.Mock).mockImplementation(() => {
throw dispatchError;
});
await expect(component.removePlaylist(item)).rejects.toBe(
dispatchError
);
expect(component.isDeletePending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(
events.map(({ boundary, outcome, phase }) => ({
boundary,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
outcome: undefined,
phase: 'store.xtream-delete-row',
},
{
boundary: 'end',
outcome: 'error',
phase: 'store.xtream-delete-row',
},
]);
});
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
const refresh = createDeferred<{
success: boolean;
favorites: Array<{ xtreamId: number; contentType: string }>;
recentlyViewed: Array<{
xtreamId: number;
contentType: string;
viewedAt: string;
}>;
hiddenCategories: Array<{
xtreamId: number;
categoryType: string;
}>;
}>();
let confirmPromise: Promise<void> | undefined;
// The shared flow awaits the connectivity-guard reset before it starts
// deleting, so the delete is several ticks away and counting them is
// fragile. The mock fires onEvent synchronously and the component
// applies it synchronously, so resolving this deferred inside the mock
// is a deterministic "the busy row has been updated" signal.
const workerEventDelivered = createDeferred<void>();
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
databaseService.deleteXtreamPlaylistContent.mockImplementation(
(
_playlistId: string,
options?: {
onEvent?: (event: DbOperationEvent) => void;
operationId?: string;
}
) => {
options?.onEvent?.({
operation: 'delete-xtream-content',
operationId: 'xtream-refresh-op',
status: 'progress',
phase: 'collecting-user-data',
current: 1,
total: 4,
});
workerEventDelivered.resolve();
return refresh.promise;
}
);
component.refreshXtreamPlaylist(item);
await workerEventDelivered.promise;
expect(component.isRefreshPending(item._id)).toBe(true);
expect(component.getBusyMessage(item)).toBe(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
expect(component.getBusyProgress(item._id)).toBe(25);
expect(component.canCancelBusyOperation(item)).toBe(true);
await component.cancelBusyOperation(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'xtream-refresh-op'
);
refresh.reject(createAbortError());
await confirmPromise;
expect(component.isRefreshPending(item._id)).toBe(false);
expect(component.getBusyProgress(item._id)).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const performanceEvents: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => {
performanceEvents.push(event);
executionOrder.push(event.boundary);
});
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (
this: Storage,
key: string,
value: string
) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
});
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
width: '400px',
PlaylistActions.updatePlaylistPositions({
positionUpdates: [
{ id: 'file', changes: { position: 0 } },
{ id: 'xtream', changes: { position: 1 } },
{ id: 'm3u', changes: { position: 2 } },
],
})
);
expect(
databaseService.updateXtreamPlaylistDetails
).toHaveBeenCalledWith({
id: item._id,
updateDate: 1712145600000,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
expect.any(String)
);
const persistedRestoreState = JSON.parse(
setItemSpy.mock.calls.find(
([key]) => key === `xtream-restore-${item._id}`
)?.[1] ?? 'null'
);
expect(persistedRestoreState).toEqual({
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
playbackPositions: [],
});
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual([
'setItem',
'start',
'dispatch',
'end',
'navigate',
]);
expect(performanceEvents).toHaveLength(2);
expect(performanceEvents[0]?.phaseId).toBe(
performanceEvents[1]?.phaseId
);
expect(
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
metadata,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
metadata: { items: 1 },
outcome: undefined,
phase: 'store.xtream-refresh-meta',
},
{
boundary: 'end',
metadata: { items: 1 },
outcome: 'success',
phase: 'store.xtream-refresh-meta',
},
]);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
component.refreshPlaylist(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
});
});
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
});
component.refreshPlaylist(item);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
runtime.supportsPlaylistRefresh = false;
const lateComponent = TestBed.createComponent(
RecentPlaylistsComponent
).componentInstance;
runtime.supportsPlaylistRefresh = true;
const item = createPlaylistMeta({
_id: 'playlist-m3u-2',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
lateComponent.refreshPlaylist(item);
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
{
operationId: 'playlist-refresh-op',
playlistId: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
filePath: item.filePath,
},
{
onEvent: expect.any(Function),
}
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
await Promise.resolve();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylist({
playlist: expect.objectContaining({
_id: item._id,
}),
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
// Second, independent implementation of the destructive refresh (the
// Workspace sources page). Same consequence as the shared action: the
// catalog is already gone by the time an open guard fast-fails the
// re-import bootstrap.
const item = {
_id: 'xtream-guard',
title: 'Guarded Xtream',
serverUrl: 'http://panel.example:8080',
} as PlaylistMeta;
const order: string[] = [];
let confirmPromise: Promise<void> | undefined;
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
order.push('deleteXtreamPlaylistContent');
return Promise.resolve({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
sourcePins: [],
});
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
component.refreshXtreamPlaylist(item);
await confirmPromise;
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
expect(order).toContain('deleteXtreamPlaylistContent');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: item.serverUrl }
);
});
});
@@ -6,97 +6,76 @@ import {
import {
ChangeDetectionStrategy,
Component,
effect,
computed,
inject,
input,
output,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatIcon } from '@angular/material/icon';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { TranslatePipe } from '@ngx-translate/core';
import {
PlaylistActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
filterSources,
orderFailingFirst,
PlaylistContextFacade,
SourceBucketOf,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistDeleteActionService,
PlaylistRefreshService,
RuntimeCapabilitiesService,
SortBy,
SortService,
} from '@iptvnator/services';
import {
foldSearchText,
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from '@iptvnator/shared/interfaces';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import {
XtreamRefreshFlowService,
type XtreamRefreshProgressReporter,
} from '../xtream-refresh-flow.service';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistMeta, sourceStatusBucket } from '@iptvnator/shared/interfaces';
import type { PlaylistType } from '../add-playlist-menu/playlist-type';
import { SourceRemovalService } from '../source-removal/source-removal.service';
import { SourceRowComponent } from '../source-row/source-row.component';
import { EmptyStateComponent } from './empty-state/empty-state.component';
import { PlaylistInfoComponent } from './playlist-info/playlist-info.component';
import { PlaylistItemComponent } from './playlist-item/playlist-item.component';
type PlaylistBusyOperation = {
current?: number;
operation: string;
operationId?: string;
phase?: string;
status: DbOperationEvent['status'];
total?: number;
};
import { SourceListRefreshService } from './source-list-refresh.service';
/**
* The Sources page list: every source that passes the side list's type and
* status filters and the header search, in the chosen sort order, as
* `app-source-row`s. Sources being deleted (Undo pending) are left out.
*/
@Component({
selector: 'app-recent-playlists',
templateUrl: './recent-playlists.component.html',
styleUrls: ['./recent-playlists.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [SourceListRefreshService],
imports: [
DragDropModule,
EmptyStateComponent,
MatInputModule,
PlaylistItemComponent,
MatButton,
MatIcon,
SourceRowComponent,
TranslatePipe,
],
})
export class RecentPlaylistsComponent {
private readonly databaseService = inject(DatabaseService);
private readonly dialog = inject(MatDialog);
private readonly dialogService = inject(DialogService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly router = inject(Router);
private readonly snackBar = inject(MatSnackBar);
private readonly sortService = inject(SortService);
private readonly store = inject(Store);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly playlistDeleteAction = inject(PlaylistDeleteActionService);
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
private readonly statusService = inject(SourceStatusService);
private readonly viewState = inject(SourcesViewStateService);
readonly removal = inject(SourceRemovalService);
readonly refresh = inject(SourceListRefreshService);
readonly sidebarMode = input(false);
readonly searchQueryInput = input<string>('');
readonly playlistClicked = output<string>();
readonly addPlaylistClicked = output<PlaylistType | undefined>();
@@ -105,115 +84,77 @@ export class RecentPlaylistsComponent {
return this.runtime.isElectron;
}
get supportsPlaylistRefresh(): boolean {
return this.runtime.supportsPlaylistRefresh;
}
get supportsXtreamSqliteDataSource(): boolean {
return this.runtime.supportsXtreamSqliteDataSource;
}
readonly allPlaylistsLoaded = this.store.selectSignal(
selectPlaylistsLoadingFlag
);
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
private readonly sortOptions = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
readonly isCustomSort = computed(
() => this.sortOptions().by === SortBy.CUSTOM
);
readonly isCustomSortActive = () =>
this.currentSortOptions().by === SortBy.CUSTOM;
readonly searchQuery = new BehaviorSubject<string>('');
readonly pendingDeletionIds = signal<Set<string>>(new Set());
readonly pendingRefreshIds = signal<Set<string>>(new Set());
readonly busyOperations = signal<Map<string, PlaylistBusyOperation>>(
new Map()
);
constructor() {
// Update searchQuery when input changes
effect(() => {
this.searchQuery.next(this.searchQueryInput());
});
}
readonly playlistsData$ = combineLatest([
this.store.select(selectAllPlaylistsMeta),
this.searchQuery,
this.store.select(selectActiveTypeFilters),
this.sortService.getSortOptions(),
]).pipe(
map(([playlists, searchQuery, filters, sortOptions]) => {
const filteredPlaylists = playlists
.filter((item) => {
const isStalkerFilter =
item.macAddress && filters.includes('stalker');
const isXtreamFilter =
item.username &&
item.password &&
item.serverUrl &&
filters.includes('xtream');
const isM3uFilter =
!item.username &&
!item.password &&
!item.serverUrl &&
!item.macAddress &&
filters.includes('m3u');
return (
(isStalkerFilter && filters.includes('stalker')) ||
(isXtreamFilter && filters.includes('xtream')) ||
(isM3uFilter && filters.includes('m3u'))
);
})
.filter((item) =>
foldSearchText(item.title || '').includes(
foldSearchText(searchQuery)
)
);
// Apply sorting using the SortService
const sortedPlaylists = this.sortService.sortPlaylists(
filteredPlaylists,
sortOptions
);
return {
playlists: sortedPlaylists,
totalCount: playlists.length,
};
})
);
readonly playlistsData = toSignal(this.playlistsData$, {
initialValue: {
playlists: [] as PlaylistMeta[],
totalCount: 0,
},
readonly hasFilters = computed(() => {
const filter = this.viewState.filter();
return filter.type !== 'all' || filter.status !== 'all';
});
/**
* Opens the details dialog with the information about the provided playlist
* @param data selected playlist
*/
openInfoDialog(data: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, {
data,
});
/** Every source except those whose delete is pending. */
readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter(
(playlist) => !pending.has(playlist._id)
);
});
readonly visiblePlaylists = computed(() => {
const bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
const options = this.sortOptions();
const filter = this.viewState.filter();
const filtered = filterSources(
this.playlists(),
this.statusService.supportsStatusFilter
? filter
: { ...filter, status: 'all' },
this.searchQueryInput(),
bucketOf
);
const sorted = this.sortService.sortPlaylists(filtered, options);
return options.failingFirst && options.by !== SortBy.CUSTOM
? orderFailingFirst(sorted, bucketOf)
: sorted;
});
resetFilters(): void {
this.viewState.reset();
}
/**
* Drop event handler - applies the new sort order to the playlists array
* @param event drop event
* Applies a drag in custom order to the whole library: the shown rows
* trade places among the slots they occupy, and sources a filter hides
* keep theirs, so every position stays unique.
*/
drop(event: CdkDragDrop<PlaylistMeta[]>, playlists: PlaylistMeta[]): void {
moveItemInArray(playlists, event.previousIndex, event.currentIndex);
drop(event: CdkDragDrop<PlaylistMeta[]>): void {
const visible = [...this.visiblePlaylists()];
moveItemInArray(visible, event.previousIndex, event.currentIndex);
const visibleIds = new Set(visible.map((item) => item._id));
const ordered = this.sortService.sortPlaylists(
this.allPlaylists(),
this.sortOptions()
);
let shown = 0;
const merged = ordered.map((item) =>
visibleIds.has(item._id) ? visible[shown++] : item
);
this.store.dispatch(
PlaylistActions.updatePlaylistPositions({
positionUpdates: playlists.map((item, index) => ({
positionUpdates: merged.map((item, index) => ({
id: item._id,
changes: { position: index },
})),
@@ -221,475 +162,73 @@ export class RecentPlaylistsComponent {
);
}
onAddPlaylist(type?: PlaylistType) {
open(playlist: PlaylistMeta): void {
if (playlist.serverUrl) {
void this.router.navigate(['/workspace', 'xtreams', playlist._id]);
} else if (playlist.macAddress) {
void this.router.navigate(['/workspace', 'stalker', playlist._id]);
} else {
void this.router.navigate([
'/workspace',
'playlists',
playlist._id,
]);
this.playlistClicked.emit(playlist._id);
}
}
edit(playlist: PlaylistMeta): void {
this.dialog.open(PlaylistInfoComponent, { data: playlist });
}
/** Deletes with Undo; the active source hands over to its neighbour. */
remove(playlist: PlaylistMeta): void {
if (this.refresh.isRefreshing(playlist._id)) {
return;
}
const rows = this.visiblePlaylists();
const index = rows.findIndex((item) => item._id === playlist._id);
const next = rows[index + 1] ?? rows[index - 1] ?? null;
this.removal.remove(playlist, next);
}
busyMessage(item: PlaylistMeta): string {
return (
this.removal.progress.message(item._id) ||
this.refresh.message(item)
);
}
busyProgress(item: PlaylistMeta): number | null {
return this.removal.progress.isDeleting(item._id)
? this.removal.progress.progress(item._id)
: this.refresh.progress(item._id);
}
canCancelBusy(item: PlaylistMeta): boolean {
return (
this.removal.progress.canCancel(item._id) ||
this.refresh.canCancel(item)
);
}
cancelBusy(item: PlaylistMeta): void {
if (this.removal.progress.isDeleting(item._id)) {
void this.removal.progress.cancel(item._id);
} else {
void this.refresh.cancel(item);
}
}
onAddPlaylist(type?: PlaylistType): void {
this.addPlaylistClicked.emit(type);
}
getPlaylist(playlistMeta: PlaylistMeta): void {
if (playlistMeta.serverUrl) {
this.router.navigate(['/workspace', 'xtreams', playlistMeta._id]);
} else if (playlistMeta.macAddress) {
this.router.navigate(['/workspace', 'stalker', playlistMeta._id]);
} else {
this.router.navigate(['/workspace', 'playlists', playlistMeta._id]);
this.playlistClicked.emit(playlistMeta._id);
}
}
/**
* Triggers on remove click
* @param playlistId playlist id to remove
*/
removeClicked(item: PlaylistMeta): void {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
confirmLabel: this.translate.instant('HOME.PLAYLISTS.REMOVE'),
tone: 'destructive',
onConfirm: () => {
this.removePlaylist(item);
},
});
}
/**
* Removes the provided playlist from the database
* @param playlistId playlist id to remove
*/
async removePlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingDeletion(item._id, true);
try {
const deleted = await this.playlistDeleteAction.deletePlaylist(
item,
{
onEvent: (event) =>
this.updateBusyOperation(item._id, event),
}
);
if (deleted) {
if (item.serverUrl) {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
() =>
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
),
() => ({ items: 1 })
);
} else {
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: item._id,
})
);
}
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.SUCCESS'
),
undefined,
{
duration: 2000,
}
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingDeletion(item._id, false);
}
}
/**
* Sends an IPC event with the playlist details to the main process to trigger the refresh operation
* @param item playlist to update
*/
refreshPlaylist(item: PlaylistMeta) {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
if (item.serverUrl && this.supportsXtreamSqliteDataSource) {
// For Xtream playlists, delete and re-import
this.refreshXtreamPlaylist(item);
} else if (
this.supportsPlaylistRefresh &&
(item.url || item.filePath)
) {
void this.refreshM3uPlaylist(item);
} else if (item.url) {
// Browser/PWA URL refresh uses the PWA data service path.
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
}
}
/**
* Refresh Xtream playlist by deleting all data and re-importing from remote
* @param item Xtream playlist to refresh
*/
refreshXtreamPlaylist(item: PlaylistMeta): void {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.xtreamRefreshFlow.confirmAndRefresh(
item,
this.xtreamRefreshReporter
);
}
/**
* Reports the shared destructive refresh into this page's per-row busy
* indicators, which are keyed by playlist and shared with deletion — hence
* the same `updateBusyOperation`/`clearBusyOperation` pair used there.
*/
private readonly xtreamRefreshReporter: XtreamRefreshProgressReporter = {
isBusy: (playlistId) =>
this.isDeletePending(playlistId) ||
this.isRefreshPending(playlistId),
begin: ({ playlistId }) => this.setPendingRefresh(playlistId, true),
report: ({ playlistId }, event) =>
this.updateBusyOperation(playlistId, event),
end: ({ playlistId }) => {
this.clearBusyOperation(playlistId);
this.setPendingRefresh(playlistId, false);
},
};
private async refreshM3uPlaylist(item: PlaylistMeta): Promise<void> {
if (this.isDeletePending(item._id) || this.isRefreshPending(item._id)) {
return;
}
this.setPendingRefresh(item._id, true);
const operationId =
this.databaseService.createOperationId('playlist-refresh');
try {
const refreshedPlaylist =
await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent
? { userAgent: item.userAgent }
: {}),
filePath: item.filePath,
},
{
onEvent: (event) =>
this.updateBusyOperation(
item._id,
this.toPlaylistRefreshBusyEvent(event)
),
}
);
this.updateBusyOperation(item._id, {
operationId,
operation: 'playlist-refresh',
playlistId: item._id,
phase: 'saving',
status: 'progress',
});
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: {
...refreshedPlaylist,
_id: item._id,
},
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
this.clearBusyOperation(item._id);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.getPlaylistRefreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clearBusyOperation(item._id);
this.setPendingRefresh(item._id, false);
}
}
isDeletePending(playlistId: string): boolean {
return this.pendingDeletionIds().has(playlistId);
return this.removal.isPending(playlistId);
}
isRefreshPending(playlistId: string): boolean {
return this.pendingRefreshIds().has(playlistId);
}
getBusyMessage(item: PlaylistMeta): string {
const operation = this.busyOperations().get(item._id);
if (!operation) {
return '';
}
switch (operation.operation) {
case 'delete-playlist':
return this.translateDeletePhase(operation.phase);
case 'delete-xtream-content':
return this.translateRefreshPhase(operation.phase);
case 'playlist-refresh':
return this.translatePlaylistRefreshPhase(operation.phase);
default:
return '';
}
}
getBusyProgress(playlistId: string): number | null {
const operation = this.busyOperations().get(playlistId);
if (
!operation ||
operation.current == null ||
operation.total == null ||
operation.total <= 0
) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancelBusyOperation(item: PlaylistMeta): boolean {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return false;
}
if (operation.operation === 'playlist-refresh') {
return true;
}
return Boolean(item.serverUrl);
}
async cancelBusyOperation(item: PlaylistMeta): Promise<void> {
const operation = this.busyOperations().get(item._id);
if (!operation?.operationId) {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(
operation.operationId
);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
private setPendingDeletion(playlistId: string, isPending: boolean): void {
this.pendingDeletionIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private setPendingRefresh(playlistId: string, isPending: boolean): void {
this.pendingRefreshIds.update((current) => {
const next = new Set(current);
if (isPending) {
next.add(playlistId);
} else {
next.delete(playlistId);
}
return next;
});
}
private updateBusyOperation(
playlistId: string,
event: DbOperationEvent
): void {
this.busyOperations.update((current) => {
const next = new Map(current);
if (
event.status === 'completed' ||
event.status === 'cancelled' ||
event.status === 'error'
) {
next.delete(playlistId);
return next;
}
next.set(playlistId, {
operation: event.operation,
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
status: event.status,
});
return next;
});
}
private clearBusyOperation(playlistId: string): void {
this.busyOperations.update((current) => {
const next = new Map(current);
next.delete(playlistId);
return next;
});
}
private toPlaylistRefreshBusyEvent(
event: PlaylistRefreshEvent
): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private translateDeletePhase(phase?: string): string {
switch (phase) {
case 'deleting-favorites':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES'
);
case 'deleting-recently-viewed':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT'
);
case 'deleting-playback-positions':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS'
);
case 'deleting-downloads':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES'
);
case 'deleting-playlist':
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
}
}
private translateRefreshPhase(phase?: string): string {
switch (phase) {
case 'collecting-user-data':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
case 'deleting-content':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT'
);
case 'deleting-categories':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES'
);
default:
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
);
}
}
private translatePlaylistRefreshPhase(phase?: string): string {
switch (phase) {
case 'fetching':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_FETCHING'
);
case 'reading-file':
return this.translate.instant(
'HOME.PLAYLISTS.REFRESH_READING_FILE'
);
case 'parsing':
return this.translate.instant('HOME.PLAYLISTS.REFRESH_PARSING');
case 'saving':
return this.translate.instant('HOME.PLAYLISTS.REFRESH_SAVING');
default:
return this.translate.instant('HOME.PLAYLISTS.REFRESH');
}
}
private getPlaylistRefreshErrorMessage(
error: unknown,
item: PlaylistMeta
): string {
if (item.filePath) {
const message = String(
(error as { message?: string })?.message ?? error
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
return this.refresh.isRefreshing(playlistId);
}
}
@@ -0,0 +1,590 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PlaylistActions,
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { of } from 'rxjs';
import { DialogService } from '@iptvnator/ui/components';
import {
DatabaseService,
DataService,
DbOperationEvent,
PlaybackPositionService,
PlaylistDeleteActionService,
PlaylistRefreshService,
RuntimeCapabilitiesService,
SortBy,
SortOrder,
SortService,
} from '@iptvnator/services';
import {
CONNECTIVITY_GUARD_RESET,
PLAYLIST_UPDATE,
PlaylistMeta,
} from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { SourceListRefreshService } from './source-list-refresh.service';
const performanceHookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
function setPerformanceHook(
hook: ((event: RendererPerformancePhaseEvent) => void) | null
): void {
const target = globalThis as unknown as Record<symbol, unknown>;
if (hook === null) {
delete target[performanceHookSymbol];
} else {
target[performanceHookSymbol] = hook;
}
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createAbortError(): Error {
const error = new Error('Cancelled');
error.name = 'AbortError';
return error;
}
function createPlaylistMeta(
overrides: Partial<PlaylistMeta> = {}
): PlaylistMeta {
return {
_id: 'playlist-1',
title: 'Xtream Playlist',
count: 0,
importDate: new Date('2026-03-28T00:00:00.000Z').toISOString(),
autoRefresh: false,
serverUrl: 'http://localhost:8080',
username: 'demo',
password: 'secret',
...overrides,
} as PlaylistMeta;
}
describe('SourceListRefreshService', () => {
let component: SourceListRefreshService;
let databaseService: {
cancelOperation: jest.Mock;
createOperationId: jest.Mock;
deletePlaylist: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
};
let dialogService: {
openConfirmDialog: jest.Mock;
};
let dataService: {
sendIpcEvent: jest.Mock;
};
let playbackPositionService: {
getAllPlaybackPositions: jest.Mock;
};
let playlistDeleteAction: {
deletePlaylist: jest.Mock;
};
let playlistRefreshService: {
cancelRefresh: jest.Mock;
refreshPlaylist: jest.Mock;
};
let runtime: {
isElectron: boolean;
supportsPlaylistRefresh: boolean;
supportsXtreamSqliteDataSource: boolean;
};
let router: {
navigate: jest.Mock;
};
let snackBar: {
open: jest.Mock;
};
let store: MockStore;
beforeEach(async () => {
databaseService = {
cancelOperation: jest.fn().mockResolvedValue(true),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deletePlaylist: jest.fn(),
deleteXtreamPlaylistContent: jest.fn(),
updateXtreamPlaylistDetails: jest.fn().mockResolvedValue(undefined),
};
dialogService = {
openConfirmDialog: jest.fn(),
};
dataService = {
sendIpcEvent: jest.fn(),
};
playbackPositionService = {
getAllPlaybackPositions: jest.fn().mockResolvedValue([]),
};
playlistDeleteAction = {
deletePlaylist: jest.fn().mockResolvedValue(true),
};
playlistRefreshService = {
cancelRefresh: jest.fn().mockResolvedValue(undefined),
refreshPlaylist: jest.fn().mockResolvedValue({
id: 'playlist-1',
items: [],
}),
};
runtime = {
isElectron: true,
supportsPlaylistRefresh: true,
supportsXtreamSqliteDataSource: true,
};
router = {
navigate: jest.fn(),
};
snackBar = {
open: jest.fn(),
};
await TestBed.configureTestingModule({
providers: [
SourceListRefreshService,
provideMockStore({
selectors: [
{ selector: selectPlaylistsLoadingFlag, value: true },
{
selector: selectAllPlaylistsMeta,
value: [],
},
],
}),
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: DialogService,
useValue: dialogService,
},
{
provide: DataService,
useValue: dataService,
},
{
provide: PlaybackPositionService,
useValue: playbackPositionService,
},
{
provide: PlaylistDeleteActionService,
useValue: playlistDeleteAction,
},
{
provide: PlaylistRefreshService,
useValue: playlistRefreshService,
},
{
provide: RuntimeCapabilitiesService,
useValue: runtime,
},
{
provide: PlaylistContextFacade,
useValue: {
resolvedPlaylistId: signal<string | null>(null),
},
},
{
provide: MatDialog,
useValue: {
open: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: snackBar,
},
{
provide: Router,
useValue: router,
},
{
provide: SortService,
useValue: {
getSortOptions: jest.fn(() =>
of({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
})
),
sortPlaylists: jest.fn(
(playlists: PlaylistMeta[]) => playlists
),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
}).compileComponents();
component = TestBed.inject(SourceListRefreshService);
store = TestBed.inject(MockStore);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
setPerformanceHook(null);
jest.restoreAllMocks();
localStorage.clear();
});
it('tracks Xtream refresh progress and clears the busy row after abort', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-1' });
const refresh = createDeferred<{
success: boolean;
favorites: Array<{ xtreamId: number; contentType: string }>;
recentlyViewed: Array<{
xtreamId: number;
contentType: string;
viewedAt: string;
}>;
hiddenCategories: Array<{
xtreamId: number;
categoryType: string;
}>;
}>();
let confirmPromise: Promise<void> | undefined;
// The shared flow awaits the connectivity-guard reset before it starts
// deleting, so the delete is several ticks away and counting them is
// fragile. The mock fires onEvent synchronously and the component
// applies it synchronously, so resolving this deferred inside the mock
// is a deterministic "the busy row has been updated" signal.
const workerEventDelivered = createDeferred<void>();
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
databaseService.deleteXtreamPlaylistContent.mockImplementation(
(
_playlistId: string,
options?: {
onEvent?: (event: DbOperationEvent) => void;
operationId?: string;
}
) => {
options?.onEvent?.({
operation: 'delete-xtream-content',
operationId: 'xtream-refresh-op',
status: 'progress',
phase: 'collecting-user-data',
current: 1,
total: 4,
});
workerEventDelivered.resolve();
return refresh.promise;
}
);
component.refresh(item);
await workerEventDelivered.promise;
expect(component.isRefreshing(item._id)).toBe(true);
expect(component.message(item)).toBe(
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA'
);
expect(component.progress(item._id)).toBe(25);
expect(component.canCancel(item)).toBe(true);
await component.cancel(item);
expect(databaseService.cancelOperation).toHaveBeenCalledWith(
'xtream-refresh-op'
);
refresh.reject(createAbortError());
await confirmPromise;
expect(component.isRefreshing(item._id)).toBe(false);
expect(component.progress(item._id)).toBeNull();
expect(router.navigate).not.toHaveBeenCalled();
});
it('dispatches playlist meta with updateDate after Xtream refresh setup succeeds', async () => {
const item = createPlaylistMeta({ _id: 'playlist-refresh-success-1' });
let confirmPromise: Promise<void> | undefined;
const executionOrder: string[] = [];
const performanceEvents: RendererPerformancePhaseEvent[] = [];
setPerformanceHook((event) => {
performanceEvents.push(event);
executionOrder.push(event.boundary);
});
const dateNowSpy = jest
.spyOn(Date, 'now')
.mockReturnValue(1712145600000);
const originalSetItem = Storage.prototype.setItem;
const setItemSpy = jest
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(function (
this: Storage,
key: string,
value: string
) {
executionOrder.push('setItem');
return originalSetItem.call(this, key, value);
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
(store.dispatch as jest.Mock).mockImplementation((action: unknown) => {
executionOrder.push('dispatch');
return action;
});
router.navigate.mockImplementation((commands: unknown[]) => {
executionOrder.push('navigate');
return Promise.resolve(Boolean(commands));
});
databaseService.deleteXtreamPlaylistContent.mockResolvedValue({
success: true,
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
});
component.refresh(item);
await confirmPromise;
expect(dialogService.openConfirmDialog).toHaveBeenCalledWith(
expect.objectContaining({
width: '400px',
})
);
expect(
databaseService.updateXtreamPlaylistDetails
).toHaveBeenCalledWith({
id: item._id,
updateDate: 1712145600000,
});
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: { ...item, updateDate: 1712145600000 },
})
);
expect(setItemSpy).toHaveBeenCalledWith(
`xtream-restore-${item._id}`,
expect.any(String)
);
const persistedRestoreState = JSON.parse(
setItemSpy.mock.calls.find(
([key]) => key === `xtream-restore-${item._id}`
)?.[1] ?? 'null'
);
expect(persistedRestoreState).toEqual({
hiddenCategories: [{ xtreamId: 404, categoryType: 'live' }],
favorites: [
{ xtreamId: 101, contentType: 'live' },
{ xtreamId: 202, contentType: 'movie' },
],
recentlyViewed: [
{
xtreamId: 303,
contentType: 'series',
viewedAt: '2026-04-03T11:15:00.000Z',
},
],
playbackPositions: [],
});
expect(router.navigate).toHaveBeenCalledWith([
'/workspace',
'xtreams',
item._id,
]);
expect(executionOrder).toEqual([
'setItem',
'start',
'dispatch',
'end',
'navigate',
]);
expect(performanceEvents).toHaveLength(2);
expect(performanceEvents[0]?.phaseId).toBe(
performanceEvents[1]?.phaseId
);
expect(
performanceEvents.map(({ boundary, metadata, outcome, phase }) => ({
boundary,
metadata,
outcome,
phase,
}))
).toEqual([
{
boundary: 'start',
metadata: { items: 1 },
outcome: undefined,
phase: 'store.xtream-refresh-meta',
},
{
boundary: 'end',
metadata: { items: 1 },
outcome: 'success',
phase: 'store.xtream-refresh-meta',
},
]);
setItemSpy.mockRestore();
dateNowSpy.mockRestore();
});
it('uses the legacy IPC refresh flow for non-Xtream playlists', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: undefined,
url: 'https://example.com/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
component.refresh(item);
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
});
});
it('does not use legacy IPC refresh for file-backed playlists without the refresh bridge', () => {
runtime.supportsPlaylistRefresh = false;
const item = createPlaylistMeta({
_id: 'playlist-m3u-1',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
});
component.refresh(item);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(playlistRefreshService.refreshPlaylist).not.toHaveBeenCalled();
});
it('re-evaluates refresh bridge availability when refreshing local M3U playlists', async () => {
runtime.supportsPlaylistRefresh = false;
const lateComponent = TestBed.inject(SourceListRefreshService);
runtime.supportsPlaylistRefresh = true;
const item = createPlaylistMeta({
_id: 'playlist-m3u-2',
serverUrl: undefined,
username: undefined,
password: undefined,
filePath: '/tmp/test.m3u',
userAgent: 'IPTVnator-Test/1.0',
});
lateComponent.refresh(item);
expect(playlistRefreshService.refreshPlaylist).toHaveBeenCalledWith(
{
operationId: 'playlist-refresh-op',
playlistId: item._id,
title: item.title,
url: item.url,
userAgent: item.userAgent,
filePath: item.filePath,
},
{
onEvent: expect.any(Function),
}
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
await Promise.resolve();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylist({
playlist: expect.objectContaining({
_id: item._id,
}),
playlistId: item._id,
refreshEpg: true,
operationId: 'playlist-refresh-op',
})
);
});
it('clears the connectivity guard before deleting the cached Xtream catalog', async () => {
// Second, independent implementation of the destructive refresh (the
// Workspace sources page). Same consequence as the shared action: the
// catalog is already gone by the time an open guard fast-fails the
// re-import bootstrap.
const item = {
_id: 'xtream-guard',
title: 'Guarded Xtream',
serverUrl: 'http://panel.example:8080',
} as PlaylistMeta;
const order: string[] = [];
let confirmPromise: Promise<void> | undefined;
dataService.sendIpcEvent.mockImplementation((event: string) => {
order.push(`ipc:${event}`);
return Promise.resolve({ success: true });
});
databaseService.deleteXtreamPlaylistContent.mockImplementation(() => {
order.push('deleteXtreamPlaylistContent');
return Promise.resolve({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
sourcePins: [],
});
});
dialogService.openConfirmDialog.mockImplementation(
({ onConfirm }: { onConfirm?: () => Promise<void> }) => {
confirmPromise = onConfirm?.();
}
);
component.refresh(item);
await confirmPromise;
expect(order[0]).toBe(`ipc:${CONNECTIVITY_GUARD_RESET}`);
expect(order).toContain('deleteXtreamPlaylistContent');
expect(dataService.sendIpcEvent).toHaveBeenCalledWith(
CONNECTIVITY_GUARD_RESET,
{ url: item.serverUrl }
);
});
});
@@ -0,0 +1,293 @@
import { Injectable, inject, signal } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
DatabaseService,
DataService,
DbOperationEvent,
isDbAbortError,
PlaylistRefreshService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
PLAYLIST_UPDATE,
PlaylistMeta,
PlaylistRefreshEvent,
} from '@iptvnator/shared/interfaces';
import {
XtreamRefreshFlowService,
type XtreamRefreshProgressReporter,
} from '../xtream-refresh-flow.service';
interface BusyOperation {
current?: number;
operation: string;
operationId?: string;
phase?: string;
status: DbOperationEvent['status'];
total?: number;
}
const REFRESH_PHASE_KEYS: Record<string, string> = {
fetching: 'HOME.PLAYLISTS.REFRESH_FETCHING',
'reading-file': 'HOME.PLAYLISTS.REFRESH_READING_FILE',
parsing: 'HOME.PLAYLISTS.REFRESH_PARSING',
saving: 'HOME.PLAYLISTS.REFRESH_SAVING',
};
const XTREAM_PHASE_KEYS: Record<string, string> = {
'collecting-user-data':
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.COLLECTING_DATA',
'deleting-content': 'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CONTENT',
'deleting-categories':
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.DELETING_CATEGORIES',
};
/**
* The Sources page's per-row refresh: which rows refresh, their phase and
* progress, and cancelling. M3U refreshes in place, Xtream re-imports
* through the shared confirmation flow, and the browser build sends the
* URL refresh event. Provided by the list component.
*/
@Injectable()
export class SourceListRefreshService {
private readonly databaseService = inject(DatabaseService);
private readonly dataService = inject(DataService);
private readonly playlistRefreshService = inject(PlaylistRefreshService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly xtreamRefreshFlow = inject(XtreamRefreshFlowService);
private readonly refreshingIds = signal<ReadonlySet<string>>(new Set());
private readonly operations = signal<ReadonlyMap<string, BusyOperation>>(
new Map()
);
isRefreshing(id: string): boolean {
return this.refreshingIds().has(id);
}
canRefresh(item: PlaylistMeta): boolean {
if (item.serverUrl) {
return this.runtime.supportsXtreamSqliteDataSource;
}
return (
!!item.url ||
(!!item.filePath && this.runtime.supportsPlaylistRefresh)
);
}
refresh(item: PlaylistMeta): void {
if (this.isRefreshing(item._id)) {
return;
}
if (item.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
this.xtreamRefreshFlow.confirmAndRefresh(item, this.xtreamReporter);
} else if (
this.runtime.supportsPlaylistRefresh &&
(item.url || item.filePath)
) {
void this.refreshM3u(item);
} else if (item.url) {
this.dataService.sendIpcEvent(PLAYLIST_UPDATE, {
id: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
});
}
}
message(item: PlaylistMeta): string {
const operation = this.operations().get(item._id);
if (!operation) {
return '';
}
if (operation.operation === 'playlist-refresh') {
return this.translate.instant(
REFRESH_PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REFRESH'
);
}
if (operation.operation === 'delete-xtream-content') {
return this.translate.instant(
XTREAM_PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REFRESH_XTREAM_DIALOG.IN_PROGRESS'
);
}
return '';
}
progress(id: string): number | null {
const operation = this.operations().get(id);
if (
!operation ||
operation.current == null ||
operation.total == null ||
operation.total <= 0
) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancel(item: PlaylistMeta): boolean {
const operation = this.operations().get(item._id);
if (!operation?.operationId) {
return false;
}
return (
operation.operation === 'playlist-refresh' ||
Boolean(item.serverUrl)
);
}
async cancel(item: PlaylistMeta): Promise<void> {
const operation = this.operations().get(item._id);
if (!operation?.operationId) {
return;
}
if (operation.operation === 'playlist-refresh') {
await this.playlistRefreshService.cancelRefresh(
operation.operationId
);
return;
}
await this.databaseService.cancelOperation(operation.operationId);
}
/** The shared Xtream re-import reports into this list's row state. */
private readonly xtreamReporter: XtreamRefreshProgressReporter = {
isBusy: (playlistId) => this.isRefreshing(playlistId),
begin: ({ playlistId }) => this.setRefreshing(playlistId, true),
report: ({ playlistId }, event) => this.track(playlistId, event),
end: ({ playlistId }) => {
this.clear(playlistId);
this.setRefreshing(playlistId, false);
},
};
private async refreshM3u(item: PlaylistMeta): Promise<void> {
this.setRefreshing(item._id, true);
const operationId =
this.databaseService.createOperationId('playlist-refresh');
try {
const refreshed = await this.playlistRefreshService.refreshPlaylist(
{
operationId,
playlistId: item._id,
title: item.title,
url: item.url,
...(item.userAgent ? { userAgent: item.userAgent } : {}),
filePath: item.filePath,
},
{
onEvent: (event) =>
this.track(item._id, this.toBusyEvent(event)),
}
);
this.store.dispatch(
PlaylistActions.updatePlaylist({
playlist: { ...refreshed, _id: item._id },
playlistId: item._id,
refreshEpg: true,
operationId,
})
);
this.snackBar.open(
this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_SUCCESS'
),
undefined,
{ duration: 2000 }
);
} catch (error) {
if (!isDbAbortError(error)) {
console.error('Error refreshing playlist:', error);
this.snackBar.open(
this.refreshErrorMessage(error, item),
this.translate.instant('CLOSE'),
{ duration: 5000 }
);
}
} finally {
this.clear(item._id);
this.setRefreshing(item._id, false);
}
}
private refreshErrorMessage(error: unknown, item: PlaylistMeta): string {
if (item.filePath) {
const message = String(
(error as { message?: string })?.message ?? error
);
if (/(ENOENT|no such file or directory|not found)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_NOT_FOUND'
);
}
if (/(EACCES|EPERM|permission denied)/i.test(message)) {
return this.translate.instant(
'HOME.PLAYLISTS.PLAYLIST_UPDATE_FILE_ACCESS_ERROR'
);
}
}
return this.translate.instant('HOME.PLAYLISTS.PLAYLIST_UPDATE_ERROR');
}
private toBusyEvent(event: PlaylistRefreshEvent): DbOperationEvent {
return {
operationId: event.operationId,
operation: 'playlist-refresh',
playlistId: event.playlistId,
phase: event.phase,
status: event.status,
error: event.error,
};
}
private track(id: string, event: DbOperationEvent): void {
this.operations.update((current) => {
const next = new Map(current);
if (['completed', 'cancelled', 'error'].includes(event.status)) {
next.delete(id);
} else {
next.set(id, {
operation: event.operation,
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
status: event.status,
});
}
return next;
});
}
private clear(id: string): void {
this.operations.update((current) => {
const next = new Map(current);
next.delete(id);
return next;
});
}
private setRefreshing(id: string, refreshing: boolean): void {
this.refreshingIds.update((current) => {
const next = new Set(current);
if (refreshing) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}
}
@@ -0,0 +1,105 @@
import { Injectable, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { DatabaseService, DbOperationEvent } from '@iptvnator/services';
interface DeleteOperation {
operationId?: string;
phase?: string;
current?: number;
total?: number;
}
const PHASE_KEYS: Record<string, string> = {
'deleting-favorites': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_FAVORITES',
'deleting-recently-viewed': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_RECENT',
'deleting-playback-positions':
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PROGRESS',
'deleting-downloads': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_DOWNLOADS',
'deleting-content': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT',
'deleting-categories': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CATEGORIES',
'deleting-playlist': 'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_PLAYLIST',
};
/**
* Progress of a committed delete that reports it: an Xtream catalog in the
* desktop database can take a while, so its row comes back as "Removing…"
* with progress and a Cancel action that keeps the source.
*/
@Injectable({ providedIn: 'root' })
export class SourceDeleteProgressService {
private readonly database = inject(DatabaseService);
private readonly translate = inject(TranslateService);
private readonly operations = signal<ReadonlyMap<string, DeleteOperation>>(
new Map()
);
private readonly cancelled = new Set<string>();
isDeleting(id: string): boolean {
return this.operations().has(id);
}
message(id: string): string {
const operation = this.operations().get(id);
if (!operation) {
return '';
}
return this.translate.instant(
PHASE_KEYS[operation.phase ?? ''] ??
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
}
progress(id: string): number | null {
const operation = this.operations().get(id);
if (!operation?.total || operation.current == null) {
return null;
}
return Math.min(
100,
Math.round((operation.current / operation.total) * 100)
);
}
canCancel(id: string): boolean {
return !!this.operations().get(id)?.operationId;
}
async cancel(id: string): Promise<void> {
const operationId = this.operations().get(id)?.operationId;
if (operationId) {
this.cancelled.add(id);
await this.database.cancelOperation(operationId);
}
}
/** True once, after a cancelled delete: the caller skips its error. */
takeCancelled(id: string): boolean {
return this.cancelled.delete(id);
}
track(id: string, event: DbOperationEvent): void {
if (['completed', 'cancelled', 'error'].includes(event.status)) {
this.clear(id);
return;
}
this.operations.update((current) =>
new Map(current).set(id, {
operationId: event.operationId,
phase: event.phase,
current: event.current,
total: event.total,
})
);
}
clear(id: string): void {
if (!this.operations().has(id)) {
return;
}
this.operations.update((current) => {
const next = new Map(current);
next.delete(id);
return next;
});
}
}
@@ -0,0 +1,29 @@
@use '@angular/material' as mat;
.source-removal-toast__undo {
font-weight: 600;
}
.source-removal-toast__close {
color: var(--app-body-color);
}
// The toast frame (`panelClass: 'app-toast'`): bottom centre, one step
// above the page. The container renders in the overlay, so the rule is
// global; it ships with this component instead of the initial stylesheet.
::ng-deep .mat-mdc-snack-bar-container.app-toast {
@include mat.snack-bar-overrides(
(
container-color: var(--app-widget-bg),
container-shape: 10px,
supporting-text-color: var(--app-heading-color),
supporting-text-size: 13px,
button-color: var(--app-selection-color),
)
);
.mat-mdc-snackbar-surface {
border: 1px solid var(--app-line-strong);
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}
}
@@ -0,0 +1,62 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { MatButton, MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import {
MAT_SNACK_BAR_DATA,
MatSnackBarAction,
MatSnackBarActions,
MatSnackBarLabel,
MatSnackBarRef,
} from '@angular/material/snack-bar';
export interface SourceRemovalToastData {
message: string;
undoLabel: string;
closeLabel: string;
}
/**
* "Removed …" with Undo, and a close button that commits the delete at
* once instead of waiting out the toast.
*/
@Component({
selector: 'app-source-removal-toast',
imports: [
MatButton,
MatIcon,
MatIconButton,
MatSnackBarAction,
MatSnackBarActions,
MatSnackBarLabel,
],
template: `
<span matSnackBarLabel>{{ data.message }}</span>
<span matSnackBarActions>
<button
mat-button
matSnackBarAction
type="button"
class="source-removal-toast__undo"
(click)="ref.dismissWithAction()"
>
{{ data.undoLabel }}
</button>
<button
mat-icon-button
type="button"
class="source-removal-toast__close"
(click)="ref.dismiss()"
[attr.aria-label]="data.closeLabel"
>
<mat-icon>close</mat-icon>
</button>
</span>
`,
styleUrl: './source-removal-toast.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SourceRemovalToastComponent {
readonly data = inject<SourceRemovalToastData>(MAT_SNACK_BAR_DATA);
readonly ref =
inject<MatSnackBarRef<SourceRemovalToastComponent>>(MatSnackBarRef);
}
@@ -0,0 +1,273 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { PlaylistActions } from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
DatabaseService,
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
RENDERER_PERFORMANCE_PHASE_HOOK_KEY,
type RendererPerformancePhaseEvent,
} from '@iptvnator/shared/logging';
import { SourceRemovalService } from './source-removal.service';
const playlist = (id: string): PlaylistMeta => ({
_id: id,
title: id,
count: 0,
importDate: '',
autoRefresh: false,
});
describe('SourceRemovalService', () => {
let toasts: Array<{
message: string;
action: Subject<void>;
dismissed: Subject<void>;
dismiss: jest.Mock;
}>;
let deletePlaylist: jest.Mock;
let cancelOperation: jest.Mock;
let dispatch: jest.Mock;
let resolvedPlaylistId: ReturnType<typeof signal<string | null>>;
let routePlaylistId: ReturnType<typeof signal<string | null>>;
let context: {
resolvedPlaylistId: typeof resolvedPlaylistId;
routePlaylistId: typeof routePlaylistId;
selectPlaylist: jest.Mock;
activatePlaylist: jest.Mock;
};
let router: { url: string; navigate: jest.Mock; navigateByUrl: jest.Mock };
let service: SourceRemovalService;
const toast = (message: string) => {
const dismissed = new Subject<void>();
const entry: (typeof toasts)[number] = {
message,
action: new Subject<void>(),
dismissed,
dismiss: jest.fn(() => dismissed.next()),
};
toasts.push(entry);
return {
onAction: () => entry.action,
afterDismissed: () => entry.dismissed,
dismiss: entry.dismiss,
};
};
const flush = async () => {
await Promise.resolve();
await Promise.resolve();
};
beforeEach(() => {
toasts = [];
deletePlaylist = jest.fn().mockResolvedValue(true);
cancelOperation = jest.fn().mockResolvedValue(true);
dispatch = jest.fn();
resolvedPlaylistId = signal<string | null>(null);
routePlaylistId = signal<string | null>(null);
context = {
resolvedPlaylistId,
routePlaylistId,
selectPlaylist: jest.fn((p: PlaylistMeta) =>
resolvedPlaylistId.set(p._id)
),
activatePlaylist: jest.fn(),
};
router = {
url: '/workspace/sources',
navigate: jest.fn().mockResolvedValue(true),
navigateByUrl: jest.fn().mockResolvedValue(true),
};
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
SourceRemovalService,
{
provide: MatSnackBar,
useValue: {
open: jest.fn((message: string) => toast(message)),
openFromComponent: jest.fn(
(
_component: unknown,
config: { data: { message: string } }
) => toast(config.data.message)
),
},
},
{
provide: PlaylistDeleteActionService,
useValue: { deletePlaylist },
},
{ provide: PlaylistContextFacade, useValue: context },
{ provide: Router, useValue: router },
{ provide: Store, useValue: { dispatch } },
{ provide: DatabaseService, useValue: { cancelOperation } },
{
provide: RuntimeCapabilitiesService,
useValue: { supportsXtreamSqliteDataSource: true },
},
],
});
service = TestBed.inject(SourceRemovalService);
});
it('hides the source and deletes it only when the toast closes', async () => {
const a = playlist('a');
service.remove(a);
expect(service.isPending('a')).toBe(true);
expect(deletePlaylist).not.toHaveBeenCalled();
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED');
toasts[0].dismissed.next();
await flush();
expect(deletePlaylist).toHaveBeenCalledWith(a, {
onEvent: expect.any(Function),
});
expect(dispatch).toHaveBeenCalledWith(
PlaylistActions.playlistRemovalCommitted({ playlistId: 'a' })
);
expect(service.isPending('a')).toBe(false);
});
it('restores the source on Undo without deleting anything', async () => {
service.remove(playlist('a'));
toasts[0].action.next();
toasts[0].dismissed.next();
await flush();
expect(service.isPending('a')).toBe(false);
expect(deletePlaylist).not.toHaveBeenCalled();
});
it('commits an earlier removal when a second one starts', async () => {
service.remove(playlist('a'));
service.remove(playlist('b'));
await flush();
expect(toasts[0].dismiss).toHaveBeenCalled();
expect(deletePlaylist).toHaveBeenCalledTimes(1);
expect(service.isPending('b')).toBe(true);
});
it('switches away from the active source and back on Undo', () => {
resolvedPlaylistId.set('a');
service.remove(playlist('a'), playlist('b'));
expect(context.selectPlaylist).toHaveBeenCalledWith(playlist('b'));
expect(toasts[0].message).toBe('WORKSPACE.SOURCES.REMOVED_SWITCHED');
toasts[0].action.next();
expect(context.activatePlaylist).toHaveBeenCalledWith('a');
});
it('leaves a switch the user made after the delete alone on Undo', () => {
resolvedPlaylistId.set('a');
service.remove(playlist('a'), playlist('b'));
resolvedPlaylistId.set('c');
toasts[0].action.next();
expect(context.activatePlaylist).not.toHaveBeenCalled();
});
it('leaves the deleted source’s own page and returns on Undo', () => {
resolvedPlaylistId.set('a');
routePlaylistId.set('a');
router.url = '/workspace/xtreams/a/vod';
service.remove(playlist('a'));
expect(router.navigate).toHaveBeenCalledWith(['/workspace', 'sources']);
toasts[0].action.next();
expect(router.navigateByUrl).toHaveBeenCalledWith(
'/workspace/xtreams/a/vod'
);
});
it('shows a desktop Xtream row as removing as soon as the delete starts', async () => {
deletePlaylist.mockReturnValue(new Promise(() => undefined));
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
expect(service.isPending('x')).toBe(true);
toasts[0].dismissed.next();
await flush();
expect(service.isPending('x')).toBe(false);
expect(service.progress.isDeleting('x')).toBe(true);
expect(service.progress.message('x')).toBe(
'HOME.PLAYLISTS.REMOVE_DIALOG.IN_PROGRESS'
);
});
it('times an Xtream row leaving the store for the performance journeys', async () => {
const events: RendererPerformancePhaseEvent[] = [];
const hook = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
const target = globalThis as unknown as Record<symbol, unknown>;
target[hook] = (event: RendererPerformancePhaseEvent) =>
events.push(event);
try {
service.remove({ ...playlist('x'), serverUrl: 'http://x.test' });
toasts[0].dismissed.next();
await flush();
} finally {
delete target[hook];
}
expect(events.map(({ boundary, phase }) => [boundary, phase])).toEqual([
['start', 'store.xtream-delete-row'],
['end', 'store.xtream-delete-row'],
]);
});
it('shows a long delete on its row and keeps the source on Cancel', async () => {
let finish!: (deleted: boolean) => void;
deletePlaylist.mockImplementation(
(
_playlist: PlaylistMeta,
options: { onEvent: (event: object) => void }
) => {
options.onEvent({
operation: 'delete-playlist',
operationId: 'op-1',
status: 'progress',
phase: 'deleting-content',
current: 1,
total: 4,
});
return new Promise<boolean>((resolve) => (finish = resolve));
}
);
service.remove(playlist('x'));
toasts[0].dismissed.next();
await flush();
// The row is back, in its busy state.
expect(service.isPending('x')).toBe(false);
expect(service.progress.isDeleting('x')).toBe(true);
expect(service.progress.message('x')).toBe(
'HOME.PLAYLISTS.REMOVE_DIALOG.DELETING_CONTENT'
);
expect(service.progress.progress('x')).toBe(25);
await service.progress.cancel('x');
expect(cancelOperation).toHaveBeenCalledWith('op-1');
finish(false);
await flush();
expect(service.progress.isDeleting('x')).toBe(false);
expect(dispatch).not.toHaveBeenCalled();
// A cancel is not a failure.
expect(toasts).toHaveLength(1);
});
it('brings the source back and says so when the delete fails', async () => {
deletePlaylist.mockResolvedValue(false);
service.remove(playlist('a'));
toasts[0].dismissed.next();
await flush();
expect(dispatch).not.toHaveBeenCalled();
expect(service.isPending('a')).toBe(false);
expect(toasts[1].message).toBe('WORKSPACE.SOURCES.REMOVE_FAILED');
});
});
@@ -0,0 +1,229 @@
import { Injectable, inject, signal } from '@angular/core';
import { MatSnackBar, MatSnackBarRef } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from '@iptvnator/m3u-state';
import {
PlaylistContextFacade,
sourceDisplayName,
} from '@iptvnator/playlist/shared/util';
import {
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { SourceDeleteProgressService } from './source-delete-progress.service';
import { SourceRemovalToastComponent } from './source-removal-toast.component';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
/** How long Undo stays available before the delete is committed. */
export const SOURCE_REMOVAL_UNDO_MS = 4000;
interface PendingRemoval {
playlist: PlaylistMeta;
snackBar: MatSnackBarRef<SourceRemovalToastComponent>;
/** The playlist was the active one; `next` replaced it, if any. */
wasActive: boolean;
wasOnItsRoute: boolean;
previousUrl: string;
next: PlaylistMeta | null;
undone: boolean;
}
/**
* Single-source delete with Undo, for the Sources page and the switcher.
*
* Deleting a source drops its favourites, history and playback progress, so
* nothing is deleted while Undo is offered: the source only leaves the
* lists (`pendingIds`), and the real delete runs when the toast closes
* without Undo. Quitting the app inside those four seconds keeps the
* source. One removal is pending at a time; a second delete commits the
* first. A delete that reports progress (a large Xtream catalog) brings its
* row back as "Removing…" with Cancel, which keeps the source. Bulk cleanup
* keeps its confirmation dialog instead.
*/
@Injectable({ providedIn: 'root' })
export class SourceRemovalService {
private readonly deleteAction = inject(PlaylistDeleteActionService);
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly router = inject(Router);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly snackBar = inject(MatSnackBar);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
/** Progress of a long delete; its row comes back to show it. */
readonly progress = inject(SourceDeleteProgressService);
private readonly pendingState = signal<ReadonlySet<string>>(new Set());
/** Sources hidden from every list while Undo or the delete runs. */
readonly pendingIds = this.pendingState.asReadonly();
private pending: PendingRemoval | null = null;
isPending(id: string): boolean {
return this.pendingState().has(id);
}
/**
* @param next The source to switch to when `playlist` is the active one:
* the caller's next row, so the switch follows the list on screen.
*/
remove(playlist: PlaylistMeta, next: PlaylistMeta | null = null): void {
if (this.isPending(playlist._id)) {
return;
}
// The toast for an earlier removal closes here and commits it.
this.pending?.snackBar.dismiss();
const wasActive =
this.playlistContext.resolvedPlaylistId() === playlist._id;
const wasOnItsRoute =
this.playlistContext.routePlaylistId() === playlist._id;
const previousUrl = this.router.url;
const replacement = wasActive ? next : null;
this.setPending(playlist._id, true);
if (replacement) {
this.playlistContext.selectPlaylist(replacement);
} else if (wasOnItsRoute) {
void this.router.navigate(['/workspace', 'sources']);
}
const name = this.nameOf(playlist);
const message = replacement
? this.translate.instant('WORKSPACE.SOURCES.REMOVED_SWITCHED', {
name,
next: this.nameOf(replacement),
})
: this.translate.instant('WORKSPACE.SOURCES.REMOVED', { name });
const snackBar = this.snackBar.openFromComponent(
SourceRemovalToastComponent,
{
data: {
message,
undoLabel: this.translate.instant(
'PORTALS.MULTI_SOURCE.UNDO'
),
closeLabel: this.translate.instant('CLOSE'),
},
duration: SOURCE_REMOVAL_UNDO_MS,
panelClass: 'app-toast',
}
);
const removal: PendingRemoval = {
playlist,
snackBar,
wasActive,
wasOnItsRoute,
previousUrl,
next: replacement,
undone: false,
};
this.pending = removal;
snackBar.onAction().subscribe(() => this.undo(removal));
snackBar.afterDismissed().subscribe(() => {
if (this.pending === removal) {
this.pending = null;
}
if (!removal.undone) {
void this.commit(removal);
}
});
}
private undo(removal: PendingRemoval): void {
removal.undone = true;
this.setPending(removal.playlist._id, false);
if (!removal.wasActive) {
return;
}
// Only take the user back if they are still where the delete left
// them; a switch they made since then wins.
const stillReplaced =
this.playlistContext.resolvedPlaylistId() ===
(removal.next?._id ?? null);
if (!stillReplaced && removal.next) {
return;
}
if (removal.wasOnItsRoute) {
void this.router.navigateByUrl(removal.previousUrl);
} else {
this.playlistContext.activatePlaylist(removal.playlist._id);
}
}
private async commit(removal: PendingRemoval): Promise<void> {
const { playlist } = removal;
// A desktop Xtream delete works through the catalog database and
// reports progress: its row returns at once as "Removing…".
if (playlist.serverUrl && this.runtime.supportsXtreamSqliteDataSource) {
this.progress.track(playlist._id, {
operation: 'delete-playlist',
status: 'started',
});
this.setPending(playlist._id, false);
}
try {
const deleted = await this.deleteAction.deletePlaylist(playlist, {
onEvent: (event) => {
this.progress.track(playlist._id, event);
if (this.progress.isDeleting(playlist._id)) {
this.setPending(playlist._id, false);
}
},
});
if (deleted) {
const commit = () =>
this.store.dispatch(
PlaylistActions.playlistRemovalCommitted({
playlistId: playlist._id,
})
);
// The performance journeys time an Xtream row leaving the store.
if (playlist.serverUrl) {
measureRendererPerformancePhase(
RENDERER_PERFORMANCE_PHASE.XTREAM_DELETE_ROW,
commit,
() => ({ items: 1 })
);
} else {
commit();
}
} else if (!this.progress.takeCancelled(playlist._id)) {
this.snackBar.open(
this.translate.instant('WORKSPACE.SOURCES.REMOVE_FAILED', {
name: this.nameOf(playlist),
}),
this.translate.instant('CLOSE'),
{ duration: 5000, panelClass: 'app-toast' }
);
}
} finally {
this.progress.clear(playlist._id);
this.setPending(playlist._id, false);
}
}
private nameOf(playlist: PlaylistMeta): string {
return (
sourceDisplayName(playlist) ??
this.translate.instant('HOME.PLAYLISTS.UNTITLED_PLAYLIST')
);
}
private setPending(id: string, pending: boolean): void {
this.pendingState.update((current) => {
const next = new Set(current);
if (pending) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}
}
@@ -102,7 +102,7 @@
<button
mat-icon-button
type="button"
class="source-row__action source-row__action--visible"
class="source-row__action source-row__action--visible source-row__cancel"
(click)="
$event.stopPropagation(); cancelBusyRequested.emit()
"
@@ -131,7 +131,7 @@
<button
mat-icon-button
type="button"
class="source-row__action"
class="source-row__action source-row__refresh"
[class.source-row__action--visible]="refreshing()"
[disabled]="busy()"
(click)="$event.stopPropagation(); refreshRequested.emit()"
@@ -149,7 +149,7 @@
<button
mat-icon-button
type="button"
class="source-row__action"
class="source-row__action source-row__edit"
[disabled]="busy()"
(click)="$event.stopPropagation(); editRequested.emit()"
[matTooltip]="'WORKSPACE.SOURCES.ROW.EDIT' | translate"
@@ -1,3 +1,4 @@
@use '@angular/material' as mat;
@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/focus-ring';
@@ -11,7 +12,7 @@
.source-row {
--source-row-fill: var(--source-row-base);
--source-tile-ring: var(--source-row-fill);
--source-row-ring: var(--source-row-fill);
position: relative;
display: grid;
@@ -68,7 +69,7 @@
&.source-row--current {
// The same selected-row look as the side lists: a soft tint, and an
// outline in light theme. The dot's ring approximates the tint.
--source-tile-ring: color-mix(
--source-row-ring: color-mix(
in srgb,
var(--app-selection-color) 16%,
var(--source-row-base)
@@ -87,6 +88,12 @@
cursor: grab;
}
// Set on the tile element itself: its host declares defaults, which an
// inherited value would not override.
app-source-tile {
--source-tile-ring: var(--source-row-ring);
}
.source-row--failing app-source-tile {
--source-tile-color: var(--app-muted-color);
}
@@ -230,6 +237,23 @@
@include controls.ghost-button(sm);
}
// Cancelling a running refresh or delete reads as destructive.
.source-row__cancel {
@include mat.icon-button-overrides(
(
icon-color: var(--mat-sys-error),
)
);
&:not([disabled]):hover {
@include mat.icon-button-overrides(
(
icon-color: var(--mat-sys-error),
)
);
}
}
// Row actions appear on hover, keyboard focus and while the menu is open.
// A failing row keeps Retry in view; touch screens, which cannot hover,
// always show them.
@@ -2,6 +2,10 @@
// 2px ring takes `--source-tile-ring`, which the row sets to its own
// background, so the dot reads as cut out of the tile in every state.
:host {
// Defaults a row or trigger overrides on the `app-source-tile` element.
--source-tile-bg: var(--app-widget-bg);
--source-tile-color: var(--app-body-color);
--source-tile-ring: var(--app-content-bg);
--source-tile-size: 36px;
--source-tile-radius: 9px;
--source-tile-icon: 17px;
@@ -15,8 +19,8 @@
width: var(--source-tile-size);
height: var(--source-tile-size);
border-radius: var(--source-tile-radius);
background: var(--source-tile-bg, var(--app-widget-bg));
color: var(--source-tile-color, var(--app-body-color));
background: var(--source-tile-bg);
color: var(--source-tile-color);
}
:host(.source-tile--md) {
@@ -49,7 +53,7 @@
width: var(--source-tile-dot);
height: var(--source-tile-dot);
border-radius: 50%;
border: 2px solid var(--source-tile-ring, var(--app-content-bg));
border: 2px solid var(--source-tile-ring);
background: var(--app-muted-color);
&[data-state='ok'] {
+2
View File
@@ -2,3 +2,5 @@ export * from './lib/playlist-context.facade';
export * from './lib/playlist-file-import.service';
export * from './lib/source-display/source-display';
export * from './lib/source-display/source-format';
export * from './lib/source-display/source-list';
export * from './lib/source-display/sources-view-state.service';
@@ -0,0 +1,134 @@
import { TestBed } from '@angular/core/testing';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
countSourcesByStatus,
countSourcesByType,
filterSources,
matchesSourceQuery,
orderFailingFirst,
SourceBucketOf,
} from './source-list';
import {
SOURCES_VIEW_STORAGE_KEY,
SourcesViewStateService,
} from './sources-view-state.service';
const base = { count: 0, importDate: '', autoRefresh: false };
const m3u: PlaylistMeta = {
...base,
_id: 'm3u',
title: 'Free TV',
url: 'https://cdn.example/list.m3u',
};
const file: PlaylistMeta = { ...base, _id: 'file', title: 'local list' };
const xtream: PlaylistMeta = {
...base,
_id: 'xtream',
title: '',
serverUrl: 'http://biggie.to:8080',
username: 'joe',
password: 'hunter2',
};
const stalker: PlaylistMeta = {
...base,
_id: 'stalker',
title: 'Portal',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://portal.example/c/',
};
const all = [m3u, file, xtream, stalker];
const bucketOf: SourceBucketOf = (playlist) =>
(({ m3u: 'ok', xtream: 'bad', stalker: 'ok' })[playlist._id] as
'ok' | 'bad' | undefined) ?? null;
describe('source list filters', () => {
it('combines type, status and query', () => {
const ids = (
type: 'all' | 'm3u',
status: 'all' | 'ok' | 'bad',
q = ''
) =>
filterSources(all, { type, status }, q, bucketOf).map((p) => p._id);
expect(ids('all', 'all')).toEqual(['m3u', 'file', 'xtream', 'stalker']);
expect(ids('m3u', 'all')).toEqual(['m3u', 'file']);
expect(ids('all', 'bad')).toEqual(['xtream']);
// A local file has no status, so it is in neither status group.
expect(ids('m3u', 'ok')).toEqual(['m3u']);
expect(ids('all', 'all', 'portal')).toEqual(['stalker']);
});
it('finds a source by its host, not by its password', () => {
expect(matchesSourceQuery(xtream, 'biggie')).toBe(true);
expect(matchesSourceQuery(xtream, 'hunter2')).toBe(false);
expect(matchesSourceQuery(m3u, 'FREE')).toBe(true);
});
it('counts each group within the other group’s choice', () => {
expect(countSourcesByType(all, 'all', bucketOf)).toEqual({
all: 4,
m3u: 2,
xtream: 1,
stalker: 1,
});
expect(countSourcesByType(all, 'ok', bucketOf)).toEqual({
all: 2,
m3u: 1,
xtream: 0,
stalker: 1,
});
expect(countSourcesByStatus(all, 'all', bucketOf)).toEqual({
all: 4,
ok: 2,
bad: 1,
});
expect(countSourcesByStatus(all, 'm3u', bucketOf)).toEqual({
all: 2,
ok: 1,
bad: 0,
});
});
it('puts failing sources first and keeps the order otherwise', () => {
expect(orderFailingFirst(all, bucketOf).map((p) => p._id)).toEqual([
'xtream',
'm3u',
'file',
'stalker',
]);
});
});
describe('SourcesViewStateService', () => {
const create = () => {
TestBed.configureTestingModule({
providers: [SourcesViewStateService],
});
return TestBed.inject(SourcesViewStateService);
};
beforeEach(() => localStorage.clear());
afterEach(() => TestBed.resetTestingModule());
it('keeps the filters across a restart', () => {
const state = create();
state.setType('xtream');
state.setStatus('bad');
TestBed.resetTestingModule();
expect(create().filter()).toEqual({ type: 'xtream', status: 'bad' });
});
it('resets both filters', () => {
const state = create();
state.setType('m3u');
state.reset();
expect(state.filter()).toEqual({ type: 'all', status: 'all' });
});
it('ignores unknown stored values', () => {
localStorage.setItem(
SOURCES_VIEW_STORAGE_KEY,
JSON.stringify({ type: 'radio', status: 'maybe' })
);
expect(create().filter()).toEqual({ type: 'all', status: 'all' });
});
});
@@ -0,0 +1,124 @@
import {
foldSearchText,
PlaylistMeta,
PlaylistSourceKind,
resolvePlaylistSourceKind,
SourceStatusBucket,
} from '@iptvnator/shared/interfaces';
import { sourceDisplayName, sourceHost } from './source-display';
export type SourcesTypeFilter = 'all' | PlaylistSourceKind;
export type SourcesStatusFilter = 'all' | SourceStatusBucket;
export interface SourcesFilter {
type: SourcesTypeFilter;
status: SourcesStatusFilter;
}
/** A source's status bucket; null for local files and unchecked sources. */
export type SourceBucketOf = (
playlist: PlaylistMeta
) => SourceStatusBucket | null;
export type SourceTypeCounts = Record<SourcesTypeFilter, number>;
export type SourceStatusCounts = Record<SourcesStatusFilter, number>;
export function matchesSourceType(
playlist: PlaylistMeta,
type: SourcesTypeFilter
): boolean {
return type === 'all' || resolvePlaylistSourceKind(playlist) === type;
}
export function matchesSourceStatus(
playlist: PlaylistMeta,
status: SourcesStatusFilter,
bucketOf: SourceBucketOf
): boolean {
return status === 'all' || bucketOf(playlist) === status;
}
/**
* Search by what the row shows: the name (stored and displayed) and the
* host, folded for case and accents. An empty query matches everything.
*/
export function matchesSourceQuery(
playlist: PlaylistMeta,
query: string
): boolean {
const folded = foldSearchText(query).trim();
if (!folded) {
return true;
}
const haystack = [
playlist.title,
playlist.filename,
sourceDisplayName(playlist),
sourceHost(playlist),
];
return haystack.some(
(value) => !!value && foldSearchText(value).includes(folded)
);
}
export function filterSources(
playlists: readonly PlaylistMeta[],
filter: SourcesFilter,
query: string,
bucketOf: SourceBucketOf
): PlaylistMeta[] {
return playlists.filter(
(playlist) =>
matchesSourceType(playlist, filter.type) &&
matchesSourceStatus(playlist, filter.status, bucketOf) &&
matchesSourceQuery(playlist, query)
);
}
/** Type counts within the current status choice, as the side list shows. */
export function countSourcesByType(
playlists: readonly PlaylistMeta[],
status: SourcesStatusFilter,
bucketOf: SourceBucketOf
): SourceTypeCounts {
const counts: SourceTypeCounts = { all: 0, m3u: 0, xtream: 0, stalker: 0 };
for (const playlist of playlists) {
if (matchesSourceStatus(playlist, status, bucketOf)) {
counts.all += 1;
counts[resolvePlaylistSourceKind(playlist)] += 1;
}
}
return counts;
}
/** Status counts within the current type choice. */
export function countSourcesByStatus(
playlists: readonly PlaylistMeta[],
type: SourcesTypeFilter,
bucketOf: SourceBucketOf
): SourceStatusCounts {
const counts: SourceStatusCounts = { all: 0, ok: 0, bad: 0 };
for (const playlist of playlists) {
if (matchesSourceType(playlist, type)) {
counts.all += 1;
const bucket = bucketOf(playlist);
if (bucket) {
counts[bucket] += 1;
}
}
}
return counts;
}
/** Moves sources that do not respond to the top, keeping the order. */
export function orderFailingFirst(
playlists: readonly PlaylistMeta[],
bucketOf: SourceBucketOf
): PlaylistMeta[] {
const failing: PlaylistMeta[] = [];
const rest: PlaylistMeta[] = [];
for (const playlist of playlists) {
(bucketOf(playlist) === 'bad' ? failing : rest).push(playlist);
}
return [...failing, ...rest];
}
@@ -0,0 +1,63 @@
import { Injectable, signal } from '@angular/core';
import {
SourcesFilter,
SourcesStatusFilter,
SourcesTypeFilter,
} from './source-list';
export const SOURCES_VIEW_STORAGE_KEY = 'sources:view:v1';
const TYPES: readonly SourcesTypeFilter[] = ['all', 'm3u', 'xtream', 'stalker'];
const STATUSES: readonly SourcesStatusFilter[] = ['all', 'ok', 'bad'];
const DEFAULT_FILTER: SourcesFilter = { type: 'all', status: 'all' };
function readFilter(): SourcesFilter {
try {
const raw = localStorage.getItem(SOURCES_VIEW_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as Partial<SourcesFilter>) : {};
return {
type: TYPES.includes(parsed.type as SourcesTypeFilter)
? (parsed.type as SourcesTypeFilter)
: 'all',
status: STATUSES.includes(parsed.status as SourcesStatusFilter)
? (parsed.status as SourcesStatusFilter)
: 'all',
};
} catch {
return { ...DEFAULT_FILTER };
}
}
/**
* The Sources page's type and status filters, kept across restarts. The two
* combine (AND); each side-list group counts within the other's choice.
*/
@Injectable({ providedIn: 'root' })
export class SourcesViewStateService {
private readonly state = signal<SourcesFilter>(readFilter());
readonly filter = this.state.asReadonly();
setType(type: SourcesTypeFilter): void {
this.write({ ...this.state(), type });
}
setStatus(status: SourcesStatusFilter): void {
this.write({ ...this.state(), status });
}
reset(): void {
this.write({ ...DEFAULT_FILTER });
}
private write(filter: SourcesFilter): void {
this.state.set(filter);
try {
localStorage.setItem(
SOURCES_VIEW_STORAGE_KEY,
JSON.stringify(filter)
);
} catch {
// Filters still apply for this session.
}
}
}
@@ -0,0 +1,84 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { SortBy, SortOrder, SortService } from './sort.service';
const playlist = (
id: string,
fields: Partial<PlaylistMeta> = {}
): PlaylistMeta => ({
_id: id,
title: id,
count: 0,
importDate: '2026-01-01T00:00:00.000Z',
autoRefresh: false,
...fields,
});
describe('SortService', () => {
const create = () => {
TestBed.configureTestingModule({ providers: [SortService] });
return TestBed.inject(SortService);
};
beforeEach(() => localStorage.clear());
afterEach(() => TestBed.resetTestingModule());
it('sorts by the last refresh, falling back to the import date', () => {
const ids = create()
.sortPlaylists(
[
playlist('old', { importDate: '2025-01-01T00:00:00Z' }),
playlist('refreshed', {
importDate: '2024-01-01T00:00:00Z',
updateDate: Date.parse('2026-06-01T00:00:00Z'),
}),
playlist('new', { importDate: '2026-03-01T00:00:00Z' }),
],
{ by: SortBy.UPDATED, order: SortOrder.DESC }
)
.map((item) => item._id);
expect(ids).toEqual(['refreshed', 'new', 'old']);
});
it('sorts by channel count, with no count as zero', () => {
const ids = create()
.sortPlaylists(
[
playlist('xtream', {
count: undefined as unknown as number,
}),
playlist('big', { count: 900 }),
playlist('small', { count: 3 }),
],
{ by: SortBy.CHANNELS, order: SortOrder.DESC }
)
.map((item) => item._id);
expect(ids).toEqual(['big', 'small', 'xtream']);
});
it('keeps the failing-first choice across a restart', async () => {
create().setSortOptions({
by: SortBy.NAME,
order: SortOrder.ASC,
failingFirst: true,
});
TestBed.resetTestingModule();
expect(await firstValueFrom(create().getSortOptions())).toEqual({
by: SortBy.NAME,
order: SortOrder.ASC,
failingFirst: true,
});
});
it('falls back to newest first for an unknown saved sort', async () => {
localStorage.setItem(
'iptvnator-sort-options',
JSON.stringify({ by: 'size', order: 'asc' })
);
expect(await firstValueFrom(create().getSortOptions())).toEqual({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
});
});
});
+28 -4
View File
@@ -4,7 +4,11 @@ import { PlaylistMeta } from '@iptvnator/shared/interfaces';
export enum SortBy {
DATE_ADDED = 'date',
/** Last refresh, falling back to the import date. */
UPDATED = 'updated',
NAME = 'name',
/** Channel count; only M3U playlists store one. */
CHANNELS = 'channels',
CUSTOM = 'custom',
}
@@ -16,10 +20,22 @@ export enum SortOrder {
export interface SortOptions {
by: SortBy;
order: SortOrder;
/**
* Lists that know source health put the sources that do not respond
* first, keeping this order within each part.
*/
failingFirst?: boolean;
}
const SORT_BY_VALUES = new Set<string>(Object.values(SortBy));
const SORT_OPTIONS_STORAGE_KEY = 'iptvnator-sort-options';
function updatedAt(playlist: PlaylistMeta): number {
const time = playlist.updateDate ?? new Date(playlist.importDate).getTime();
return Number.isFinite(time) ? time : 0;
}
@Injectable({
providedIn: 'root',
})
@@ -53,13 +69,17 @@ export class SortService {
// Validate that the saved options match our expected types
if (
(parsedOptions.by === SortBy.DATE_ADDED ||
parsedOptions.by === SortBy.NAME ||
parsedOptions.by === SortBy.CUSTOM) &&
SORT_BY_VALUES.has(parsedOptions.by) &&
(parsedOptions.order === SortOrder.ASC ||
parsedOptions.order === SortOrder.DESC)
) {
return parsedOptions;
return {
by: parsedOptions.by,
order: parsedOptions.order,
...(parsedOptions.failingFirst === true
? { failingFirst: true }
: {}),
};
}
return defaultOptions;
@@ -100,6 +120,10 @@ export class SortService {
const dateA = new Date(a.importDate).getTime();
const dateB = new Date(b.importDate).getTime();
comparison = dateA - dateB;
} else if (by === SortBy.UPDATED) {
comparison = updatedAt(a) - updatedAt(b);
} else if (by === SortBy.CHANNELS) {
comparison = (a.count ?? 0) - (b.count ?? 0);
} else if (by === SortBy.CUSTOM) {
// Sort by position field (lower position comes first)
const posA = a.position ?? Number.MAX_SAFE_INTEGER;
+23
View File
@@ -84,6 +84,16 @@ $_sizes: (
@include _disabled;
}
/// A secondary button at another height (toolbars that stack on narrow
/// windows use 36px).
@mixin secondary-button-height($height) {
@include mat.button-overrides(
(
filled-container-height: $height,
)
);
}
/// A text action: `--app-body-color`, the heading colour on hover.
@mixin ghost-button($size: sm) {
@include mat.button-overrides(
@@ -202,6 +212,19 @@ $_sizes: (
.mat-mdc-menu-item {
min-height: 36px;
border-radius: 6px;
.mat-icon {
font-size: 16px;
line-height: 16px;
}
}
// The label span fills the row, so a trailing keyboard hint can sit at
// its end.
.mat-mdc-menu-item-text {
display: flex;
flex: 1;
align-items: center;
}
.mat-mdc-menu-item[aria-checked='true'] {
+102
View File
@@ -0,0 +1,102 @@
// The UI kit's side list: a panel header (title, subtitle, ghost icon
// actions), uppercase group labels and 40px single-choice rows. The chosen
// row takes the app's one selected-row look (`--app-list-selected-*`: the
// Settings navigation's soft tint, outlined in light theme), so every side
// list and the playlist switcher's current row agree.
@use 'focus-ring';
@mixin header {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 4px 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--app-widget-header-border);
}
@mixin title {
margin: 0;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.2px;
line-height: 1.3;
color: var(--app-heading-color);
}
@mixin subtitle {
margin: 2px 0 0;
font-size: 13px;
color: var(--app-body-color);
}
@mixin label {
margin: 0;
padding: 8px 12px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--app-muted-color);
}
/// A row button: leading 16px icon (or dot), label, trailing count. Mark
/// the chosen row with `aria-pressed="true"`.
@mixin row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
width: 100%;
height: 40px;
padding: 0 12px;
box-sizing: border-box;
border: 0;
border-radius: 8px;
background: none;
color: var(--app-body-color);
font: inherit;
font-size: 14px;
font-weight: 400;
text-align: start;
cursor: pointer;
.mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
color: var(--app-muted-color);
}
&:hover {
background: color-mix(in srgb, var(--app-on-surface) 6%, transparent);
color: var(--app-heading-color);
}
&[aria-pressed='true'] {
background: var(--app-list-selected-bg);
box-shadow: inset 0 0 0 1px var(--app-list-selected-outline);
color: var(--app-list-selected-fg);
font-weight: 500;
.mat-icon {
color: inherit;
}
}
&:focus-visible {
@include focus-ring.focus-ring-declarations($offset: -2px);
}
}
@mixin row-label {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@mixin row-count {
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color);
}
@@ -1,38 +1,92 @@
<div class="context-column">
<header class="context-header">
<div class="context-header__top">
<h2>{{ 'WORKSPACE.SOURCES.TITLE' | translate }}</h2>
<div class="side-list">
<header class="side-list__header">
<div class="side-list__heading">
<h2 class="side-list__title">
{{ 'WORKSPACE.SOURCES.TITLE' | translate }}
</h2>
<p class="side-list__subtitle">
@if (total() === 1) {
{{ 'WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE' | translate }}
} @else {
{{
'WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER'
| translate: { count: total() }
}}
}
</p>
</div>
<p>{{ 'WORKSPACE.SOURCES.SUBTITLE' | translate }}</p>
<button
mat-icon-button
type="button"
class="side-list__action"
data-test-id="sources-add-playlist"
(click)="addPlaylist()"
[matTooltip]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
[attr.aria-label]="'WORKSPACE.SHELL.ADD_PLAYLIST' | translate"
>
<mat-icon>add</mat-icon>
</button>
</header>
<div class="context-divider"></div>
<section class="panel-section">
<div class="nav-list option-list">
<h3 class="panel-section-title">
<div class="side-list__groups">
<section
class="side-list__group"
role="group"
aria-labelledby="sources-type-label"
>
<h3 id="sources-type-label" class="side-list__label">
{{ 'WORKSPACE.SOURCES.TYPE' | translate }}
</h3>
@for (option of typeOptions; track option.id) {
<button
type="button"
class="nav-item category-item option-row"
[class.selected]="isTypeActive(option.id)"
class="side-list__row option-row"
[attr.data-filter]="option.id"
[attr.aria-pressed]="filter().type === option.id"
(click)="selectType(option.id)"
>
<mat-icon>{{ option.icon }}</mat-icon>
<span class="nav-item-label option-label">
{{
option.translationKey
? (option.translationKey | translate)
: option.label
}}
</span>
<div class="nav-item-meta option-meta">
{{ getTypeCount(option.id) }}
</div>
<mat-icon aria-hidden="true">{{ option.icon }}</mat-icon>
<span class="side-list__row-label option-label">{{
option.labelKey | translate
}}</span>
<span class="side-list__row-count">{{
typeCounts()[option.id]
}}</span>
</button>
}
</div>
</section>
</section>
@if (showStatus) {
<section
class="side-list__group"
role="group"
aria-labelledby="sources-status-label"
>
<h3 id="sources-status-label" class="side-list__label">
{{ 'WORKSPACE.SOURCES.STATUS' | translate }}
</h3>
@for (option of statusOptions; track option.id) {
<button
type="button"
class="side-list__row option-row"
[attr.data-filter]="'status-' + option.id"
[attr.aria-pressed]="filter().status === option.id"
(click)="selectStatus(option.id)"
>
<span
class="side-list__dot"
[attr.data-state]="option.id"
aria-hidden="true"
></span>
<span class="side-list__row-label option-label">{{
option.labelKey | translate
}}</span>
<span class="side-list__row-count">{{
statusCounts()[option.id]
}}</span>
</button>
}
</section>
}
</div>
</div>
@@ -1,4 +1,5 @@
@use '../../nav-list.scss';
@use '../../../../../../ui/styles/app-controls' as controls;
@use '../../../../../../ui/styles/side-list';
:host {
display: flex;
@@ -7,7 +8,7 @@
height: 100%;
}
.context-column {
.side-list {
display: flex;
flex-direction: column;
min-height: 0;
@@ -15,88 +16,73 @@
overflow: hidden;
}
// ─── Header ───────────────────────────────────────────────────────────────────
.context-header {
padding: 4px 6px 10px;
.side-list__header {
@include side-list.header;
flex-shrink: 0;
}
.context-header__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
h2 {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
}
.side-list__heading {
flex: 1;
min-width: 0;
}
.context-header p {
margin: 4px 0 0;
font-size: 0.78rem;
color: var(--mat-sys-on-surface-variant);
.side-list__title {
@include side-list.title;
}
// ─── Divider ──────────────────────────────────────────────────────────────────
.context-divider {
height: 1px;
background: var(--mat-sys-outline-variant);
margin: 0 0 6px;
opacity: 0.6;
flex-shrink: 0;
.side-list__subtitle {
@include side-list.subtitle;
}
.panel-section {
.side-list__action {
@include controls.ghost-icon-button(32px, 18px);
flex: none;
}
.side-list__groups {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.option-list {
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
}
.option-row {
border-radius: 12px;
transition:
box-shadow 0.18s ease,
transform 0.18s ease;
.side-list__group {
display: flex;
flex-direction: column;
gap: 1px;
}
.option-label,
.option-meta,
mat-icon {
transition: color 0.18s ease, opacity 0.18s ease;
.side-list__label {
@include side-list.label;
}
.side-list__row {
@include side-list.row;
}
.side-list__row-label {
@include side-list.row-label;
}
.side-list__row-count {
@include side-list.row-count;
}
// The status rows lead with a dot in the colour the source rows use.
.side-list__dot {
width: 8px;
height: 8px;
margin: 0 4px;
border-radius: 50%;
background: var(--app-muted-color);
&[data-state='ok'] {
background: var(--app-success-color);
}
&.selected {
box-shadow: 0 10px 20px -18px var(--app-selection-glow);
transform: translateY(-1px);
.option-label,
.option-meta,
mat-icon {
color: var(--app-selection-color) !important;
}
.option-meta {
opacity: 0.95;
}
&[data-state='bad'] {
background: var(--app-live-color);
}
}
.panel-section-title {
margin: 0;
padding: 10px 10px 6px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--mat-sys-on-surface-variant);
opacity: 0.85;
}
@@ -0,0 +1,149 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslateModule } from '@ngx-translate/core';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import { WorkspaceSourcesFiltersPanelComponent } from './workspace-sources-filters-panel.component';
const PLAYLISTS = [
{ _id: 'm3u', title: 'a', url: 'https://a.example/list.m3u' },
{ _id: 'file', title: 'b' },
{ _id: 'xtream', title: 'c', serverUrl: 'http://x.example' },
{ _id: 'stalker', title: 'd', macAddress: 'm', portalUrl: 'http://p' },
] as PlaylistMeta[];
const BUCKETS: Record<string, string> = {
m3u: 'ok',
xtream: 'bad',
stalker: 'ok',
};
describe('WorkspaceSourcesFiltersPanelComponent', () => {
let fixture: ComponentFixture<WorkspaceSourcesFiltersPanelComponent>;
let supportsStatusFilter: boolean;
let openAddPlaylistDialog: jest.Mock;
const render = () => {
fixture = TestBed.createComponent(
WorkspaceSourcesFiltersPanelComponent
);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
};
const rows = (element: HTMLElement) =>
Array.from(
element.querySelectorAll<HTMLButtonElement>('.side-list__row')
);
const counts = (element: HTMLElement) =>
rows(element).map(
(row) =>
`${row.dataset['filter']}:${row.querySelector('.side-list__row-count')?.textContent?.trim()}`
);
beforeEach(() => {
localStorage.clear();
supportsStatusFilter = true;
openAddPlaylistDialog = jest.fn();
TestBed.configureTestingModule({
imports: [
WorkspaceSourcesFiltersPanelComponent,
TranslateModule.forRoot(),
],
providers: [
provideMockStore({
selectors: [
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
],
}),
{
provide: SourceStatusService,
useValue: {
get supportsStatusFilter() {
return supportsStatusFilter;
},
statusOf: (p: PlaylistMeta) =>
BUCKETS[p._id]
? {
reachability:
BUCKETS[p._id] === 'ok'
? 'ok'
: 'bad',
}
: null,
},
},
{
provide: SourceRemovalService,
useValue: { pendingIds: signal(new Set<string>()) },
},
{
provide: WORKSPACE_SHELL_ACTIONS,
useValue: { openAddPlaylistDialog },
},
],
});
});
it('counts each group within the other group’s choice', () => {
const element = render();
expect(counts(element)).toEqual([
'all:4',
'm3u:2',
'xtream:1',
'stalker:1',
'status-all:4',
'status-ok:2',
'status-bad:1',
]);
rows(element)[6].click();
fixture.detectChanges();
expect(counts(element).slice(0, 4)).toEqual([
'all:1',
'm3u:0',
'xtream:1',
'stalker:0',
]);
expect(TestBed.inject(SourcesViewStateService).filter()).toEqual({
type: 'all',
status: 'bad',
});
});
it('marks the chosen row of each group as pressed', () => {
const element = render();
rows(element)[2].click();
fixture.detectChanges();
expect(
rows(element).map((row) => row.getAttribute('aria-pressed'))
).toEqual([
'false',
'false',
'true',
'false',
'true',
'false',
'false',
]);
});
it('has no status group where source health is unknown', () => {
supportsStatusFilter = false;
const element = render();
expect(rows(element)).toHaveLength(4);
});
it('adds a playlist from the header', () => {
const element = render();
element
.querySelector<HTMLButtonElement>(
'[data-test-id="sources-add-playlist"]'
)
?.click();
expect(openAddPlaylistDialog).toHaveBeenCalled();
});
});
@@ -4,112 +4,139 @@ import {
computed,
inject,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import { Store } from '@ngrx/store';
import {
FilterActions,
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { SOURCE_TYPE_ICONS } from '@iptvnator/shared/interfaces';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import {
countSourcesByStatus,
countSourcesByType,
SourceBucketOf,
SourcesStatusFilter,
SourcesTypeFilter,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
SOURCE_TYPE_ICONS,
sourceStatusBucket,
} from '@iptvnator/shared/interfaces';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspaceShellContextDrawerService,
} from '@iptvnator/workspace/shell/util';
type PlaylistFilterId = 'all' | 'm3u' | 'xtream' | 'stalker';
interface PlaylistFilterOption {
id: PlaylistFilterId;
interface TypeOption {
id: SourcesTypeFilter;
icon: string;
label?: string;
translationKey?: string;
labelKey: string;
}
const ALL_FILTERS = ['m3u', 'xtream', 'stalker'];
interface StatusOption {
id: SourcesStatusFilter;
labelKey: string;
}
/**
* The Sources side list: Type and, on desktop where source health is known,
* Status. Both are single-choice and combine; each group counts within the
* other group's choice.
*/
@Component({
selector: 'app-workspace-sources-filters-panel',
imports: [MatIcon, MatListModule, TranslatePipe],
imports: [MatIcon, MatIconButton, MatTooltip, TranslatePipe],
templateUrl: './workspace-sources-filters-panel.component.html',
styleUrl: './workspace-sources-filters-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceSourcesFiltersPanelComponent {
private readonly store = inject(Store);
// Root-provided; optional keeps standalone unit tests light. Picking a type
// filter is a single-select choice that never navigates, so the phone
// drawer closes here to reveal the filtered list.
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
private readonly statusService = inject(SourceStatusService);
private readonly removal = inject(SourceRemovalService);
private readonly viewState = inject(SourcesViewStateService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS, {
optional: true,
});
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta);
readonly typeOptions: PlaylistFilterOption[] = [
// Root-provided; optional keeps standalone unit tests light. Picking a
// filter never navigates, so the phone drawer closes to reveal the list.
private readonly contextDrawer = inject(
WorkspaceShellContextDrawerService,
{
id: 'all',
icon: 'layers',
translationKey: 'WORKSPACE.SOURCES.ALL',
},
optional: true,
}
);
readonly typeOptions: readonly TypeOption[] = [
{ id: 'all', icon: 'layers', labelKey: 'WORKSPACE.SOURCES.ALL' },
{
id: 'm3u',
icon: SOURCE_TYPE_ICONS.m3u,
translationKey: 'HOME.PLAYLIST_TYPES.M3U',
labelKey: 'HOME.PLAYLIST_TYPES.M3U',
},
{
id: 'xtream',
icon: SOURCE_TYPE_ICONS.xtream,
translationKey: 'HOME.PLAYLIST_TYPES.XTREAM',
labelKey: 'HOME.PLAYLIST_TYPES.XTREAM',
},
{
id: 'stalker',
icon: SOURCE_TYPE_ICONS.stalker,
translationKey: 'HOME.PLAYLIST_TYPES.STALKER',
labelKey: 'HOME.PLAYLIST_TYPES.STALKER',
},
];
readonly statusOptions: readonly StatusOption[] = [
{ id: 'all', labelKey: 'WORKSPACE.SOURCES.STATUS_ANY' },
{ id: 'ok', labelKey: 'WORKSPACE.SOURCES.STATUS_OK' },
{ id: 'bad', labelKey: 'WORKSPACE.SOURCES.STATUS_BAD' },
];
readonly typeCounts = computed(() => {
const items = this.playlists();
return {
all: items.length,
m3u: items.filter((item) => !item.serverUrl && !item.macAddress)
.length,
xtream: items.filter((item) => !!item.serverUrl).length,
stalker: items.filter((item) => !!item.macAddress).length,
};
});
readonly showStatus = this.statusService.supportsStatusFilter;
readonly filter = this.viewState.filter;
isTypeActive(filterId: PlaylistFilterId): boolean {
const selected = this.activeTypeFilters();
if (filterId === 'all') {
return (
selected.length === ALL_FILTERS.length &&
ALL_FILTERS.every((id) => selected.includes(id))
);
}
return selected.length === 1 && selected[0] === filterId;
}
selectType(filterId: PlaylistFilterId): void {
const selectedFilters =
filterId === 'all' ? ALL_FILTERS : [filterId];
this.store.dispatch(
FilterActions.setSelectedFilters({
selectedFilters,
})
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter(
(playlist) => !pending.has(playlist._id)
);
});
private readonly bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
readonly total = computed(() => this.playlists().length);
readonly typeCounts = computed(() =>
countSourcesByType(
this.playlists(),
this.showStatus ? this.filter().status : 'all',
this.bucketOf
)
);
readonly statusCounts = computed(() =>
countSourcesByStatus(
this.playlists(),
this.filter().type,
this.bucketOf
)
);
selectType(type: SourcesTypeFilter): void {
this.viewState.setType(type);
this.contextDrawer?.close();
}
getTypeCount(filterId: PlaylistFilterId): number {
const counts = this.typeCounts();
if (filterId === 'all') {
return counts.all;
}
return counts[filterId];
selectStatus(status: SourcesStatusFilter): void {
this.viewState.setStatus(status);
this.contextDrawer?.close();
}
addPlaylist(): void {
this.workspaceActions?.openAddPlaylistDialog();
}
}
@@ -0,0 +1,53 @@
import { SortBy, SortOptions, SortOrder } from '@iptvnator/services';
export interface SourceSortOption {
by: SortBy;
order: SortOrder;
translationKey: string;
}
/** The Sources sort menu, in menu order. Custom order allows dragging. */
export const SOURCE_SORT_OPTIONS: readonly SourceSortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.UPDATED,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.UPDATED',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CHANNELS,
order: SortOrder.DESC,
translationKey: 'HOME.SORT_OPTIONS.CHANNELS',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
export function isSortOptionActive(
option: SourceSortOption,
current: SortOptions
): boolean {
return option.by === current.by && option.order === current.order;
}
@@ -1,55 +1,98 @@
<section
class="sources-page"
[class.sources-page--empty]="visibleSourcesCount() === 0"
class="sources-page app-scrollbar"
[class.sources-page--empty]="!hasPlaylists()"
>
@if (visibleSourcesCount() > 0 || canCleanSources()) {
@if (hasPlaylists()) {
<header class="sources-header">
<div class="sources-header__meta">
<div class="sources-header__title">
<h2 class="sources-title">{{ title() }}</h2>
<span class="sources-subtitle">{{ subtitle() }}</span>
<span class="sources-count" data-test-id="sources-count">{{
visibleCount()
}}</span>
</div>
@if (canCleanSources()) {
<button
mat-stroked-button
type="button"
class="cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
</button>
}
<button
type="button"
mat-stroked-button
class="sort-trigger"
[matMenuTriggerFor]="sortMenu"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{ activeSortLabel() }}</span>
<mat-icon class="sort-trigger__caret">arrow_drop_down</mat-icon>
</button>
<mat-menu #sortMenu="matMenu" xPosition="before">
@for (option of sortOptions; track option.translationKey) {
<div class="sources-header__actions">
@if (canCleanSources()) {
<button
mat-menu-item
mat-flat-button
type="button"
[appMenuItemRadio]="isSortActive(option)"
(click)="setSortOption(option)"
class="sources-action cleanup-trigger"
[attr.aria-label]="'SOURCE_CLEANUP.TITLE' | translate"
(click)="openCleanup()"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
<mat-icon>cleaning_services</mat-icon>
<span>{{ 'SOURCE_CLEANUP.TITLE' | translate }}</span>
@if (failingCount() > 0) {
<span class="sources-action__count">{{
failingCount()
}}</span>
}
</button>
}
</mat-menu>
<button
mat-flat-button
type="button"
class="sources-action sort-trigger"
[matMenuTriggerFor]="sortMenu"
[attr.aria-label]="
('WORKSPACE.REFINE_SORT_SECTION' | translate) +
': ' +
activeSortLabel()
"
>
<mat-icon>sort</mat-icon>
<span class="sort-trigger__label">{{
activeSortLabel()
}}</span>
<mat-icon class="sort-trigger__chevron" iconPositionEnd
>expand_more</mat-icon
>
</button>
<mat-menu
#sortMenu="matMenu"
class="app-menu"
xPosition="before"
>
<div role="group">
@for (option of sortOptions; track $index) {
<button
mat-menu-item
type="button"
[appMenuItemRadio]="isSortActive(option)"
(click)="setSortOption(option)"
>
<mat-icon appMenuItemRadioCheck>check</mat-icon>
<span>{{
option.translationKey | translate
}}</span>
</button>
}
</div>
@if (showFailingFirst) {
<mat-divider role="separator" />
<button
mat-menu-item
type="button"
role="menuitemcheckbox"
class="sort-failing-first"
[attr.aria-checked]="failingFirst()"
(click)="toggleFailingFirst()"
>
<mat-icon
[class.sort-failing-first__check--off]="
!failingFirst()
"
>check</mat-icon
>
<span>{{
'HOME.SORT_OPTIONS.FAILING_FIRST' | translate
}}</span>
</button>
}
</mat-menu>
</div>
</header>
}
<div
class="sources-content"
[class.app-scrollbar]="visibleSourcesCount() > 0"
>
<div class="sources-content">
<app-recent-playlists
[searchQueryInput]="searchQuery()"
(addPlaylistClicked)="onAddPlaylist($event)"
@@ -1,4 +1,4 @@
@use '../../../../../../ui/styles/panel-header' as panel;
@use '../../../../../../ui/styles/app-controls' as controls;
:host {
display: block;
@@ -6,103 +6,122 @@
min-height: 100%;
}
// The page scrolls as one: the header is not sticky.
.sources-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
min-width: 0;
overflow: hidden;
overflow-y: auto;
}
.sources-header {
@include panel.standard-panel-header($sticky: true);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 24px 32px 14px;
}
.sources-header__meta {
@include panel.standard-panel-meta();
.sources-header__title {
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
min-width: 0;
}
.sources-title {
@include panel.standard-panel-title();
margin: 0;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.4px;
line-height: 1.2;
color: var(--app-heading-color);
// A long translated title wraps rather than cuts off (content headings
// never ellipsize).
overflow-wrap: anywhere;
}
.sources-subtitle {
@include panel.standard-panel-subtitle();
.sources-count {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--app-muted-color);
}
.sort-trigger {
flex-shrink: 0;
display: inline-flex;
.sources-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
gap: 8px;
}
.sources-action {
@include controls.secondary-button(md);
app-region: no-drag;
}
.sort-trigger__label {
font-size: 0.85rem;
font-weight: 500;
}
.sources-action__count {
margin-inline-start: 8px;
padding: 1px 7px;
border-radius: 999px;
background: var(--app-danger-surface);
color: var(--app-danger-text);
font-size: 11px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sort-trigger__caret {
font-size: 18px;
width: 18px;
height: 18px;
margin-left: -2px;
}
.sort-trigger__chevron {
@include controls.trigger-chevron;
}
.sources-content {
display: flex;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px 20px 20px;
flex-direction: column;
padding: 0 20px 32px;
}
.sources-content app-recent-playlists {
display: block;
height: 100%;
.sources-page--empty .sources-content {
padding: 0;
}
.sources-page--empty {
.sources-content {
padding: 0;
overflow: hidden;
display: flex;
}
.sources-content app-recent-playlists {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
}
}
@media (max-width: 768px) {
@media (max-width: 1100px) {
.sources-header {
flex-direction: column;
align-items: flex-start;
padding: 10px 16px;
min-height: unset;
}
.sources-header__meta {
flex-direction: column;
align-items: flex-start;
gap: 4px;
.sources-action {
@include controls.secondary-button-height(36px);
}
}
@media (max-width: 640px) {
.sources-header {
padding: 16px 16px 10px;
}
.sources-title {
font-size: 22px;
}
.sources-content {
padding: 12px 16px 16px;
padding: 0 8px 24px;
}
}
.cleanup-trigger {
flex-shrink: 0;
app-region: no-drag;
// The unchecked failing-first item keeps its check slot, so its label lines
// up with the radio items above. Menu items come from this template, so the
// component's scoping reaches them inside the overlay.
.sort-failing-first__check--off {
visibility: hidden;
}
@media (max-width: 1100px) {
.cleanup-trigger span {
display: none;
}
// UI-kit menu panel. The panel renders in the overlay container, so the
// rule is global; it ships with the components that use `class="app-menu"`
// rather than in the app's initial stylesheet.
::ng-deep .mat-mdc-menu-panel.app-menu {
@include controls.menu-panel;
}
@@ -12,17 +12,24 @@ import {
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatButtonModule } from '@angular/material/button';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { provideMockStore } from '@ngrx/store/testing';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of, Subject } from 'rxjs';
import { selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import { SourceRemovalService } from '@iptvnator/playlist/shared/ui';
import { SourcesViewStateService } from '@iptvnator/playlist/shared/util';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
RuntimeCapabilitiesService,
SortBy,
SortOrder,
SortService,
} from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
@@ -41,10 +48,24 @@ class MockRecentPlaylistsComponent {
readonly addPlaylistClicked = output<void>();
}
const PLAYLISTS = [
{ _id: 'playlist-1', title: 'Playlist 1', url: 'https://a.example/1.m3u' },
{
_id: 'playlist-2',
title: 'Playlist 2',
serverUrl: 'http://x.example',
username: 'u',
password: 'p',
},
] as PlaylistMeta[];
describe('WorkspaceSourcesComponent', () => {
let fixture: ComponentFixture<WorkspaceSourcesComponent>;
let setSortOptions: jest.Mock;
beforeEach(async () => {
localStorage.clear();
setSortOptions = jest.fn();
await TestBed.configureTestingModule({
imports: [WorkspaceSourcesComponent, NoopAnimationsModule],
providers: [
@@ -62,21 +83,29 @@ describe('WorkspaceSourcesComponent', () => {
},
provideMockStore({
selectors: [
{
selector: selectActiveTypeFilters,
value: ['m3u', 'xtream', 'stalker'],
},
{
selector: selectAllPlaylistsMeta,
value: [
{
_id: 'playlist-1',
title: 'Playlist 1',
},
],
},
{ selector: selectAllPlaylistsMeta, value: PLAYLISTS },
],
}),
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSourceHealth: true },
},
{
provide: SourceStatusService,
useValue: {
supportsStatusFilter: true,
statusOf: (p: PlaylistMeta) => ({
reachability: p._id === 'playlist-2' ? 'bad' : 'ok',
}),
},
},
{
provide: SourceRemovalService,
useValue: {
pendingIds: signal(new Set<string>()),
isPending: () => false,
},
},
{
provide: ActivatedRoute,
useValue: {
@@ -91,7 +120,8 @@ describe('WorkspaceSourcesComponent', () => {
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
}),
setSortOptions: jest.fn(),
setSortOptions: (options: unknown) =>
setSortOptions(options),
},
},
{
@@ -140,6 +170,7 @@ describe('WorkspaceSourcesComponent', () => {
set: {
imports: [
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
@@ -199,35 +230,45 @@ describe('WorkspaceSourcesComponent', () => {
expect(context.protected('other')).toBe(false);
});
it('renders the shared panel header structure without paragraph subtitle margins', async () => {
it('puts a 28px title and the visible count on the page', async () => {
fixture.detectChanges();
await fixture.whenStable();
const header: HTMLElement =
fixture.nativeElement.querySelector('.sources-header');
const meta: HTMLElement = fixture.nativeElement.querySelector(
'.sources-header__meta'
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.sources-title')?.textContent?.trim()
).toBe('All Playlists');
expect(
element.querySelector('.sources-count')?.textContent?.trim()
).toBe('2');
// The page scrolls as one; the header is not a separate bar.
expect(element.querySelector('.sources-page')?.classList).toContain(
'app-scrollbar'
);
const title: HTMLElement =
fixture.nativeElement.querySelector('.sources-title');
const subtitle: HTMLElement =
fixture.nativeElement.querySelector('.sources-subtitle');
expect(header).not.toBeNull();
expect(meta).not.toBeNull();
expect(title.textContent?.trim()).toBe('All Playlists');
expect(subtitle.textContent?.trim()).toBe('1 playlist');
expect(subtitle.tagName).toBe('SPAN');
});
it('uses the shared always-visible scrollbar utility for the playlist list', async () => {
it('titles the page by the status filter and counts what it shows', async () => {
TestBed.inject(SourcesViewStateService).setStatus('bad');
fixture.detectChanges();
await fixture.whenStable();
const content: HTMLElement =
fixture.nativeElement.querySelector('.sources-content');
const element = fixture.nativeElement as HTMLElement;
expect(
element.querySelector('.sources-title')?.textContent?.trim()
).toBe('WORKSPACE.SOURCES.STATUS_BAD');
expect(
element.querySelector('.sources-count')?.textContent?.trim()
).toBe('1');
});
expect(content.classList.contains('app-scrollbar')).toBe(true);
it('counts sources that do not respond on Clean up', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pill = (fixture.nativeElement as HTMLElement).querySelector(
'.cleanup-trigger .sources-action__count'
);
expect(pill?.textContent?.trim()).toBe('1');
});
it('offers the source sorts as radio rows with the active one checked', async () => {
@@ -246,20 +287,25 @@ describe('WorkspaceSourcesComponent', () => {
'.cdk-overlay-container [mat-menu-item]'
)
);
expect(rows.map((row) => row.getAttribute('role'))).toEqual(
Array(5).fill('menuitemradio')
);
expect(rows.map((row) => row.getAttribute('role'))).toEqual([
...Array(7).fill('menuitemradio'),
'menuitemcheckbox',
]);
expect(rows.map((row) => row.getAttribute('aria-checked'))).toEqual([
'true',
'false',
'false',
'false',
'false',
...Array(7).fill('false'),
]);
for (const row of rows) {
for (const row of rows.slice(0, 7)) {
expect(row.firstElementChild?.classList).toContain(
'app-menu-item-radio-check'
);
}
rows[7].click();
expect(setSortOptions).toHaveBeenCalledWith({
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
failingFirst: true,
});
});
});
@@ -1,65 +1,81 @@
import { WorkspaceShellXtreamImportService } from '../workspace-shell/services/workspace-shell-xtream-import.service';
import { Injector, viewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import {
SourceCleanupDialogComponent,
ChangeDetectionStrategy,
Component,
computed,
inject,
Injector,
viewChild,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { SourceStatusService } from '@iptvnator/portal/shared/data-access';
import {
isLiveExternalPlayerSession,
PORTAL_EXTERNAL_PLAYBACK,
} from '@iptvnator/portal/shared/util';
import {
PlaylistRefreshActionService,
RecentPlaylistsComponent,
SourceCleanupDialogComponent,
SourceRemovalService,
} from '@iptvnator/playlist/shared/ui';
import {
RuntimeCapabilitiesService,
SourceActivityService,
filterSources,
SourceBucketOf,
SourcesViewStateService,
} from '@iptvnator/playlist/shared/util';
import {
injectTranslationTick,
RuntimeCapabilitiesService,
SortService,
SourceActivityService,
} from '@iptvnator/services';
import {
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
} from '@iptvnator/portal/shared/util';
import { PlaylistActions } from '@iptvnator/m3u-state';
PlaylistUpdateState,
sourceHealthType,
sourceStatusBucket,
} from '@iptvnator/shared/interfaces';
import {
MenuItemRadioCheckDirective,
MenuItemRadioDirective,
} from '@iptvnator/ui/components';
import {
foldSearchText,
sourceHealthType,
PlaylistUpdateState,
} from '@iptvnator/shared/interfaces';
import {
Component,
computed,
inject,
ChangeDetectionStrategy,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { RecentPlaylistsComponent } from '@iptvnator/playlist/shared/ui';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
selectActiveTypeFilters,
selectAllPlaylistsMeta,
} from '@iptvnator/m3u-state';
import { map } from 'rxjs';
import { SortBy, SortOrder, SortService } from '@iptvnator/services';
import {
WORKSPACE_SHELL_ACTIONS,
WorkspacePlaylistType,
} from '@iptvnator/workspace/shell/util';
import {
isSortOptionActive,
SOURCE_SORT_OPTIONS,
SourceSortOption,
} from './workspace-sources-sort-options';
interface SortOption {
by: SortBy;
order: SortOrder;
icon: string;
translationKey: string;
}
const TYPE_TITLE_KEYS = {
all: 'WORKSPACE.SOURCES.ALL_PLAYLISTS',
m3u: 'WORKSPACE.SOURCES.M3U_PLAYLISTS',
xtream: 'WORKSPACE.SOURCES.XTREAM_PLAYLISTS',
stalker: 'WORKSPACE.SOURCES.STALKER_PLAYLISTS',
} as const;
/**
* The Sources page: a 28px title with the visible count, Clean up (desktop,
* with a count of sources that do not respond) and the sort menu, above the
* list. The side list's filters live in `SourcesViewStateService`.
*/
@Component({
selector: 'app-workspace-sources',
imports: [
MatButtonModule,
MatDividerModule,
MatIconModule,
MatMenuModule,
MenuItemRadioCheckDirective,
@@ -68,12 +84,20 @@ interface SortOption {
TranslatePipe,
],
templateUrl: './workspace-sources.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-sources.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceSourcesComponent {
private readonly injector = inject(Injector);
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly sortService = inject(SortService);
private readonly statusService = inject(SourceStatusService);
private readonly removal = inject(SourceRemovalService);
private readonly viewState = inject(SourcesViewStateService);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly playback = inject(PORTAL_EXTERNAL_PLAYBACK, {
optional: true,
});
@@ -81,11 +105,95 @@ export class WorkspaceSourcesComponent {
optional: true,
});
private readonly sourceList = viewChild(RecentPlaylistsComponent);
private readonly languageTick = injectTranslationTick();
readonly sortOptions = SOURCE_SORT_OPTIONS;
readonly showFailingFirst = this.statusService.supportsStatusFilter;
private readonly currentSort = toSignal(this.sortService.getSortOptions(), {
requireSync: true,
});
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
private readonly allPlaylists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly playlists = computed(() => {
const pending = this.removal.pendingIds();
return this.allPlaylists().filter((p) => !pending.has(p._id));
});
private readonly bucketOf: SourceBucketOf = (playlist) => {
const status = this.statusService.statusOf(playlist);
return status ? sourceStatusBucket(status.reachability) : null;
};
readonly hasPlaylists = computed(() => this.playlists().length > 0);
readonly visibleCount = computed(() => {
const filter = this.viewState.filter();
return filterSources(
this.playlists(),
this.showFailingFirst ? filter : { ...filter, status: 'all' },
this.searchQuery(),
this.bucketOf
).length;
});
readonly title = computed(() => {
this.languageTick();
const filter = this.viewState.filter();
const key =
this.showFailingFirst && filter.status === 'bad'
? 'WORKSPACE.SOURCES.STATUS_BAD'
: TYPE_TITLE_KEYS[filter.type];
return this.translate.instant(key);
});
readonly canCleanSources = computed(
() =>
this.runtime.supportsSourceHealth &&
this.playlists().some((p) => sourceHealthType(p))
);
/** Sources last seen not responding, across every filter. */
readonly failingCount = computed(
() => this.playlists().filter((p) => this.bucketOf(p) === 'bad').length
);
readonly activeSortLabel = computed(() => {
this.languageTick();
const current = this.currentSort();
const match = this.sortOptions.find((option) =>
isSortOptionActive(option, current)
);
return this.translate.instant(
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
);
});
readonly failingFirst = computed(() => !!this.currentSort().failingFirst);
isSortActive(option: SourceSortOption): boolean {
return isSortOptionActive(option, this.currentSort());
}
setSortOption(option: SourceSortOption): void {
this.sortService.setSortOptions({
...this.currentSort(),
by: option.by,
order: option.order,
});
}
toggleFailingFirst(): void {
this.sortService.setSortOptions({
...this.currentSort(),
failingFirst: !this.failingFirst(),
});
}
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
openCleanup(): void {
const dialogs = this.injector.get(MatDialog);
const refresh = this.injector.get(PlaylistRefreshActionService);
@@ -108,7 +216,7 @@ export class WorkspaceSourcesComponent {
activity.isBusy(id) ||
refresh.isSourceBusy(id) ||
!!this.imports?.isSourceBusy(id) ||
!!this.sourceList()?.isDeletePending(id) ||
this.removal.isPending(id) ||
!!this.sourceList()?.isRefreshPending(id) ||
this.playlists().find((p) => p._id === id)?.updateState ===
PlaylistUpdateState.IN_PROGRESS,
@@ -121,156 +229,4 @@ export class WorkspaceSourcesComponent {
},
});
}
private readonly route = inject(ActivatedRoute);
private readonly store = inject(Store);
private readonly workspaceActions = inject(WORKSPACE_SHELL_ACTIONS);
private readonly translate = inject(TranslateService);
private readonly sortService = inject(SortService);
private readonly activeTypeFilters = this.store.selectSignal(
selectActiveTypeFilters
);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
);
private readonly languageTick = injectTranslationTick();
readonly sortOptions: SortOption[] = [
{
by: SortBy.DATE_ADDED,
order: SortOrder.DESC,
icon: 'schedule',
translationKey: 'HOME.SORT_OPTIONS.NEWEST',
},
{
by: SortBy.DATE_ADDED,
order: SortOrder.ASC,
icon: 'history',
translationKey: 'HOME.SORT_OPTIONS.OLDEST',
},
{
by: SortBy.NAME,
order: SortOrder.ASC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_ASC',
},
{
by: SortBy.NAME,
order: SortOrder.DESC,
icon: 'sort_by_alpha',
translationKey: 'HOME.SORT_OPTIONS.NAME_DESC',
},
{
by: SortBy.CUSTOM,
order: SortOrder.ASC,
icon: 'drag_indicator',
translationKey: 'HOME.SORT_OPTIONS.CUSTOM_ORDER',
},
];
private readonly currentSortOptions = toSignal(
this.sortService.getSortOptions(),
{ requireSync: true }
);
readonly searchQuery = toSignal(
this.route.queryParamMap.pipe(map((params) => params.get('q') ?? '')),
{ initialValue: '' }
);
readonly title = computed(() => {
this.languageTick();
const filters = this.activeTypeFilters();
if (filters.length === 1) {
if (filters[0] === 'm3u') {
return this.translateText('WORKSPACE.SOURCES.M3U_PLAYLISTS');
}
if (filters[0] === 'xtream') {
return this.translateText('WORKSPACE.SOURCES.XTREAM_PLAYLISTS');
}
if (filters[0] === 'stalker') {
return this.translateText(
'WORKSPACE.SOURCES.STALKER_PLAYLISTS'
);
}
}
return this.translateText('WORKSPACE.SOURCES.ALL_PLAYLISTS');
});
readonly visibleSourcesCount = computed(() => {
const query = foldSearchText(this.searchQuery().trim());
const filters = this.activeTypeFilters();
const allPlaylists = this.playlists();
return allPlaylists
.filter((item) => {
const isStalkerFilter =
!!item.macAddress && filters.includes('stalker');
const isXtreamFilter =
!!item.username &&
!!item.password &&
!!item.serverUrl &&
filters.includes('xtream');
const isM3uFilter =
!item.username &&
!item.password &&
!item.serverUrl &&
!item.macAddress &&
filters.includes('m3u');
return isStalkerFilter || isXtreamFilter || isM3uFilter;
})
.filter((item) => foldSearchText(item.title || '').includes(query))
.length;
});
readonly subtitle = computed(() => {
this.languageTick();
const count = this.visibleSourcesCount();
if (count === 1) {
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_ONE');
}
return this.translateText('WORKSPACE.SOURCES.PLAYLIST_COUNT_OTHER', {
count,
});
});
readonly activeSortLabel = computed(() => {
this.languageTick();
const current = this.currentSortOptions();
const match = this.sortOptions.find(
(option) =>
option.by === current.by && option.order === current.order
);
return this.translateText(
match?.translationKey ?? 'HOME.SORT_OPTIONS.NEWEST'
);
});
onAddPlaylist(type?: WorkspacePlaylistType): void {
this.workspaceActions.openAddPlaylistDialog(type);
}
isSortActive(option: SortOption): boolean {
const current = this.currentSortOptions();
return current.by === option.by && current.order === option.order;
}
setSortOption(option: SortOption): void {
this.sortService.setSortOptions({
by: option.by,
order: option.order,
});
}
private translateText(
key: string,
params?: Record<string, string | number>
): string {
return this.translate.instant(key, params);
}
}