Files
iptvnator/apps/electron-backend-e2e/src/playlist-switcher.e2e.ts
T
d8229b98fa feat(playback): record recently viewed only after the stream plays (#1732)
* feat(playback): record recently viewed only after the stream plays

A channel, movie or episode used to enter Recently Viewed (and the
dashboard's Continue Watching hero) the moment it was selected or its
link was resolved, so streams that failed straight away cluttered the
history.

Writers now defer the write to a root PlaybackHistoryGate, keyed by the
stream URL and/or the playback session key. The inline players confirm
those keys once the owned engine's position has advanced by two seconds
(seeks, stalls, pauses and a previous stream's progress do not count),
the radio player does the same, and a launched MPV/VLC session confirms
on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on
selection. Covers M3U (live, radio, movie detail), Stalker (live, radio,
VOD, series), Xtream VOD and series, and the global live collection.

The M3U host's embeddedPlayback is now compared by value: the history
write updates the playlist meta mid-playback, and a new but identical
playback object remounted the engine and restarted the stream.

E2E flows that relied on recording-on-click now play local fixtures
(HLS/TS/WebM routed in place of unreachable or public streams) and wait
for confirmed playback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): tighten recently viewed confirmation per review

- Correlate by session key first: when both the deferred write and the
  confirmation carry a playbackSessionKey, only that is compared, so the
  same stream URL played in another playlist no longer records a failed
  attempt. URLs remain the fallback (portal writes, MPV/VLC sessions).
  The M3U radio player now receives the host's session key.
- Count only playing progress: engines report `playing` (not paused, not
  seeking) with each time update, so short seeks of paused media no
  longer confirm a view.
- Xtream: a write confirmed after a playlist switch still saves to its own
  playlist but no longer replaces the current playlist's recent list.
- Global live tab: a row confirmed after another row was selected still
  moves to the top of an open Recently Viewed list (only a disposed tab
  skips the notification).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): confirm session-keyed history only by its own session

A write deferred with a playback session key (M3U) is now confirmed only
by that key. The app-wide MPV/VLC session confirmation carries just the
URL, so opening the same stream externally from another playlist could
still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is
instead confirmed by the WebPlayerViewComponent that requested it, under
its own session key, once the launch has opened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(playback): keep the history gate off the initial bundle

The `@iptvnator/services` barrel ships in the initial bundle, so adding
PlaybackHistoryGate there (and subscribing to it from the app-wide
ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes.

- Move the gate to a new lazy-only `playback-data-access` project
  (`@iptvnator/playback/data-access`; scope:shared, domain:playback,
  type:data-access) and register it in the coverage policy.
- The gate subscribes to MPV/VLC session updates itself; it is created by
  the first deferred write, which precedes the launch it waits for.
  ExternalPlaybackService is back to master.
- The Xtream "playlist switched before confirmation" check moves to the
  lazy helper; the initial-path store only takes a `skipListRefresh` flag.

Net effect on this branch: +27 bytes over master (master itself is
108 bytes over the ratchet baseline already).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(playback): drop late Xtream confirmations off the initial path

The Xtream store ships in the initial bundle, so even the small
`skipListRefresh` flag cost 27 bytes there. A confirmation can only
arrive after a switch to another playlist from a slow MPV/VLC launch
(the inline player goes with the page), so the lazy helper now drops it
instead: recording it would misfile the item or replace the other
playlist's recent list. with-recent-items is back to master.

This branch is now 3 bytes below master on renderer.initialBytes; the
ratchet still reports master's pre-existing overage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(playback): pass the spec type-check gate from master

- playback-data-access: align tsconfig.spec.json with the epg-data-access
  config #1705 updated (bundler resolution, global.d.ts for window.electron).
- M3U recent-history spec: type the selectSignal override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep late Xtream confirmations in their own playlist history

A confirmation that arrives after a switch to another playlist (a slow
MPV/VLC launch) is no longer dropped: the lazy helper saves it to the
captured playlist through the data source, without reloading the store's
recent list, which belongs to the other playlist by then. The store and its
barrel ship in the initial bundle, so the save path stays in the feature
helper; renderer.initialBytes stays under the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): correlate global live-tab history by its session key

The unified Favorites/Recent live tab deferred its history write by stream
URL only, so the same URL played from another playlist could confirm a
failed selection, and a switch to catch-up before confirmation could never
match. It now defers with the tab's playlist-scoped playbackSessionKey (the
key its players confirm with), and the tab's radio player receives it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep MPV/VLC rows of the live tab confirmable by URL

The live tab's session key can only be confirmed by its own inline
players; MPV/VLC confirm the launched URL alone. A row that goes to an
external player (also later, after a double-click) now defers by URL, and
only rows played inline carry the session key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): per-channel M3U history attempts, capability-based Xtream fallback

- M3U: the recently-viewed dedupe key now includes the channel id, so a
  second row of the same URL defers its own write (its session key) and
  is recorded when it plays after the first row failed.
- Xtream late write: key uncached content by Xtream id per
  supportsXtreamSqliteDataSource (the data-source factory's contract), not
  by a generic Electron bridge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:23:06 +02:00

519 lines
18 KiB
TypeScript

import { Page } from '@playwright/test';
import {
channelItemByTitle,
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openPlaylistFavorites,
openPlaylistRecent,
openSettings,
openSourceEditor,
openSources,
openWorkspaceSection,
playlistSwitcherTitle,
resetMockServers,
restartElectronApp,
saveSettings,
saveSourceDialog,
sourceRowByTitle,
test,
updateSourceDialog,
waitForDbOperationEvent,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamCatalog,
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
const xtreamStressUsername = 'stress';
const xtreamStressPassword = 'stress';
const dbWorkerBatchDelayMs = '20';
const localAFileName = 'switcher-local-a.m3u';
const localADisplayName = 'switcher-local-a';
const localBFileName = 'switcher-local-b.m3u';
const localBDisplayName = 'switcher-local-b';
const localAChannelName = 'Switcher Favorite A';
const localBChannelName = 'Switcher Favorite B';
const offlineLocalFileName = 'offline-local-source.m3u';
const offlineLocalDisplayName = 'offline-local-source';
const xtreamAName = 'Switcher Xtream A';
const xtreamBName = 'Switcher Xtream B';
const stalkerAName = 'Switcher Stalker A';
const stalkerBName = 'Switcher Stalker B';
const offlineXtreamName = 'Offline Cached Xtream';
const importingXtreamName = 'Importing Xtream Portal';
test.describe('Electron Playlist Switcher', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(120000);
test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(app.mainWindow, {
name: xtreamAName,
});
await addXtreamPortal(app.mainWindow, {
name: xtreamBName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerAName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerBName,
});
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
localBDisplayName
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
xtreamAName
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamBName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await openWorkspaceSection(app.mainWindow, 'Recently added');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/recently-added$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerAName);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/recent$/
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/all$/
);
await openPlaylistFavorites(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
stalkerBName
);
} finally {
await closeElectronApp(app);
}
});
test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({
dataDir,
}) => {
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: localAChannelName,
url: 'https://streams.example.test/switcher-favorite-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: localBChannelName,
url: 'https://streams.example.test/switcher-favorite-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, localAChannelName)
.first()
.click()
);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localBDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localBChannelName);
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
channelItemByTitle(app.mainWindow, localBChannelName)
.first()
.click()
);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Import Switch Local',
url: 'https://streams.example.test/import-switch-local.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
// Keep the slowed-down import in 100-row commits so the switch
// below still happens mid-import (see xtream-responsiveness).
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION: '100',
},
});
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await waitForM3uCatalog(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: importingXtreamName,
password: xtreamStressPassword,
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
predicate: (event) =>
event.status === 'progress' &&
(event.current ?? 0) > 0 &&
(event.total ?? 0) > 0,
timeoutMs: 30000,
});
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod$/
);
await Promise.race([
overlay.waitFor({ state: 'visible', timeout: 15000 }),
waitForXtreamCatalog(app.mainWindow),
]);
await waitForXtreamCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
const startedBeforeReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await app.mainWindow.waitForTimeout(1200);
const startedAfterReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
expect(startedAfterReturn).toBe(startedBeforeReturn);
} finally {
await closeElectronApp(app);
}
});
test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const offlineLocalPath = writeTemporaryM3uFile(
dataDir,
offlineLocalFileName,
[
{
groupTitle: 'News',
name: 'Offline Local Channel',
url: 'https://streams.example.test/offline-local.m3u8',
},
]
);
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: offlineXtreamName,
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
await openWorkspaceSection(app.mainWindow, 'Movies');
await waitForXtreamCatalog(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, offlineLocalPath);
await waitForM3uCatalog(app.mainWindow);
await openSources(app.mainWindow);
const dialog = await openSourceEditor(
app.mainWindow,
offlineXtreamName
);
await updateSourceDialog(dialog, {
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
});
await saveSourceDialog(app.mainWindow, dialog);
await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
}
async function switchPlaylistFromHeader(
page: Page,
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 20000 })
.toMatch(pattern);
}
async function countDbEvents(
page: Page,
operation: string,
status: string
): Promise<number> {
return page.evaluate(
({ operation, status: desiredStatus }) =>
(window.__dbOperationEvents ?? []).filter(
(event) =>
event.operation === operation &&
event.status === desiredStatus
).length,
{ operation, status }
);
}
async function selectSettingsOption(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible({ timeout: 20000 });
await item.hover();
await item.locator('.favorite-button').first().click();
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(/star/);
}