Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa

# Conflicts:
#	docs/architecture/pwa-self-hosted.md
This commit is contained in:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: backup
---
Backups made in the browser and self-hosted versions now include Xtream favorites, recently viewed items and resume positions. Restoring such a backup brings them back right away, under whichever playlist the portal is imported as.
+8
View File
@@ -0,0 +1,8 @@
---
type: fix
area: m3u
issues: [1894]
---
Groups hidden in "Manage groups" stay hidden after the desktop app restarts,
instead of every group of the playlist showing again.
@@ -0,0 +1,9 @@
---
type: fix
area: playback
issues: [1902]
---
Embedded MPV no longer skips to the next episode when the provider drops the
connection in the middle of an episode. It now reconnects and continues the
episode where it stopped; a movie interrupted the same way resumes as well.
@@ -0,0 +1,6 @@
---
type: fix
area: playlist
---
In the desktop app, emptying an optional playlist field such as User-Agent, Referrer, Origin, the login details, the EPG sources or the file path now stays empty after saving instead of bringing the old value back.
+6
View File
@@ -0,0 +1,6 @@
---
type: fix
area: portals
---
Movie and series details and catalog cards from Stalker and Xtream portals no longer show "N/A", "-" or a zero rating in place of missing information. Fields the provider left empty are simply hidden, and TMDB can now fill in the rating and release year for them.
+6
View File
@@ -0,0 +1,6 @@
---
type: perf
area: search
---
Search results appear about twice as fast after you stop typing: the search now waits once for typing to pause instead of twice.
@@ -0,0 +1,9 @@
---
type: fix
area: workspace
---
Opening an already-imported Xtream portal no longer flashes the "Syncing
playlist" overlay for a split second before the catalog appears. The overlay
still shows for real downloads and for saved catalogs that take a while to
load.
+2
View File
@@ -353,6 +353,8 @@ jobs:
--only open-source/renderer.layoutShiftScore
--only playback/renderer.httpRequestsToPlaying
--only playback/renderer.layoutShiftScore
--only search/renderer.ipcCallsPerSearch
--only search/renderer.sqlStatementsPerSearch
- name: Upload journey summaries
if: always()
@@ -0,0 +1,200 @@
import type { APIRequestContext, Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamImportToFinish,
xtreamMockServer,
} from './electron-test-fixtures';
import { installEmbeddedMpvSessionCapture } from './embedded-mpv-frame-copy-packaged-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
/**
* Issue #1902: an episode whose connection drops reaches mpv as EOF in the
* middle of the episode, and series autoplay used to take that for the end
* and start the next episode. The `streamdrop` mock cuts every episode a
* third of the way into its 60 s clip and refuses it for a few seconds, so
* ffmpeg's own reconnect fails and only the app-level reconnect can resume.
* Needs the native-view Embedded MPV runtime; skips where it is missing.
*/
const credentials = { password: 'streamdrop', username: 'streamdrop' };
/** Past the ~21 s cut: the resumed episode has played on. */
const RESUMED_POSITION_SECONDS = 25;
/** Keyframe rounding and the 500 ms poll before the drop. */
const RESUME_TOLERANCE_SECONDS = 3;
type SessionSample = {
episodeId: number | null;
positionSeconds: number;
reconnecting: boolean;
status: string;
};
async function fetchSeasonOneEpisodeIds(
request: APIRequestContext,
seriesId: number
): Promise<number[]> {
const params = new URLSearchParams({
action: 'get_series_info',
series_id: String(seriesId),
...credentials,
});
const response = await request.get(
`${xtreamMockServer}/player_api.php?${params}`
);
expect(response.ok()).toBeTruthy();
const info = (await response.json()) as {
episodes?: Record<string, { id: string; episode_num: number }[]>;
};
return [...(info.episodes?.['1'] ?? [])]
.sort((a, b) => a.episode_num - b.episode_num)
.map((episode) => Number(episode.id));
}
/**
* Session updates in arrival order, each tagged with the episode its session
* last loaded: the `loading` update carries the requested URL, while later
* snapshots report mpv's `path`, which not every engine fills in reliably.
*/
function readSessions(page: Page): Promise<SessionSample[]> {
return page.evaluate(() => {
const episodeBySession = new Map<string, number>();
return (window.__packagedEmbeddedMpvSessions ?? []).map((session) => {
const match = /\/series\/[^/]+\/[^/]+\/(\d+)\.\w+$/.exec(
session.streamUrl
);
if (match) {
episodeBySession.set(session.id, Number(match[1]));
}
return {
episodeId: episodeBySession.get(session.id) ?? null,
positionSeconds: session.positionSeconds,
reconnecting: !!session.reconnect,
status: session.status,
};
});
});
}
/**
* What followed the first episode's mid-episode EOF, in update order. Every
* position played after the drop must stay near the drop: a reload that
* restarted the episode would otherwise reach the resumed position as well.
* A zero is the engine's placeholder before the reload's first `time-pos`.
*/
function outcomeAfterDrop(
sessions: SessionSample[],
[first, second]: number[]
): 'pending' | 'resumed' | 'restarted' | 'skipped' {
const dropped = sessions.findIndex(
(session) => session.episodeId === first && session.status === 'ended'
);
if (dropped < 0) {
return 'pending';
}
const dropPosition = sessions[dropped].positionSeconds;
for (const session of sessions.slice(dropped)) {
if (session.episodeId === second) {
return 'skipped';
}
if (session.episodeId !== first || session.status !== 'playing') {
continue;
}
if (
session.positionSeconds > 0 &&
session.positionSeconds < dropPosition - RESUME_TOLERANCE_SECONDS
) {
return 'restarted';
}
if (session.positionSeconds >= RESUMED_POSITION_SECONDS) {
return 'resumed';
}
}
return 'pending';
}
test('@playback @xtream @electron @embedded-mpv resumes an episode whose connection drops instead of skipping to the next one', async ({
dataDir,
request,
}) => {
test.setTimeout(180_000);
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamSeriesFixture(request, credentials);
const series = fixture.items[0];
if (!series) {
throw new Error('The stream-drop scenario has no series.');
}
const episodeIds = await fetchSeasonOneEpisodeIds(
request,
Number(series.series_id)
);
expect(episodeIds).toHaveLength(3);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_ENABLE_EMBEDDED_MPV_EXPERIMENT: '1',
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY: '0',
IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW: '1',
},
});
try {
const support = await app.mainWindow.evaluate(() =>
window.electron.getEmbeddedMpvSupport()
);
test.skip(
!support.supported || support.engine !== 'native',
`Native-view Embedded MPV unavailable: ${JSON.stringify(support)}`
);
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'playback');
await app.mainWindow.getByTestId('select-video-player').click();
await app.mainWindow.getByTestId('embedded-mpv').click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: 'Stream Drop',
});
await waitForXtreamImportToFinish(app.mainWindow);
await installEmbeddedMpvSessionCapture(app);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, getXtreamTitle(series));
const episode = app.mainWindow
.locator('.episode-card, .episode-list-item')
.first();
await expect(episode).toBeVisible({ timeout: 20_000 });
await episode.click();
await expect
.poll(
async () =>
outcomeAfterDrop(
await readSessions(app.mainWindow),
episodeIds
),
{ timeout: 90_000 }
)
.not.toBe('pending');
const sessions = await readSessions(app.mainWindow);
expect(outcomeAfterDrop(sessions, episodeIds)).toBe('resumed');
// The drop was shown as a reconnect, not as the episode's end.
expect(sessions.some((session) => session.reconnecting)).toBe(true);
} finally {
await closeElectronApp(app);
}
});
@@ -60,7 +60,7 @@ export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
/** Well below the shell's 300 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
@@ -0,0 +1,301 @@
import type { Locator, Page } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import {
closeElectronApp,
electronMainPath,
expect,
type LaunchedElectronApp,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openSources,
openWorkspaceSection,
restartElectronApp,
sourceRowByTitle,
test,
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
const SOURCE_TITLE = 'group-management-source';
const HIDDEN_GROUPS = ['Movies', 'Sports'];
const VISIBLE_HIDDEN_GROUPS_KEY = '__e2eVisibleHiddenGroups';
const RECONCILE_CHANNEL = 'EPG_RECONCILE_SOURCES';
interface ReconcileScript {
calls: number;
gate: Promise<void>;
release: () => void;
}
type ReconcileGlobal = typeof globalThis & {
__e2eReconcileScript?: ReconcileScript;
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installReconcileScript(
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
channel: string
): void {
const register = ipcMain.handle;
const scope = globalThis as ReconcileGlobal;
ipcMain.handle = function (name, listener) {
if (name !== channel) {
return register.call(this, name, listener);
}
return register.call(this, name, async (...args) => {
const script = scope.__e2eReconcileScript;
if (script) {
script.calls += 1;
if (script.calls === 1) return { success: false };
await script.gate;
}
return listener(...args);
});
};
}
function launchWithReconcileScript(
dataDir: string
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'reconcile-script.cjs');
writeFileSync(
entryPoint,
[
`(${installReconcileScript.toString()})(require('electron'), ${JSON.stringify(RECONCILE_CHANNEL)});`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/**
* The next renderer's startup reconciliation fails, and the retry the
* playlist inventory awaits is parked until `release`: the M3U route then
* activates (its settings resolver has finished) while the store still has no
* playlists.
*/
async function delayPlaylistInventory(app: LaunchedElectronApp) {
await app.electronApp.evaluate(() => {
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
(globalThis as ReconcileGlobal).__e2eReconcileScript = {
calls: 0,
gate,
release,
};
});
return {
waitUntilParked: () =>
expect
.poll(() =>
app.electronApp.evaluate(
() =>
(globalThis as ReconcileGlobal).__e2eReconcileScript
?.calls ?? 0
)
)
.toBeGreaterThan(1),
release: () =>
app.electronApp.evaluate(() => {
const scope = globalThis as ReconcileGlobal;
const script = scope.__e2eReconcileScript;
delete scope.__e2eReconcileScript;
script?.release();
}),
};
}
const CHANNELS = ['News', 'Sports', 'Movies'].flatMap((group) =>
[1, 2].map((index) => ({
groupTitle: group,
name: `${group} Channel ${index}`,
url: `https://streams.example.test/${group.toLowerCase()}-${index}.m3u8`,
}))
);
function groupNavLabels(page: Page): Locator {
return page.locator('app-groups-view .group-nav-item .nav-item-label');
}
async function openGroupManagement(page: Page): Promise<Locator> {
await page
.getByRole('button', { name: 'Manage groups', exact: true })
.click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog.locator('.group-item')).toHaveCount(3);
return dialog;
}
function groupCheckbox(dialog: Locator, group: string): Locator {
return dialog
.locator('.group-item')
.filter({ hasText: group })
.getByRole('checkbox');
}
async function storedHiddenGroupTitles(page: Page): Promise<unknown> {
return page.evaluate(async () => {
const playlistId = location.pathname.match(
/\/workspace\/playlists\/([^/]+)/
)?.[1];
if (!playlistId) throw new Error('M3U playlist route is missing');
const api = (window as unknown as { electron: ElectronBridgeApi })
.electron;
const playlist = (await api.dbGetAppPlaylist(playlistId)) as {
hiddenGroupTitles?: unknown;
} | null;
const hidden = playlist?.hiddenGroupTitles;
return Array.isArray(hidden) ? [...hidden].sort() : hidden;
});
}
/** Imports the playlist and hides {@link HIDDEN_GROUPS} in Manage groups. */
async function importAndHideGroups(
app: LaunchedElectronApp,
dataDir: string
): Promise<void> {
const filePath = writeTemporaryM3uFile(
dataDir,
`${SOURCE_TITLE}.m3u`,
CHANNELS
);
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText([
'Movies',
'News',
'Sports',
]);
const dialog = await openGroupManagement(app.mainWindow);
for (const group of HIDDEN_GROUPS) {
await groupCheckbox(dialog, group).uncheck();
}
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog).toBeHidden();
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
await expect
.poll(() => storedHiddenGroupTitles(app.mainWindow))
.toEqual(HIDDEN_GROUPS);
}
/**
* Records, from the start of every new document, each hidden group the
* groups rail ever renders visibly — including before the startup inventory
* publishes the playlist.
*/
async function recordVisibleHiddenGroups(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.context().addInitScript(
({ hiddenGroups, key }) => {
const seen: string[] = [];
Reflect.set(window, key, seen);
const check = () => {
for (const item of document.querySelectorAll(
'app-groups-view .group-nav-item'
)) {
const group = item.getAttribute('data-group-key') ?? '';
if (
hiddenGroups.includes(group) &&
!seen.includes(group) &&
item.checkVisibility()
) {
seen.push(group);
}
}
};
new MutationObserver(check).observe(document, {
attributes: true,
attributeFilter: ['class', 'style'],
childList: true,
subtree: true,
});
},
{ hiddenGroups: HIDDEN_GROUPS, key: VISIBLE_HIDDEN_GROUPS_KEY }
);
}
test.describe('Electron M3U group management', () => {
test('keeps groups hidden in Manage groups after a restart (#1894)', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importAndHideGroups(app, dataDir);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, SOURCE_TITLE)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
// The dialog opens from the restored state, so saving it again
// cannot silently unhide the groups.
const reopened = await openGroupManagement(app.mainWindow);
await expect(groupCheckbox(reopened, 'News')).toBeChecked();
await expect(groupCheckbox(reopened, 'Movies')).not.toBeChecked();
await expect(groupCheckbox(reopened, 'Sports')).not.toBeChecked();
await reopened
.getByRole('button', { name: 'Close', exact: true })
.click();
await expect(reopened).toBeHidden();
} finally {
await closeElectronApp(app);
}
});
test('never shows hidden groups when the Groups route opens before the playlist inventory', async ({
dataDir,
}) => {
const app = await launchWithReconcileScript(dataDir);
try {
await importAndHideGroups(app, dataDir);
await recordVisibleHiddenGroups(app);
const inventory = await delayPlaylistInventory(app);
// A reload boots a fresh store straight on this route.
await reloadFromMainProcess(app);
await inventory.waitUntilParked();
// The route session has read the playlist and published its
// channels, still behind the startup screen, before the store
// has the playlist the hidden groups belong to.
const rail = app.mainWindow.locator(
'app-groups-view .group-nav-item'
);
await expect(rail).toHaveCount(3);
await expect(rail.first()).toBeHidden();
await inventory.release();
await expectRendererReloadedOnRoute(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/groups$/
);
await expect(groupNavLabels(app.mainWindow)).toHaveText(['News']);
expect(
await app.mainWindow.evaluate(
(key) => Reflect.get(window, key),
VISIBLE_HIDDEN_GROUPS_KEY
)
).toEqual([]);
} finally {
await closeElectronApp(app);
}
});
});
@@ -34,7 +34,7 @@ export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* Longest accepted gap between two keydowns. Below the shell's 300 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/
@@ -46,7 +46,7 @@ function runPlaylistUpdateScenario(scenario: string): unknown {
const { default: Database } = await import('better-sqlite3');
const { drizzle } = await import('drizzle-orm/better-sqlite3');
const schema = await import('@iptvnator/shared/database/schema');
const { updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { getAppPlaylist, getAppPlaylistMetas, updatePlaylist, upsertAppPlaylist, upsertAppPlaylists } = await import(${JSON.stringify(operationsUrl)});
const { setPlaylistServerTimezone } = await import(${JSON.stringify(timezoneOperationsUrl)});
const { __databaseConnectionTestHooks } = await import(${JSON.stringify(connectionUrl)});
const sqlite = new Database(':memory:');
@@ -317,6 +317,91 @@ describe('playlist.operations', () => {
expect(result['moved'].payload).not.toHaveProperty('serverTimezone');
});
it('clears the optional columns a full upsert leaves empty, so a cleared field stays cleared', () => {
// The codec maps an empty value to `undefined`, which ON CONFLICT DO
// UPDATE skips; reads prefer the column, so the old value would come
// back. Covers the single and batch writers and both conflict-update
// shapes (a clockless document and one that carries its own clock).
const ids = ['single', 'batch', 'with-clock'];
const result = runPlaylistUpdateScenario(`
const source = (id, extra = {}) => ({
_id: id,
title: id,
type: 'm3u-url',
url: 'http://example.com/list.m3u',
userAgent: 'Agent/1',
referrer: 'http://ref.example',
origin: 'http://origin.example',
username: 'demo',
password: 'secret',
filePath: '/playlists/list.m3u',
manualEpgUrls: ['http://epg.example/manual.xml'],
favorites: ['1'],
...(id === 'with-clock' ? { serverTimezone: 'UTC' } : {}),
...extra,
});
const cleared = {
userAgent: '',
referrer: ' ',
origin: '',
username: '',
password: '',
filePath: undefined,
manualEpgUrls: undefined,
};
for (const id of ${JSON.stringify(ids)}) {
await upsertAppPlaylist(db, source(id));
}
await upsertAppPlaylist(db, source('single', cleared));
await upsertAppPlaylists(db, [source('batch', cleared)]);
await upsertAppPlaylist(db, source('with-clock', cleared));
const docs = {};
for (const id of ${JSON.stringify(ids)}) {
docs[id] = await getAppPlaylist(db, id);
}
const metas = Object.fromEntries(
(await getAppPlaylistMetas(db)).map((meta) => [meta._id, meta])
);
const rows = Object.fromEntries(
sqlite.prepare('SELECT id, date_created FROM playlists').all()
.map((row) => [row.id, row.date_created])
);
process.stdout.write(JSON.stringify({ docs, metas, rows }));
`) as {
docs: Record<string, Record<string, unknown>>;
metas: Record<string, Record<string, unknown>>;
rows: Record<string, string | null>;
};
for (const id of ids) {
// Metadata reads see only the columns; the full read merges the
// payload in. Both must lose every cleared field.
for (const playlist of [result.docs[id], result.metas[id]]) {
expect(playlist).toEqual(
expect.objectContaining({
_id: id,
title: id,
url: 'http://example.com/list.m3u',
favorites: ['1'],
manualEpgUrls: [],
})
);
for (const field of [
'userAgent',
'referrer',
'origin',
'username',
'password',
'filePath',
]) {
expect(playlist).not.toHaveProperty(field);
}
}
expect(result.rows[id]).toEqual(expect.any(String));
}
expect(result.docs['with-clock']['serverTimezone']).toBe('UTC');
});
it('loads app playlist metadata without selecting the large payload column', async () => {
const from = jest.fn().mockResolvedValue([
{
@@ -142,6 +142,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
};
let status: EmbeddedMpvSessionStatus;
let positionSeconds: number;
let durationSeconds: number | null;
let recordingActive: boolean;
let errorOrigin: 'playback' | 'engine' | undefined;
let rejectedOptionKeys: string[] | undefined;
@@ -179,6 +180,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
service = new EmbeddedMpvNativeService();
status = 'idle';
positionSeconds = 0;
durationSeconds = null;
recordingActive = false;
errorOrigin = undefined;
rejectedOptionKeys = undefined;
@@ -193,7 +195,7 @@ describe('EmbeddedMpvNativeService reconnect', () => {
getSessionSnapshot: jest.fn<MockSnapshot | null, [string]>(() => ({
status,
positionSeconds,
durationSeconds: null,
durationSeconds,
volume: 1,
streamUrl: LIVE.streamUrl,
recording: { active: recordingActive },
@@ -596,6 +598,63 @@ describe('EmbeddedMpvNativeService reconnect', () => {
});
});
it('treats EOF in the middle of an episode as a drop, not as its end (#1902)', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
// mpv drained its cache after the server closed the connection.
poll('ended');
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(1);
status = 'loading';
jest.advanceTimersByTime(2_000);
expect(addon.loadPlayback).toHaveBeenLastCalledWith('session-1', {
...VOD,
startTime: 1_260,
});
// The genuine end of the episode stays the end.
positionSeconds = 2_700;
poll('playing');
poll('ended');
jest.advanceTimersByTime(60_000);
expect(lastUpdate()?.status).toBe('ended');
expect(lastUpdate()?.reconnect).toBeUndefined();
expect(addon.loadPlayback).toHaveBeenCalledTimes(2);
});
it('forgets where the reload resumed once the user seeks', () => {
service.createSession(BOUNDS, 'Title', 0.5, {
extraOptions: [],
autoReconnect: true,
});
service.loadPlayback('session-1', VOD);
poll('loading');
positionSeconds = 1_260;
durationSeconds = 2_700;
poll('playing');
poll('ended');
status = 'loading';
jest.advanceTimersByTime(2_000);
poll('playing');
// A seek away and back: the next early EOF near the old resume
// point is a new drop, not the reload ending where it resumed.
service.seek('session-1', 600);
service.seekBy('session-1', 662);
poll('ended');
expect(lastUpdate()?.reconnect?.attempt).toBe(2);
});
it('treats EOF on a live stream as a drop', () => {
startPlayingLive();
@@ -741,6 +741,7 @@ export class EmbeddedMpvNativeService {
seek(sessionId: string, seconds: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.onUserSeek(sessionId);
this.getAddon().seek(sessionId, seconds);
return this.refreshSession(sessionId);
}
@@ -760,6 +761,7 @@ export class EmbeddedMpvNativeService {
if (!Number.isFinite(deltaSeconds)) {
return this.refreshSession(sessionId);
}
this.onUserSeek(sessionId);
if (typeof addon.seekBy === 'function') {
addon.seekBy(sessionId, deltaSeconds);
} else {
@@ -770,6 +772,14 @@ export class EmbeddedMpvNativeService {
return this.refreshSession(sessionId);
}
/** A seek invalidates the reconnect's resume point (embedded-mpv-reconnect.ts). */
private onUserSeek(sessionId: string): void {
const session = this.sessions.get(sessionId);
if (session) {
this.reconnect.onUserSeek(session.reconnect);
}
}
setVolume(sessionId: string, volume: number): EmbeddedMpvSession | null {
this.assertEmbeddedMpvEnabled();
this.getAddon().setVolume(sessionId, volume);
@@ -1333,7 +1343,8 @@ export class EmbeddedMpvNativeService {
payload.status,
previousStatus,
payload.positionSeconds,
snapshot.errorOrigin
snapshot.errorOrigin,
payload.durationSeconds
);
if (reconnect) {
payload.reconnect = reconnect;
@@ -197,6 +197,328 @@ describe('EmbeddedMpvReconnectCoordinator', () => {
expect(reload).not.toHaveBeenCalled();
});
it('treats a VOD EOF well before the end as a drop and resumes there (#1902)', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_260,
undefined,
2_700
);
// The server closed the connection 21 min into a 45 min episode.
const info = coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_261,
undefined,
2_700
);
expect(info?.attempt).toBe(1);
// The poll keeps reading the same EOF until the attempt fires.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_261,
undefined,
2_700
)
).toBe(info);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenCalledTimes(1);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 1_260,
});
});
it('keeps a VOD EOF at the end, or with no known duration, as the end', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_670,
undefined,
2_700
);
// 4 s short of a duration rounded down to whole seconds.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_696,
undefined,
2_700
)
).toBeNull();
// A long film's closing seconds fall inside the 1 % margin.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
7_150,
undefined,
7_200
)
).toBeNull();
// Without a duration nothing says the EOF came early.
state = createEmbeddedMpvReconnectState(true);
startPlaying(VOD);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
null
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).not.toHaveBeenCalled();
});
it('accepts the end when the reload cannot get past where it resumed', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
2_400,
undefined,
2_700
);
// The duration was estimated from the bitrate; the file ends at 2 401 s.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
2_401,
undefined,
2_700
)?.attempt
).toBe(1);
jest.advanceTimersByTime(2_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 2_400,
});
coordinator.observe(SESSION, state, 'loading', 'ended');
expect(
coordinator.observe(
SESSION,
state,
'ended',
'loading',
2_401,
undefined,
2_700
)
).toBeNull();
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
2_401,
undefined,
2_700
)
).toBeNull();
jest.advanceTimersByTime(60_000);
expect(reload).toHaveBeenCalledTimes(1);
});
it('reconnects again when the resumed episode drops further on', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
600,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
601,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
900,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
901,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenLastCalledWith(SESSION, {
...VOD,
startTime: 900,
});
});
it('keeps retrying an early EOF when the addon refuses the reload', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
reload.mockImplementationOnce(() => {
throw new Error('addon exploded');
});
jest.advanceTimersByTime(2_000);
// The refused reload left the original EOF in the snapshot: it is
// still the drop, not a reload that ended where it resumed.
expect(
coordinator.observe(
SESSION,
state,
'ended',
'ended',
1_200,
undefined,
2_700
)?.attempt
).toBe(2);
jest.advanceTimersByTime(4_000);
expect(reload).toHaveBeenCalledTimes(2);
});
it('judges an EOF after a user seek on its position alone', () => {
startPlaying(VOD);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_200,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_200,
undefined,
2_700
);
jest.advanceTimersByTime(2_000);
coordinator.observe(SESSION, state, 'loading', 'ended');
coordinator.observe(
SESSION,
state,
'playing',
'loading',
1_201,
undefined,
2_700
);
// Back to 10 min; the connection drops again a few minutes later.
coordinator.onUserSeek(state);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
600,
undefined,
2_700
);
coordinator.observe(
SESSION,
state,
'playing',
'playing',
1_202,
undefined,
2_700
);
expect(
coordinator.observe(
SESSION,
state,
'ended',
'playing',
1_203,
undefined,
2_700
)?.attempt
).toBe(2);
});
it('resumes a VOD reload where it dropped but keeps live at the live edge', () => {
startPlaying(VOD);
coordinator.observe(SESSION, state, 'playing', 'playing', 95);
@@ -16,8 +16,13 @@ import { isEmbeddedMpvSessionGoneError } from './embedded-mpv-session-errors';
* - Only a load that already reached `playing` is retried. A URL that never
* worked (404, refused credentials, unsupported container) keeps the manual
* Retry; hammering a panel six times with the same bad request helps nobody.
* - EOF counts as a loss only for live playback — a broadcast never ends on
* its own, a movie does.
* - EOF counts as a loss for live playback — a broadcast never ends on its
* own. A movie or episode does, so on VOD only an EOF well before the
* known duration counts: mpv reports a connection that the server closed
* and ffmpeg could not reopen as EOF, and taking it for the end made
* series autoplay skip the rest of the episode (#1902). A reload that
* ends again where it resumed proves the file ends there (a duration
* estimated from the bitrate can overshoot), and is left as the end.
* - The attempt budget belongs to one outage: it resets after
* {@link EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS} of uninterrupted playing,
* not on the first `playing`, so a stream that flaps every few seconds runs
@@ -48,6 +53,15 @@ export const EMBEDDED_MPV_RECONNECT_MAX_ATTEMPTS = 6;
export const EMBEDDED_MPV_RECONNECT_BASE_DELAY_MS = 2_000;
export const EMBEDDED_MPV_RECONNECT_MAX_DELAY_MS = 30_000;
export const EMBEDDED_MPV_RECONNECT_STABLE_PLAYBACK_MS = 30_000;
/**
* A VOD EOF is the end of the file when less than this much remained
* (seconds, or the ratio of the duration when that is longer): the margin
* absorbs frame and container rounding and duration estimates.
*/
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS = 10;
export const EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO = 0.01;
/** Playback a resumed reload must make before another early EOF counts. */
export const EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS = 5;
export interface EmbeddedMpvReconnectState {
/** `Settings.embeddedMpvAutoReconnect`, captured at session creation. */
@@ -74,6 +88,12 @@ export interface EmbeddedMpvReconnectState {
* is trusted as a real seek-to-start only once that has happened.
*/
positionReported: boolean;
/**
* Where the last VOD reload resumed, until playback gets past it or the
* user seeks. An early EOF before then ended where the file does, not
* where it dropped.
*/
resumedAtSeconds: number | null;
timer: NodeJS.Timeout | null;
/** What the renderer is shown while an attempt is scheduled or in flight. */
pending: EmbeddedMpvReconnectInfo | null;
@@ -100,6 +120,7 @@ export function createEmbeddedMpvReconnectState(
attemptInFlight: false,
lastPositionSeconds: null,
positionReported: false,
resumedAtSeconds: null,
timer: null,
pending: null,
};
@@ -131,6 +152,25 @@ export function isEmbeddedMpvStreamLoss(
return status === 'error' || (status === 'ended' && isLive);
}
/** Whether a VOD EOF at `positionSeconds` came well before the end. */
export function isEmbeddedMpvEarlyEof(
positionSeconds: number,
durationSeconds: number | null | undefined
): boolean {
if (
typeof durationSeconds !== 'number' ||
!Number.isFinite(durationSeconds) ||
durationSeconds <= 0
) {
return false;
}
const margin = Math.max(
EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_SECONDS,
durationSeconds * EMBEDDED_MPV_EARLY_EOF_MIN_REMAINING_RATIO
);
return durationSeconds - positionSeconds > margin;
}
export class EmbeddedMpvReconnectCoordinator {
private readonly now: () => number;
private readonly log: Pick<Console, 'log' | 'warn'>;
@@ -158,9 +198,18 @@ export class EmbeddedMpvReconnectCoordinator {
? playback.startTime
: null;
state.positionReported = false;
state.resumedAtSeconds = null;
state.pending = null;
}
/**
* The user moved the playhead: an EOF after that is not the reload
* ending where it resumed, so it is judged on its position alone.
*/
onUserSeek(state: EmbeddedMpvReconnectState): void {
state.resumedAtSeconds = null;
}
cancel(state: EmbeddedMpvReconnectState): void {
this.clearTimer(state);
state.attemptInFlight = false;
@@ -188,7 +237,8 @@ export class EmbeddedMpvReconnectCoordinator {
status: EmbeddedMpvSessionStatus,
previousStatus: EmbeddedMpvSessionStatus | null,
positionSeconds?: number,
errorOrigin?: 'playback' | 'engine'
errorOrigin?: 'playback' | 'engine',
durationSeconds?: number | null
): EmbeddedMpvReconnectInfo | null {
const now = this.now();
@@ -211,6 +261,14 @@ export class EmbeddedMpvReconnectCoordinator {
// the seeded `startTime` stays the better resume point.
state.lastPositionSeconds = 0;
}
if (
state.resumedAtSeconds !== null &&
positionSeconds >
state.resumedAtSeconds +
EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
) {
state.resumedAtSeconds = null;
}
}
if (status === 'playing') {
@@ -240,9 +298,13 @@ export class EmbeddedMpvReconnectCoordinator {
const isLive =
state.playback !== null &&
isLiveEmbeddedMpvPlayback(state.playback);
if (!isEmbeddedMpvStreamLoss(status, isLive)) {
if (
!isEmbeddedMpvStreamLoss(status, isLive) &&
!this.isDroppedVod(state, status, positionSeconds, durationSeconds)
) {
// paused/idle are user actions, closed is teardown, ended on
// VOD is the movie finishing — none of them is an outage.
// VOD near its end is the movie finishing — none of them is an
// outage.
if (status === 'paused') {
// Disarmed until the stream plays again: reloading a stream
// the user paused would resume it without them asking.
@@ -299,6 +361,9 @@ export class EmbeddedMpvReconnectCoordinator {
state.attemptInFlight = true;
try {
this.hooks.reload(sessionId, playback);
// Only a reload that started can end where it resumed; a
// refused one leaves the original EOF, still a drop.
state.resumedAtSeconds = playback.startTime ?? null;
} catch (error) {
state.attemptInFlight = false;
if (isEmbeddedMpvSessionGoneError(error)) {
@@ -325,6 +390,39 @@ export class EmbeddedMpvReconnectCoordinator {
return state.pending;
}
/**
* A VOD `ended` well before the duration is a dropped connection, unless
* it is the reload that resumed there ending again: then the file itself
* ends there and the duration was only an estimate.
*/
private isDroppedVod(
state: EmbeddedMpvReconnectState,
status: EmbeddedMpvSessionStatus,
positionSeconds: number | undefined,
durationSeconds: number | null | undefined
): boolean {
if (status !== 'ended' || state.playback === null) {
return false;
}
const position =
typeof positionSeconds === 'number' &&
Number.isFinite(positionSeconds) &&
positionSeconds > 0
? positionSeconds
: state.lastPositionSeconds;
if (
position === null ||
!isEmbeddedMpvEarlyEof(position, durationSeconds)
) {
return false;
}
return (
state.resumedAtSeconds === null ||
position >
state.resumedAtSeconds + EMBEDDED_MPV_EARLY_EOF_PROGRESS_SECONDS
);
}
/**
* Live streams reload at the live edge. Anything else resumes where the
* connection dropped: the stored playback still carries the offset the
@@ -6,10 +6,7 @@ import {
playlistConflictUpdate,
} from '@iptvnator/shared/database/app-playlist';
import { appState, playlists } from '@iptvnator/shared/database/schema';
import type {
NewPlaylist,
Playlist as PlaylistRow,
} from '@iptvnator/shared/database/schema';
import type { Playlist as PlaylistRow } from '@iptvnator/shared/database/schema';
import { SERVER_PAYLOAD_FIELDS } from '@iptvnator/shared/interfaces';
import {
metadataDocument,
@@ -42,46 +39,6 @@ function metadataColumns() {
const metaColumns = metadataColumns();
/**
* Columns a whole-document upsert must rewrite even when the document no
* longer has a value: the codec maps empty values to `undefined`, which an
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
* column value and `parseAppPlaylist` would bring it back.
*/
const REPLACEABLE_COLUMNS = [
'serverUrl',
'username',
'password',
'lastUpdated',
'userAgent',
'origin',
'referrer',
'filePath',
'epgUrls',
'detectedEpgUrls',
'manualEpgUrls',
'disabledEpgUrls',
'macAddress',
'url',
'portalUrl',
'count',
'importDate',
'updateDate',
'position',
'favorites',
'recentlyViewed',
] as const satisfies readonly (keyof NewPlaylist)[];
function clearedColumns(row: NewPlaylist): Partial<NewPlaylist> {
const cleared: Partial<Record<keyof NewPlaylist, null>> = {};
for (const column of REPLACEABLE_COLUMNS) {
if (row[column] === undefined) {
cleared[column] = null;
}
}
return cleared as Partial<NewPlaylist>;
}
/** Playlist documents in the shared `playlists` table, same rows as desktop. */
export class ServerPlaylistRepository {
constructor(private readonly db: DatabaseInstance) {}
@@ -146,10 +103,7 @@ export class ServerPlaylistRepository {
throw new InvalidPlaylistDocumentError();
}
const update = {
...playlistConflictUpdate(row, next),
...clearedColumns(row),
};
const update = playlistConflictUpdate(row, next);
this.db.transaction((tx) => {
tx.insert(playlists)
.values(row)
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Request, Response } from 'express';
/** Share of the clip after which the provider loses the first connection. */
export const DROPPING_EPISODE_CUT_RATIO = 0.35;
/**
* How long the stream is refused after the cut: long enough to defeat
* ffmpeg's own HTTP reconnect, short enough for an app-level retry.
*/
export const DROPPING_EPISODE_REFUSE_MS = 4_000;
let fixture: Buffer | null = null;
/** Stream id → when its connection was cut. Every episode drops once. */
const droppedAt = new Map<string, number>();
export function resetDroppingEpisodes(): void {
droppedAt.clear();
}
/**
* `dropping-episode` fixture: every episode plays a local 60 s clip with
* byte ranges, but the first response that reaches a third of the file is
* cut there, and the stream answers 403 for a few seconds — a panel still
* holding the dropped connection's slot. mpv reports such a drop as EOF in
* the middle of the episode (issue #1902).
*/
export function streamDroppingEpisode(
request: Request,
response: Response
): void {
const body = (fixture ??= readFileSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
));
const streamId = String(request.params['streamId']);
const cutAt = droppedAt.get(streamId);
response.set('Cache-Control', 'no-store');
if (
cutAt !== undefined &&
Date.now() - cutAt < DROPPING_EPISODE_REFUSE_MS
) {
response.status(403).end();
return;
}
const range = /^bytes=(\d+)-(\d*)$/.exec(request.get('range') ?? '');
const start = range ? Number(range[1]) : 0;
const end = range?.[2]
? Math.min(Number(range[2]), body.length - 1)
: body.length - 1;
if (start > end) {
response
.status(416)
.set('Content-Range', `bytes */${body.length}`)
.end();
return;
}
response
.status(range ? 206 : 200)
.type('video/x-matroska')
.set('Accept-Ranges', 'bytes')
.set('Content-Length', String(end - start + 1));
if (range) {
response.set('Content-Range', `bytes ${start}-${end}/${body.length}`);
}
const cut = Math.floor(body.length * DROPPING_EPISODE_CUT_RATIO);
if (cutAt === undefined && start < cut && end >= cut) {
response.write(body.subarray(start, cut), () => {
droppedAt.set(streamId, Date.now());
response.socket?.destroy();
});
return;
}
response.end(body.subarray(start, end + 1));
}
+16 -1
View File
@@ -43,8 +43,10 @@ export interface ScenarioConfig {
* episodes slowly from generated bytes so download screenshots complete
* without any request leaving the machine (the release capture's network
* gate rejects the public HLS stub every other scenario redirects to).
* `dropping-episode` plays a local clip whose connection is lost
* mid-episode (see dropping-episode.ts).
*/
downloadStreamFixture?: 'slow-series' | 'local-media';
downloadStreamFixture?: 'slow-series' | 'local-media' | 'dropping-episode';
/**
* Optional `player_api.php` actions the portal accepts and then never
* answers: the connection stays open until the client gives up. Models a
@@ -172,6 +174,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
expiryDate: '2099-12-31',
downloadStreamFixture: 'slow-series',
},
'streamdrop:streamdrop': {
name: 'stream-drop',
description:
'One series whose episodes lose their connection mid-playback — embedded MPV reconnect tests',
seed: 8181,
categoryCount: { live: 0, vod: 0, series: 1 },
itemsPerCategory: 1,
seasonsPerSeries: 1,
episodesPerSeason: 3,
accountStatus: 'Active',
expiryDate: '2099-12-31',
downloadStreamFixture: 'dropping-episode',
},
'epg:epg': {
name: 'epg-fixture',
description:
+106 -4
View File
@@ -1,6 +1,13 @@
import { statSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import express from 'express';
import { resetAll } from './data-store.js';
import {
DROPPING_EPISODE_CUT_RATIO,
DROPPING_EPISODE_REFUSE_MS,
resetDroppingEpisodes,
} from './dropping-episode.js';
import { LONG_PROGRAMME_TITLE } from './scenarios.js';
import {
createXtreamMockApp,
@@ -46,9 +53,37 @@ jest.mock('@faker-js/faker', () => {
jest.setTimeout(60_000);
const DROPPING_EPISODE_URL = '/series/streamdrop/streamdrop/80000.mkv';
const droppingFixtureSize = statSync(
join(process.cwd(), 'apps/xtream-mock-server/src/fixtures/episode.mkv')
).size;
const droppingCut = Math.floor(
droppingFixtureSize * DROPPING_EPISODE_CUT_RATIO
);
/** Bytes received before the body ended, and whether it ended cleanly. */
async function readDroppingBody(
response: Response
): Promise<{ bytes: number; complete: boolean }> {
const reader = response.body?.getReader();
let bytes = 0;
try {
for (;;) {
const chunk = await reader?.read();
if (!chunk || chunk.done) {
return { bytes, complete: true };
}
bytes += chunk.value.length;
}
} catch {
return { bytes, complete: false };
}
}
describe('Xtream mock server factory', () => {
afterEach(() => {
resetAll();
resetDroppingEpisodes();
jest.restoreAllMocks();
});
@@ -192,7 +227,9 @@ describe('Xtream mock server factory', () => {
expect(xml).toContain('<tv ');
expect(xml).toContain('<display-name>Aurora News</display-name>');
expect(xml).toContain('channel="aurora-news.fictional"');
expect(xml).toContain(`${running.origin}/assets/marketing/logo/aurora-news.svg`);
expect(xml).toContain(
`${running.origin}/assets/marketing/logo/aurora-news.svg`
);
expect(xml).not.toMatch(/https?:\/\/(?!127\.0\.0\.1|localhost)/);
} finally {
await running.close();
@@ -259,9 +296,9 @@ describe('Xtream mock server factory', () => {
}
// Movies must finish quickly while episodes stay slow enough to
// keep a progress row in frame.
expect(
Number(movie.headers.get('content-length'))
).toBeGreaterThan(Number(episode.headers.get('content-length')));
expect(Number(movie.headers.get('content-length'))).toBeGreaterThan(
Number(episode.headers.get('content-length'))
);
expect(ordinaryMovie.status).toBe(302);
} finally {
@@ -301,6 +338,71 @@ describe('Xtream mock server factory', () => {
}
});
it('drops stream-drop episodes a third of the way in, refuses the retry, then serves ranges again', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
);
try {
const first = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: 'bytes=0-' },
}
);
expect(first.status).toBe(206);
expect(Number(first.headers.get('content-length'))).toBe(
droppingFixtureSize
);
expect(await readDroppingBody(first)).toEqual({
bytes: droppingCut,
complete: false,
});
// ffmpeg's own reconnect right after the drop.
const retry = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(retry.status).toBe(403);
await retry.body?.cancel();
const later = Date.now() + DROPPING_EPISODE_REFUSE_MS + 1;
jest.spyOn(Date, 'now').mockReturnValue(later);
const resumed = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`,
{
headers: { range: `bytes=${droppingCut}-` },
}
);
expect(resumed.status).toBe(206);
expect(resumed.headers.get('content-range')).toBe(
`bytes ${droppingCut}-${droppingFixtureSize - 1}/${droppingFixtureSize}`
);
expect(await readDroppingBody(resumed)).toEqual({
bytes: droppingFixtureSize - droppingCut,
complete: true,
});
// A reset arms the drop again for the next test.
expect(
(await fetch(`${running.origin}/reset`, { method: 'POST' }))
.status
).toBe(200);
const afterReset = await fetch(
`${running.origin}${DROPPING_EPISODE_URL}`
);
expect(afterReset.status).toBe(200);
expect(await readDroppingBody(afterReset)).toEqual({
bytes: droppingCut,
complete: false,
});
} finally {
await running.close();
}
});
it('never answers the silent scenario detail actions while the rest of the portal works', async () => {
const running = await startLoopbackServer(
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
@@ -9,6 +9,10 @@ import express, {
import { installLiveFormatFixture } from './live-format-fixture.js';
import { resolveXtreamMockPortString } from './mock-port.js';
import { resetAll } from './data-store.js';
import {
resetDroppingEpisodes,
streamDroppingEpisode,
} from './dropping-episode.js';
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
import { installPerformanceControlRoutes } from './performance-control-routes.js';
import {
@@ -97,6 +101,7 @@ export function createXtreamMockApp(
return;
}
resetAll();
resetDroppingEpisodes();
response.json({ status: 'reset' });
});
app.get('/playlist.m3u', (_request, response) => {
@@ -316,6 +321,10 @@ function installStreamRoutes(
);
return;
}
if (fixture === 'dropping-episode') {
streamDroppingEpisode(request, response);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
@@ -10,6 +10,13 @@ Regenerate from the repository root with FFmpeg:
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts
```
The mock returns the fixture only for synthetic live-format account media;
`episode.mkv` is sixty seconds of the same pattern, H.264 only, keyframes every
second, played by the `streamdrop` account's episodes:
```sh
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=10 -t 60 -c:v libx264 -preset veryslow -crf 40 -pix_fmt yuv420p -g 10 -fflags +bitexact -flags:v +bitexact -map_metadata -1 apps/xtream-mock-server/src/fixtures/episode.mkv
```
The mock returns `live.mpegts` only for synthetic live-format account media;
manifest and denied-segment routes return controlled 200/403 responses. Web and
Electron E2E tests check advancing playback, not HTTP status alone.
Binary file not shown.
@@ -157,6 +157,14 @@ Contracts:
- Hosts provide hero chips/meta/actions as `*appDetailTags` / `*appDetailMeta` /
`*appDetailActions` templates; the shell stamps them into the hero and again
into the About block. Degradation stays "missing → not rendered".
- Provider stand-ins count as missing: `N/A` (any case), a lone `-`, and a
`0` IMDb/Kinopoisk score are dropped where provider rows are mapped, by
`readProviderText` / `readProviderRating`
(`libs/shared/interfaces/src/lib/provider-metadata.util.ts`) — in
`createStalkerInfo()`, the `vod-details-adapters.ts` normalizers and the
Xtream store's detail selection. Templates keep plain `@if` guards; do not
teach them the placeholder list. Widen the list only with evidence of a
provider sending the new stand-in.
- The shell never wraps the `[detail-player]` slot in a conditional; the
host's `@if (inlinePlayback())` is the only creator/destroyer of the
player subtree, so shell state changes cannot recreate the `<video>`.
+20 -2
View File
@@ -636,6 +636,13 @@ button disabled state at current-season boundaries.
Autoplay is enabled by default for series playback in embedded MPV. On `ended`, Xtream and Stalker series detail views start the next episode only when the current episode has a next item in the same season. Playback stops on the last episode of the current season. Previous always switches to the previous episode in the current season; it does not implement a restart-threshold behavior.
`ended` alone does not prove the episode is over: mpv reports a connection the
server closed (and ffmpeg could not reopen) as EOF too, once its cache runs
dry. `EmbeddedMpvPlayerComponent` therefore emits `playbackEnded` only for an
`ended` session that carries no `reconnect`; while the main process reloads a
VOD that ended well before its duration (see Network Auto-Reconnect), the
episode continues instead of autoplay skipping its rest (issue #1902).
## Session Options (Extra libmpv Options)
`Settings > Playback > Extra embedded MPV options` is a free-form textarea,
@@ -701,8 +708,9 @@ included.
`EmbeddedMpvReconnectCoordinator`
(`apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts`) reloads
the last user-requested playback when a session reports a stream loss: an
`error` status, or `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses). It is driven by
`error` status, `ended` while the playback is live (`isLive`, else "no
`contentInfo`", the same rule the renderer uses), or `ended` on VOD well
before the known duration. It is driven by
`refreshSession()` status transitions, so it covers every engine, and it is
captured per session from `Settings.embeddedMpvAutoReconnect` (default on;
mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
@@ -716,6 +724,16 @@ mirrored as `EMBEDDED_MPV_AUTO_RECONNECT`). The policy is deliberately narrow:
the IPC socket so a stream that ended reports `ended` rather than the
`paused` that keep-open would otherwise look like, and an mpv process that
exits abnormally reports `error`.
- A VOD `ended` counts only when more than `max(10 s, 1 % of the duration)`
remained (`isEmbeddedMpvEarlyEof`): the margin absorbs container rounding
and the whole-second snapshot values, and with no known duration an EOF is
always the end. A reload the engine accepted records where it resumed; an
early EOF before playback gets 5 s past that point (and with no user seek
in between) ended where the file does (a duration mpv estimated from the
bitrate can overshoot) and is left as the end, so autoplay still continues
after one attempt instead of a reload loop. A refused reload records
nothing, so the original EOF keeps its backoff.
With the setting off, an early EOF stays a plain `ended`.
- Backoff is 2 s, 4 s, 8 s, 16 s, 30 s, 30 s, at most six attempts per
outage. The budget resets only after 30 s of uninterrupted `playing`, so a
stream that flaps every few seconds runs out of attempts instead of being
+13
View File
@@ -633,6 +633,19 @@ channel-list-container/
- `PlaylistsService.updatePlaylistMeta()` persists `hiddenGroupTitles` into the
stored playlist payload, and M3U refresh/update flows preserve the existing
value when refreshed playlist data omits the field.
- Electron loads the playlist store from `DB_GET_APP_PLAYLIST_METAS`, which
reads columns only, so `hiddenGroupTitles` is absent from the store after a
restart. `M3uWorkspaceRouteSession` copies it from the full playlist read into
the store (`updatePlaylistMeta` with `persist: false`) before publishing the
channels, unless the store value changed while that read was queued (#1894).
A restored or deep-linked route can finish that read before the startup
inventory, which waits for settings and EPG reconciliation, publishes the
playlist; the hydration then waits for the entity, since `updateOne` drops an
update for a missing one. The startup screen keeps the router outlet hidden
until that inventory is published, and the hydration runs in the same store
update, so the rail never renders a hidden group. A save's meta write joins
the per-playlist queue that `getPlaylist()` reads through, so a reopened
route reads the saved value.
- The groups route keeps the manage action reachable even when every group is
hidden by separating "playlist has no groups" from "no visible/search-matching
groups" empty states.
+75 -14
View File
@@ -704,6 +704,11 @@ source's default section, which is VOD (`getPlaylistLink` links to
and the first page is the "All items" grid. The plan's "live category list"
would need a second click and is not measured; the landed section is
recorded under `evidence.firstPage.section`.
The seeded portal is already imported, so the click only reads the saved
catalog and finishes well inside the shell's show delay for the import
overlay ([workspace shell](workspace-shell.md)): a J2 click must not mount
`app-workspace-shell-import-overlay`, and before that delay it flashed for
about two frames between the skeleton and the first page.
**HTTP requests to the mock.** The J2 profile is seeded with the origin of a
loopback proxy in the test process
@@ -922,11 +927,13 @@ put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms,
its term after `SEARCH_INPUT_DEBOUNCE_MS` (300 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
adopts `q` and runs `executeSearch` after its own 300 ms debounce, which
calls the `dbGlobalSearch` bridge method once with a limit of 101.
runs `executeSearch` as soon as the applied term reaches the store, with no
debounce of its own, which calls the `dbGlobalSearch` bridge method once
with a limit of 101. A newer term supersedes a query still in flight: its
result is dropped by request version.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
@@ -936,7 +943,7 @@ been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 350 ms debounce, as steady typing would be),
the first key (well below the 300 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
@@ -945,7 +952,7 @@ rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 650 ms of debounce, before any query ran: nothing in the DOM
end inside the 300 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
@@ -970,7 +977,7 @@ window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 350 ms debounce
At that point a late key on a busy machine could let the 300 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
@@ -1012,9 +1019,10 @@ counter: global search does not touch the network.
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. With the current debounces both include
the 650 ms the term waits (350 ms in the shell, then 300 ms in the results
component); the first also includes the 500 ms of typing.
Both are taken in the renderer. Both include the 300 ms the term waits in
the shell; the first also includes the 500 ms of typing. Until the single
debounce below, the wait was 650 ms (350 ms in the shell, then 300 ms in the
results component).
### First measurement
@@ -1040,6 +1048,46 @@ debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
### One debounce
The results component's own 300 ms timer is gone: the shell debounce is
now the only one, and global search runs as soon as the shell applies the
term. `SEARCH_INPUT_DEBOUNCE_MS` went from 350 to 300 ms. It has to stay
above the gap between keys of steady typing, or a word typed in one go
queries once per key; 300 ms covers an average typist (about 200-250 ms
between keys) with some margin, while the query and first page take only
30-40 ms of the remaining time. The route sync in the component reads the
store term untracked, so a term the shell applied before it writes `q` is
no longer pushed back to the previous `q` and queried again.
Local, macOS, 2026-10-11, three J4 runs per build (five measured
iterations each), the same machine, master `633fde3e0` against this
change:
| Measurement | Two debounces | One debounce |
| ------------------------------------ | -------------------- | -------------------- |
| `lastKeystrokeToSettledMs` P50 / P90 | 692-695 / 695-697 ms | 336-337 / 337-339 ms |
| `firstKeystrokeToFirstResultMs` P50 | 1,190 ms | 832-834 ms |
| `renderer.ipcCallsPerSearch` | 1 | 1 |
| `renderer.sqlStatementsPerSearch` | 2 | 2 |
| `renderer.domMutationsToResults` | 402 | 392 |
Per keystroke (query calls / SQL / DOM mutations / CD ticks) at 100 ms per
key, keys 1-5 are unchanged at `0/0/0/1`; key 6 is `1/2/402/9` before and
`1/2/392/7` after.
Slower typing was measured with a scratch change to the journey (key
interval and cadence guard raised, quiet window 1 s; not committed). At
400 ms per key both builds run one query per key from the second
character, five in all with 2 statements each: the shell debounce already
expired between keys before this change, and the results timer only
delayed each query. Before, the queries trail the keys by 650 ms (keys
`y s t e m` read `0 1 1 1 2`); after, each follows its own key. At 325 ms
per key the two builds differ: before, one query in 17 of 20 iterations
(two or four when a gap stretched past 350 ms); after, five. Typing with
gaps between 300 and 350 ms is the only range where this change adds
queries; each is a 2-statement query whose stale result is dropped.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
@@ -1064,8 +1112,8 @@ persisted status (`xtream-import-status:<id>:<type>`) reads `completed` and
the type has rows, unless the portal has no categories of that type. Rows
are required because a refresh deletes the catalog without resetting the
status. `xtream-import-wait.e2e.ts` holds the account check past the overlay
window and fails without it. No J4 baseline exists yet; J4 counters join the
ratchet once three runner runs agree.
window and fails without it. Two J4 counters are enforced since #1899
(see [Enforced journey counters](#enforced-journey-counters)).
## `renderer.initialBytes`
@@ -1317,8 +1365,8 @@ largest distance in it, the content pane's 323 px, which is why Back added
buttons appearing when the categories arrive. The skeletons of the category
list and the grid are replaced by content without any shift. Nothing was
hidden behind the startup overlay: the "Syncing playlist" overlay of the
cached catalog load flashes for about two frames after the shift and covers
no further one.
cached catalog load flashed for about two frames after the shift and covered
no further one. It no longer mounts on a J2 click (see the J2 section).
The content frame and the header controls now slide into place instead of
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
@@ -1332,6 +1380,14 @@ changes) and the wall-clock entries did not move. Under
`prefers-reduced-motion` there is no slide, so a user with that preference
still sees the jump that the journey no longer measures.
The two J4 entries, `search/renderer.ipcCallsPerSearch` 1 and
`search/renderer.sqlStatementsPerSearch` 2 (#1899, `evidenceRun`
38165392046), were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runs of #1899. They guard
the debounce: a search that queried per keystroke, or ran a second query for
the same term, raises them. Like the entries in the table they carry the
guard-only note: #1899 halved the J4 wall clock without moving either.
Being deterministic is not the same as being validated. Principle 3 of the
plan promotes a counter to a guardrail once a PR has shown that lowering it
lowered the journey's wall-clock. None of these counters has that evidence
@@ -1367,7 +1423,12 @@ Not enforced, with the reason:
- J3 `renderer.ipcSerialDepthToPlaying` (4 on the runner, 3 on a Mac):
added after these runs and measured in one runner run so far; a
candidate once `master` runs agree.
- J4: not measured yet.
- J4 `renderer.domMutationsToResults` (402 on `master`, 392 since #1899),
`renderer.ipcSerialDepthToResults` (1) and `renderer.layoutShiftScore`
(0): identical in the three #1899 runs; candidates once `master` runs
agree.
- J4 `renderer.cdTicksToResults` (11 to 13) and `renderer.longTasks`: see
the general reasons above.
Runner counters differ from a Mac (the Linux-only `getWindowState` call, for
one), so take every journey baseline value from the runner only.
+47 -1
View File
@@ -12,6 +12,8 @@ settings screen.
- Manifest types: `libs/shared/interfaces/src/lib/playlist-backup.interface.ts`
- Xtream pending restore storage:
`libs/services/src/lib/xtream-pending-restore.service.ts`
- Browser (PWA) Xtream user state:
`libs/services/src/lib/pwa-xtream-backup-state.service.ts`
## Manifest Contract
@@ -61,9 +63,12 @@ Xtream backups export only connection metadata plus portable user state.
- User state:
- hidden categories by `{ categoryType, xtreamId }`
- favorites by `{ contentType, xtreamId, addedAt?, position? }`
- recently viewed by `{ contentType, xtreamId, viewedAt }`
- recently viewed by `{ contentType, xtreamId, viewedAt, backdropUrl? }`
(`backdropUrl` is written and read by the browser build only)
- playback positions as `PlaybackPositionData[]`
- optional VOD source pins by `{ matchKey, contentId, updatedAt? }`
- optional title snapshots `collectionItems` (browser build only; see
[Browser runtime](#browser-runtime))
An imported Xtream archive can wait for its catalog to download. Export reads
that pending snapshot before consulting the catalog: its hidden categories,
@@ -225,6 +230,47 @@ categories/favorites/history can
remain after a later failure, but the snapshot remains available to retry the
complete restore. It is consumed only after every restore step succeeds.
### Browser Runtime
Whenever `XTREAM_DATA_SOURCE` resolves to `PwaXtreamDataSource` — the
browser, or a desktop bridge without the Xtream SQLite API
(`RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`) — Xtream
favorites, recently viewed items, playback positions and title snapshots live
in the `USER_DATA_STORAGE` maps owned by `PwaXtreamDataSource`
(`XTREAM_USER_DATA_KEYS`; see
[PWA portal user data](./pwa-self-hosted.md#pwa-portal-user-data)).
`PwaXtreamBackupStateService` reads and writes them through that token, so the
self-hosted server storage is covered too.
- Export converts each playlist's entries to the archive shape. Typed keys
(`movie:42`) carry their identity; a legacy bare id resolves only through
the snapshot stored under it, and is left out when none identifies it.
Recently viewed keeps the newest view per title, with the artwork saved
for that view as `backdropUrl`. `collectionItems` carries
the snapshots of exported favorites and history, and is omitted when there
are none. Hidden categories stay `[]` and `sourcePins` stays absent: the
browser tracks neither.
- An unreadable map (storage error, malformed JSON, a playlist entry that is
not a list) fails the export instead of being written as an empty list.
- A pending restore snapshot still wins over the stored maps, as on desktop.
- Import parks the restore state like the desktop does, then applies it at
once through `XtreamPendingRestoreService.applyAndConsume`: the browser has
no catalog cache to wait for. Favorites, history and positions of the
imported playlist id are replaced (positions get that id); other playlists'
entries are untouched. `collectionItems` merge into the playlist's
snapshots, keyed by typed identity; an archive without them leaves the
snapshots alone, malformed items are skipped, a damaged cache is replaced,
and a quota error on this cache does not fail the restore.
- The lists are written before the optional title snapshots, so the cache
never takes storage space they need (as for additions; see the
[snapshot-storage contract](./xtream-portal-compatibility.md)). A failed
write fails the entry and keeps the snapshot parked, so the Xtream store
replays the complete state when the playlist next opens. That replay
carries no title snapshots, so they are still merged after a failed list
write, unless storage is full and they would take room the replay needs.
Older builds ignore `collectionItems`; the manifest version stays `1`.
## Current UX
The settings page now exports/imports “playlist backups” instead of the old
+5 -1
View File
@@ -437,7 +437,11 @@ and sidecar state in localStorage:
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
`localStorage`), never through `localStorage` directly, so server storage
mode can substitute `ServerUserDataStorage`.
mode can substitute `ServerUserDataStorage`. The key names are shared as
`XTREAM_USER_DATA_KEYS` with the playlist backup, which exports and restores
the favorites, recent items, positions and snapshots through the same token
(`PwaXtreamBackupStateService`; see
[backup/restore](./playlist-backup-restore.md#browser-runtime)).
Entries should include a content snapshot when the item is added. Global
collection routes and the dashboard can then restore titles, posters, content
+11
View File
@@ -618,6 +618,17 @@ Xtream-only row shape:
12. `DB_GET_APP_STATE`
13. `DB_SET_APP_STATE`
`DB_UPSERT_APP_PLAYLIST(S)` write whole documents: the row built by the shared
codec (`buildPlaylistRow` in `libs/shared/database`) replaces the stored one.
`playlistConflictUpdate` sets every optional column the document leaves empty
(an empty string or a missing key) to NULL instead of skipping it, because
`parseAppPlaylist` prefers a column over the payload and would otherwise bring
a cleared User-Agent, Referrer, Origin, credential, EPG list or file path back.
Only `date_created` is kept from the first insert. Callers must send the full
document (`PlaylistsService` reads, patches and writes the whole row); a
column-only change goes through `DB_UPDATE_PLAYLIST`. The self-hosted web
backend's playlist repository uses the same conflict update.
`DB_GET_APP_PLAYLIST_METAS` is the preferred path for summary surfaces such as
the workspace sidebar and dashboard source rail. It selects playlist metadata
columns only and deliberately skips the large `payload` column, which can
+11
View File
@@ -422,6 +422,17 @@ Failure-handling rule:
not `undefined` collections or renderer exceptions. The workspace Stalker
context panel and live layout rely on this guarantee.
Detail metadata rule:
- `createStalkerInfo()` (`stalker-vod.utils.ts`) is the mapping both detail
templates read: the series view binds `info` directly and the shared VOD
view reads it through `normalizeStalkerVod()`. Each field takes the first
usable candidate, and portal stand-ins (`N/A`, `-`, an unrated `0` score)
become `''` there — see the placeholder rule in
[Embedded Inline Playback](./embedded-inline-playback.md). It runs before
TMDB enrichment, which keeps a non-empty provider rating and release date,
so a surviving stand-in would also block the TMDB value.
## Stalker Identity Policy
Full Stalker/Ministra portal authentication defaults to MAC-only identity. The
+7 -1
View File
@@ -200,7 +200,13 @@ services:
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
and recent-command recording.
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
state and labels.
state and labels. Remote import phases and refresh preparation of the
shown playlist (of any playlist on the dashboard) show the overlay at
once. A session that has only read the saved catalog (`loading-cached`,
`isImporting` still false) shows it only after
`XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS` (300 ms), so a warm open goes from
the skeleton to the page without flashing the overlay, while a slow or
stuck cache read still blocks and stays cancellable.
When adding shell behavior, prefer placing it in the service that owns the
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
+12
View File
@@ -405,6 +405,7 @@ sometimes only respond to that misspelled action.
| `tzoffset:tzoffset` | 6006 | live:2, vod:1, series:1 | 3 | active |
| `emptyvod:emptyvod` | 7007 | 2 each | 5 | active |
| `marketing:marketing` | 8020 | live:4, vod:4, series:4 | curated | active |
| `streamdrop:streamdrop` | 8181 | series:1 | 1 | active |
| `expired:expired` | 4004 | 4 each | 10 | Expired |
| `inactive:inactive` | 5005 | 4 each | 10 | Disabled |
| `<any other>` | hash | 6 each | 30 | active |
@@ -428,6 +429,17 @@ Timeshift URLs of `epg` and `epglong` play the local MPEG-TS clip
the catch-up seek bar's label bounds; `epg:epg` keeps its short titles,
which other tests assert verbatim.
### `streamdrop:streamdrop` fixture details
Its three episodes play the local 60 s Matroska clip
(`src/fixtures/episode.mkv`, generation command in the fixtures README) with
byte ranges, but `dropping-episode.ts` cuts the first response that reaches
35 % of the file and answers 403 for the next 4 s, so ffmpeg's own HTTP
reconnect fails and mpv reports EOF mid-episode. `POST /reset` re-arms the
drop. The Electron E2E `embedded-mpv-series-stream-drop.e2e.ts` uses it to
check that Embedded MPV resumes the episode instead of autoplaying the next
one (issue #1902).
### `marketing:marketing` fixture details
This scenario is reserved for release screenshots and marketing materials:
@@ -344,6 +344,12 @@ resolves. An unresolved source exposes no actions. Playback and download
descriptors also fall back to the catalog title and poster after `info` and
`movie_data`.
Provider stand-ins in `get_vod_info` / `get_series_info` (`N/A`, `-`, an
unrated `0` score) are emptied before the response becomes the store
selection (`stores/xtream-detail-metadata.ts`), since both detail routes bind
`info` fields directly. A `get_vod_info` response made only of stand-ins
therefore counts as sparse and gets the fallback page above.
## Catch-Up Playback URLs
Xtream-compatible portals differ on archive playback URL shape. IPTVnator
@@ -1,3 +1,4 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -30,7 +31,11 @@ async function flushEffects(): Promise<void> {
describe('M3uWorkspaceRouteSession performance phases', () => {
const hookSymbol = Symbol.for(RENDERER_PERFORMANCE_PHASE_HOOK_KEY);
const store = { dispatch: jest.fn() };
const store = {
dispatch: jest.fn(),
select: jest.fn(() => of({})),
selectSignal: jest.fn(() => signal({})),
};
beforeEach(() => {
Object.defineProperty(window, 'electron', {
@@ -1,10 +1,14 @@
import { TestBed } from '@angular/core/testing';
import { NavigationEnd, Router } from '@angular/router';
import { of, Subject, throwError } from 'rxjs';
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
} from '@iptvnator/m3u-state';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
import { Channel, Playlist } from '@iptvnator/shared/interfaces';
import { Channel, Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
import { Store } from '@ngrx/store';
import { M3uWorkspaceRouteSession } from './m3u-workspace-route-session.service';
@@ -76,8 +80,13 @@ describe('M3uWorkspaceRouteSession', () => {
const playlistsService = {
getPlaylist: jest.fn(),
};
const playlistEntities = new BehaviorSubject<
Record<string, PlaylistMeta | undefined>
>({});
const store = {
dispatch: jest.fn(),
select: jest.fn(() => playlistEntities.asObservable()),
selectSignal: jest.fn(() => () => playlistEntities.value),
};
const electronApi = {
setUserAgent: jest.fn().mockResolvedValue(true),
@@ -99,6 +108,9 @@ describe('M3uWorkspaceRouteSession', () => {
electronApi.setUserAgent.mockClear();
playlistsService.getPlaylist.mockReset();
store.dispatch.mockClear();
playlistEntities.next({
[PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta,
});
await TestBed.configureTestingModule({
providers: [
@@ -205,6 +217,121 @@ describe('M3uWorkspaceRouteSession', () => {
).toBe(false);
});
it('hydrates the hidden groups the startup metadata list leaves out, before the channels (#1894)', async () => {
playlistsService.getPlaylist.mockReturnValue(
of({
hiddenGroupTitles: ['Sports'],
playlist: {
items: [PRIMARY_CHANNEL],
},
} as Playlist)
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
const types = store.dispatch.mock.calls.map(([action]) => action.type);
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: PLAYLIST_ID,
hiddenGroupTitles: ['Sports'],
} as PlaylistMeta,
persist: false,
})
);
expect(
types.indexOf(PlaylistActions.updatePlaylistMeta.type)
).toBeLessThan(types.indexOf(ChannelActions.setChannels.type));
});
it('hydrates no hidden groups for a playlist that never saved any', async () => {
playlistsService.getPlaylist.mockReturnValue(
of({ playlist: { items: [PRIMARY_CHANNEL] } } as Playlist)
);
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: PLAYLIST_ID,
hiddenGroupTitles: [] as string[],
} as PlaylistMeta,
persist: false,
})
);
});
it('keeps hidden groups saved while the playlist read was queued', async () => {
const response = new Subject<Playlist>();
playlistsService.getPlaylist.mockReturnValue(response.asObservable());
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
// The dialog's save lands in the store before the queued read returns
// the row as it was before that save.
playlistEntities.next({
[PLAYLIST_ID]: {
_id: PLAYLIST_ID,
hiddenGroupTitles: ['News'],
} as PlaylistMeta,
});
response.next({
hiddenGroupTitles: [] as string[],
playlist: { items: [PRIMARY_CHANNEL] },
} as Playlist);
response.complete();
await flushEffects();
expect(store.dispatch).toHaveBeenCalledWith(
ChannelActions.setChannels({ channels: [PRIMARY_CHANNEL] })
);
expect(
store.dispatch.mock.calls.some(
([action]) =>
action.type === PlaylistActions.updatePlaylistMeta.type
)
).toBe(false);
});
it('waits for the startup inventory when a restored route reads the playlist first', async () => {
playlistEntities.next({});
playlistsService.getPlaylist.mockReturnValue(
of({
hiddenGroupTitles: ['Sports'],
playlist: { items: [PRIMARY_CHANNEL] },
} as Playlist)
);
const hydration = PlaylistActions.updatePlaylistMeta({
playlist: {
_id: PLAYLIST_ID,
hiddenGroupTitles: ['Sports'],
} as PlaylistMeta,
persist: false,
});
TestBed.inject(M3uWorkspaceRouteSession);
await flushEffects();
// An update for an entity that is not in the store yet is dropped.
expect(store.dispatch).not.toHaveBeenCalledWith(hydration);
playlistEntities.next({
[PLAYLIST_ID]: { _id: PLAYLIST_ID } as PlaylistMeta,
});
expect(store.dispatch).toHaveBeenCalledWith(hydration);
expect(
store.dispatch.mock.calls.filter(
([action]) =>
action.type === PlaylistActions.updatePlaylistMeta.type
)
).toHaveLength(1);
});
it('clears in-memory favorites without dispatching persistence when playlist loading fails', async () => {
playlistsService.getPlaylist.mockReturnValue(
throwError(() => new Error('Playlist loading failed'))
@@ -8,14 +8,20 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { ChannelActions, FavoritesActions } from '@iptvnator/m3u-state';
import {
ChannelActions,
FavoritesActions,
PlaylistActions,
selectPlaylistEntities,
} from '@iptvnator/m3u-state';
import {
measureRendererPerformancePhase,
RENDERER_PERFORMANCE_PHASE,
} from '@iptvnator/shared/logging';
import { filter, firstValueFrom } from 'rxjs';
import { filter, firstValueFrom, map, Subscription, take } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PlaylistsService } from '@iptvnator/services';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
type M3uLoadedSection = 'all' | 'groups';
@@ -26,10 +32,14 @@ export class M3uWorkspaceRouteSession {
private readonly playlistsService = inject(PlaylistsService);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly playlistEntities = this.store.selectSignal(
selectPlaylistEntities
);
private currentPlaylistId: string | null = null;
private currentSection: string | null = null;
private loadRequestId = 0;
private pendingHiddenGroupsHydration: Subscription | null = null;
constructor() {
this.router.events
@@ -93,6 +103,9 @@ export class M3uWorkspaceRouteSession {
ChannelActions.setChannelsLoading({ loading: true })
);
const hiddenGroupTitlesBeforeRead =
this.playlistEntities()[playlistId]?.hiddenGroupTitles;
try {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylist(playlistId)
@@ -102,6 +115,12 @@ export class M3uWorkspaceRouteSession {
return;
}
this.hydrateHiddenGroupTitles(
playlistId,
playlist.hiddenGroupTitles,
hiddenGroupTitlesBeforeRead
);
void window.electron
?.setUserAgent(playlist.userAgent, playlist.referrer)
.catch((error: unknown) => {
@@ -143,6 +162,49 @@ export class M3uWorkspaceRouteSession {
}
}
/**
* Electron's startup playlist list reads the metadata columns only, which
* leave out the hidden groups stored in the payload (#1894). Published
* before the channels so the groups rail never shows them; a save made
* while the read was queued is newer and stays.
*
* A restored or deep-linked route can finish this read before the startup
* inventory publishes the playlist (that waits for settings and EPG
* reconciliation), and `updateOne` drops an update for a missing entity,
* so the hydration waits for it.
*/
private hydrateHiddenGroupTitles(
playlistId: string,
storedHiddenGroupTitles: string[] | undefined,
hiddenGroupTitlesBeforeRead: string[] | undefined
): void {
this.pendingHiddenGroupsHydration?.unsubscribe();
this.pendingHiddenGroupsHydration = this.store
.select(selectPlaylistEntities)
.pipe(
map((entities) => entities[playlistId]),
filter((playlist) => playlist !== undefined),
take(1),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((playlist) => {
if (
playlist.hiddenGroupTitles !== hiddenGroupTitlesBeforeRead
) {
return;
}
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
hiddenGroupTitles: storedHiddenGroupTitles ?? [],
} as PlaylistMeta,
persist: false,
})
);
});
}
private isLoadedSection(
section: string | null
): section is M3uLoadedSection {
@@ -26,6 +26,24 @@ describe('grid list rating helpers', () => {
).toBe('7.2');
});
it('shows no badge for a provider stand-in or an unrated zero', () => {
for (const value of ['N/A', 'n/a', '-', '0', '0.0', 0]) {
expect(formatGridRating(value)).toBeUndefined();
}
});
it('falls back to the generic rating when imdb rating is a stand-in', () => {
expect(
resolveGridRating({
rating: '6.5',
rating_imdb: 'N/A',
})
).toBe('6.5');
expect(resolveGridRating({ rating: '0', rating_imdb: '-' })).toBe(
undefined
);
});
it('falls back to the generic rating when imdb rating is blank', () => {
expect(
resolveGridRating({
@@ -14,6 +14,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
import { readProviderRating } from '@iptvnator/shared/interfaces';
import {
PortalWatchState,
getXtreamCatchupDays,
@@ -49,22 +50,19 @@ interface GridListItem {
[key: string]: unknown;
}
/**
* Card rating badge text. Provider stand-ins (`"N/A"`, `"-"`) and an unrated
* zero read as no rating — see `readProviderRating` — so the badge is hidden
* and {@link resolveGridRating} can reach the generic `rating` behind them.
*/
export function formatGridRating(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toFixed(1);
}
if (typeof value !== 'string') {
const text = readProviderRating(value);
if (text === undefined) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const numericRating = Number.parseFloat(trimmed);
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : trimmed;
const numericRating = Number.parseFloat(text);
return Number.isFinite(numericRating) ? numericRating.toFixed(1) : text;
}
export function resolveGridRating(
@@ -342,6 +342,82 @@ describe('stalker-vod.utils regressions', () => {
});
});
describe('provider placeholders in Stalker VOD info', () => {
// The series view renders `info` directly and the shared VOD template
// renders it through the view-model adapter, so the stand-ins have to
// be gone from `info` itself — not just from one template's guards.
const PLACEHOLDER_ROW = {
id: '31',
cmd: '/media/file_31.mpg',
name: 'Placeholder Movie',
o_name: 'N/A',
screenshot_uri: 'N/A',
description: 'N/A',
actors: 'n/a',
director: '-',
year: 'N/A',
genres_str: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
};
it('maps every stand-in to the empty value the info shape uses for absent', () => {
const info = createStalkerInfo(PLACEHOLDER_ROW);
expect(info).toEqual({
name: 'Placeholder Movie',
o_name: undefined,
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
});
});
it('keeps the series view free of stand-ins through re-normalization', () => {
const displayed = normalizeStalkerVodDetailsItem(
normalizeStalkerVodDetailsItem({
...PLACEHOLDER_ROW,
is_series: 1,
})
);
expect(displayed.info.releasedate).toBe('');
expect(displayed.info.rating_imdb).toBe('');
expect(displayed.info.rating_kinopoisk).toBe('');
expect(displayed.info.description).toBe('');
});
it('falls through a placeholder to the usable value behind it', () => {
const info = createStalkerInfo({
id: '32',
year: '2011',
genres_str: 'Drama',
description: 'Plot from the catalog row',
rating_imdb: 7.1,
info: {
name: 'N/A',
o_name: 'Original Title',
releasedate: 'N/A',
genre: '-',
description: 'N/A',
rating_imdb: '0.0',
},
});
expect(info.name).toBe('Original Title');
expect(info.o_name).toBe('Original Title');
expect(info.releasedate).toBe('2011');
expect(info.genre).toBe('Drama');
expect(info.description).toBe('Plot from the catalog row');
expect(info.rating_imdb).toBe('7.1');
});
});
describe('firstNonBlankStalkerId', () => {
it('skips a blank candidate instead of treating it as set', () => {
expect(firstNonBlankStalkerId('', ' ', '77')).toBe('77');
@@ -4,8 +4,10 @@ import { Observable } from 'rxjs';
import { PlaylistsService } from '@iptvnator/services';
import {
createStalkerVodItem,
firstProviderText,
isStalkerSeriesFlag,
isStalkerSeriesItem,
readProviderRating,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
@@ -146,36 +148,46 @@ function toStringOrFallback(value: unknown, fallback = ''): string {
return String(value);
}
function toOptionalString(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
return String(value);
}
/**
* Maps a raw portal row (or an already-built selection, on re-normalization)
* to the `info` both Stalker detail templates render.
*
* Portals send `"N/A"`, `"-"` or an unrated `"0"` score where they have
* nothing, so every field reads the first USABLE candidate and falls back to
* `''`, which is how this shape spells "absent". The series view binds
* `info` directly, and TMDB enrichment keeps any non-empty provider rating
* and release date, so a stand-in surviving this point would be rendered
* AND would block the TMDB value that should have replaced it.
*/
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
const info = item.info ?? {};
const text = (...values: unknown[]) => firstProviderText(...values) ?? '';
const rating = (primary: unknown, fallback: unknown) =>
readProviderRating(primary) ?? readProviderRating(fallback) ?? '';
return {
name: toStringOrFallback(
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
'Unknown'
),
o_name: toOptionalString(info.o_name ?? item.o_name),
movie_image: toStringOrFallback(
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
),
description: toStringOrFallback(info.description ?? item.description),
actors: toStringOrFallback(info.actors ?? item.actors),
director: toStringOrFallback(info.director ?? item.director),
releasedate: toStringOrFallback(
info.releasedate ?? item.releasedate ?? item.year
),
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
rating_kinopoisk: toStringOrFallback(
info.rating_kinopoisk ?? item.rating_kinopoisk
name:
firstProviderText(
info.name,
info.o_name,
item.o_name,
item.name,
item.title
) ?? 'Unknown',
o_name: firstProviderText(info.o_name, item.o_name),
movie_image: text(
info.movie_image,
item.cover,
item.screenshot_uri,
item.logo
),
description: text(info.description, item.description),
actors: text(info.actors, item.actors),
director: text(info.director, item.director),
releasedate: text(info.releasedate, item.releasedate, item.year),
genre: text(info.genre, item.genre, item.genres_str),
rating_imdb: rating(info.rating_imdb, item.rating_imdb),
rating_kinopoisk: rating(info.rating_kinopoisk, item.rating_kinopoisk),
// TMDB enrichment fields must survive re-normalization — detail
// views normalize the selected item on every render
...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}),
@@ -6,13 +6,16 @@ import {
normalizeFavoriteStorage,
normalizeRecentStorage,
} from './pwa-collection-identity';
import type { UserDataStorage } from '@iptvnator/services';
import {
XTREAM_USER_DATA_KEYS,
type UserDataStorage,
} from '@iptvnator/services';
import { XtreamContentItem } from './xtream-data-source.interface';
const STORAGE_KEYS = {
COLLECTION_ITEMS: 'xtream-collection-items',
FAVORITES: 'xtream-favorites',
RECENT_ITEMS: 'xtream-recent-items',
COLLECTION_ITEMS: XTREAM_USER_DATA_KEYS.collectionItems,
FAVORITES: XTREAM_USER_DATA_KEYS.favorites,
RECENT_ITEMS: XTREAM_USER_DATA_KEYS.recentItems,
};
/** Synchronous persistence and migration using only snapshots and cached identity evidence. */
@@ -342,6 +342,64 @@ describe('PWA Xtream collection identity', () => {
).toEqual(types);
});
it('restores playback positions under the playlist they are restored to', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [
{
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 90,
playlistId: 'exported-id',
updatedAt: '2026-10-01T12:00:00.000Z',
},
],
});
expect(await source.getPlaybackPosition('p1', 42, 'vod')).toEqual({
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 90,
playlistId: 'p1',
updatedAt: '2026-10-01T12:00:00.000Z',
});
});
it('keeps the artwork saved with each view through clear/restore', async () => {
await source.restoreUserData('p1', {
hiddenCategories: [],
playbackPositions: [],
favorites: [],
recentlyViewed: [
{
contentType: 'movie',
xtreamId: 42,
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
],
});
expect(read('xtream-recent-items').p1).toEqual([
{
id: 'movie:42',
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
]);
const backup = await source.clearPlaylistContent('p1');
expect(backup.recentlyViewed).toEqual([
{
contentType: 'movie',
xtreamId: 42,
viewedAt: '2026-10-01T12:00:00.000Z',
backdropUrl: 'https://img.example.com/still.jpg',
},
]);
});
it('preserves ambiguous numeric legacy entries without selecting the last matching type', async () => {
localStorage.setItem(
'xtream-favorites',
@@ -22,6 +22,7 @@ import {
ParentalLockService,
PlaylistsService,
USER_DATA_STORAGE,
XTREAM_USER_DATA_KEYS,
} from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
@@ -42,6 +43,7 @@ import {
typedCollectionRef,
itemCollectionKey,
findCollectionItems,
normalizeStoredBackdrop,
} from './pwa-collection-identity';
import { PwaCollectionStorage } from './pwa-collection-storage';
@@ -50,7 +52,7 @@ import { PwaCollectionStorage } from './pwa-collection-storage';
*/
const STORAGE_KEYS = {
PLAYLISTS: 'xtream-playlists',
PLAYBACK_POSITIONS: 'xtream-playback-positions',
PLAYBACK_POSITIONS: XTREAM_USER_DATA_KEYS.playbackPositions,
};
interface XtreamCachedContentItem {
@@ -1356,6 +1358,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...identity,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
};
})
.filter(
@@ -1403,16 +1406,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
type: item.contentType,
});
if (id !== null)
recentItems[playlistId].push({ id, viewedAt: item.viewedAt });
recentItems[playlistId].push({
id,
viewedAt: item.viewedAt,
...normalizeStoredBackdrop(item),
});
}
this.collectionStorage.saveRecentItemsToStorage(recentItems);
// Restore playback positions
// Restore playback positions under this playlist: an archive's carry
// the id they were exported with, and the player matches on it.
this.clearPlaybackPositionsForPlaylist(playlistId);
const playbackPositions = this.getPlaybackPositionsFromStorage();
playbackPositions[playlistId] = restoreState.playbackPositions.map(
(position) => ({
...position,
playlistId,
updatedAt: position.updatedAt ?? new Date().toISOString(),
})
);
@@ -0,0 +1,99 @@
import { XtreamSerieInfo, XtreamVodInfo } from '@iptvnator/shared/interfaces';
import {
stripXtreamSerieInfoPlaceholders,
stripXtreamVodInfoPlaceholders,
} from './xtream-detail-metadata';
describe('Xtream detail placeholders', () => {
it('empties every stand-in the VOD detail route would render', () => {
const info = stripXtreamVodInfoPlaceholders({
name: 'Placeholder Film',
o_name: 'N/A',
movie_image: 'N/A',
cover_big: '-',
releasedate: 'N/A',
genre: 'n/a',
duration: 'N/A',
country: '-',
director: 'N/A',
actors: 'N/A',
cast: '-',
description: 'N/A',
plot: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
backdrop_path: ['N/A', 'https://img.example/backdrop.jpg'],
duration_secs: 0,
} as unknown as XtreamVodInfo);
expect(info).toEqual({
name: 'Placeholder Film',
o_name: '',
movie_image: '',
cover_big: '',
releasedate: '',
genre: '',
duration: '',
country: '',
director: '',
actors: '',
cast: '',
description: '',
plot: '',
rating_imdb: '',
rating_kinopoisk: '',
backdrop_path: ['https://img.example/backdrop.jpg'],
duration_secs: 0,
});
});
it('leaves real values, absent fields and non-text fields untouched', () => {
const source = {
name: 'Real Film',
releasedate: '2019-04-05',
rating_imdb: '7.4',
rating: 0,
tmdb_id: 123,
} as unknown as XtreamVodInfo;
expect(stripXtreamVodInfoPlaceholders(source)).toEqual(source);
});
it('empties every stand-in the series detail route would render', () => {
const info = stripXtreamSerieInfoPlaceholders({
name: 'Placeholder Series',
cover: 'N/A',
plot: 'N/A',
cast: '-',
director: 'n/a',
genre: 'N/A',
releaseDate: 'N/A',
rating: '0',
rating_5based: 0,
episode_run_time: 'N/A',
backdrop_path: ['-'],
} as unknown as XtreamSerieInfo);
expect(info).toEqual({
name: 'Placeholder Series',
cover: '',
plot: '',
cast: '',
director: '',
genre: '',
releaseDate: '',
rating: '',
rating_5based: 0,
episode_run_time: '',
backdrop_path: [],
});
});
it('passes a malformed info payload through unchanged', () => {
expect(stripXtreamSerieInfoPlaceholders(undefined as never)).toBe(
undefined
);
const list = [] as unknown as XtreamSerieInfo;
expect(stripXtreamSerieInfoPlaceholders(list)).toBe(list);
});
});
@@ -0,0 +1,98 @@
import {
readProviderRating,
readProviderText,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
const VOD_TEXT_FIELDS = [
'name',
'o_name',
'movie_image',
'cover_big',
'releasedate',
'genre',
'duration',
'country',
'director',
'actors',
'cast',
'description',
'plot',
] as const satisfies readonly (keyof XtreamVodInfo)[];
const VOD_RATING_FIELDS = [
'rating_imdb',
'rating_kinopoisk',
] as const satisfies readonly (keyof XtreamVodInfo)[];
const SERIE_TEXT_FIELDS = [
'name',
'cover',
'plot',
'cast',
'director',
'genre',
'releaseDate',
'episode_run_time',
] as const satisfies readonly (keyof XtreamSerieInfo)[];
const SERIE_RATING_FIELDS = [
'rating',
] as const satisfies readonly (keyof XtreamSerieInfo)[];
/**
* Removes provider stand-ins (`"N/A"`, `"-"`, an unrated `"0"` score) from
* an Xtream detail response before it becomes the store selection.
*
* The VOD and series detail routes bind these `info` fields directly, and
* TMDB enrichment keeps any non-empty provider rating and release date — a
* stand-in left here is both rendered and shields the TMDB value that should
* have replaced it. Only string fields that are present are rewritten, to
* `''` (how these shapes spell "absent"), so the response keeps its shape.
*/
export function stripXtreamVodInfoPlaceholders(
info: XtreamVodInfo
): XtreamVodInfo {
return stripPlaceholders(info, VOD_TEXT_FIELDS, VOD_RATING_FIELDS);
}
/** {@link stripXtreamVodInfoPlaceholders} for `get_series_info`. */
export function stripXtreamSerieInfoPlaceholders(
info: XtreamSerieInfo
): XtreamSerieInfo {
return stripPlaceholders(info, SERIE_TEXT_FIELDS, SERIE_RATING_FIELDS);
}
function stripPlaceholders<T extends { backdrop_path?: unknown }>(
info: T,
textFields: readonly (keyof T)[],
ratingFields: readonly (keyof T)[]
): T {
// Broken panels answer with `[]` or nothing; leave those to the callers'
// existing sparse-response handling
if (!info || typeof info !== 'object' || Array.isArray(info)) {
return info;
}
const stripped: Record<keyof T, unknown> = { ...info };
const rewrite = (
fields: readonly (keyof T)[],
read: (value: unknown) => string | undefined
) => {
for (const field of fields) {
const value = info[field];
if (typeof value === 'string') {
stripped[field] = read(value) ?? '';
}
}
};
rewrite(textFields, readProviderText);
rewrite(ratingFields, readProviderRating);
if (Array.isArray(info.backdrop_path)) {
stripped.backdrop_path = info.backdrop_path.filter(
(url) => readProviderText(url) !== undefined
);
}
return stripped as T;
}
@@ -28,6 +28,28 @@ describe('Xtream VOD selection helpers', () => {
);
});
it('strips provider stand-ins from the detail info it publishes', () => {
const selection = buildXtreamVodSelection(
{
info: {
name: 'Placeholder Film',
releasedate: 'N/A',
rating_imdb: 'N/A',
director: '-',
},
} as unknown as Parameters<typeof buildXtreamVodSelection>[0],
undefined,
42
);
expect(selection.info).toEqual({
name: 'Placeholder Film',
releasedate: '',
rating_imdb: '',
director: '',
});
});
it('keeps a complete detail playback pair ahead of a catalog collision', () => {
const catalogItem = {
stream_id: 42,
@@ -1,4 +1,5 @@
import {
getXtreamVodInfo,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
@@ -6,6 +7,7 @@ import {
resolveXtreamVodPlaybackSource,
XtreamVodPlaybackItem,
} from '../services/xtream-vod-playback-source';
import { stripXtreamVodInfoPlaceholders } from './xtream-detail-metadata';
export type XtreamVodSelection = XtreamVodDetails & {
[key: string]: unknown;
@@ -35,11 +37,13 @@ export function buildXtreamVodSelection(
resolveXtreamVodPlaybackSource(
catalogItem as unknown as XtreamVodPlaybackItem
);
const info = getXtreamVodInfo(vodDetails);
return {
...catalogItem,
...recoveredCatalogItem,
...vodDetails,
...(info ? { info: stripXtreamVodInfoPlaceholders(info) } : {}),
stream_id: playbackSource?.streamId ?? Number(vodId),
container_extension: playbackSource?.containerExtension ?? null,
xtream_id:
@@ -345,6 +345,39 @@ describe('XtreamStore VOD details recovery', () => {
expect(store.isLoadingDetails()).toBe(false);
});
it('publishes series details without provider stand-ins', async () => {
const xtreamApiService = {
getSeriesInfo: jest.fn().mockResolvedValue({
info: {
name: 'Placeholder Series',
plot: 'N/A',
rating: '0',
releaseDate: 'N/A',
},
seasons: [],
episodes: {},
}),
getVodInfo: jest.fn(),
getVodStream: jest.fn(),
};
const store = configureStore(xtreamApiService);
store.fetchSerialDetailsWithMetadata({
categoryId: 7,
serialId: '99',
});
await waitForCondition(() => store.selectedItem() !== null);
expect(
(store.selectedItem() as { info: Record<string, unknown> }).info
).toEqual({
name: 'Placeholder Series',
plot: '',
rating: '',
releaseDate: '',
});
});
it('clears the previous series while a reused-route request is pending or fails', async () => {
const nextDetails = createDeferred<never>();
const xtreamApiService = {
@@ -35,6 +35,7 @@ import {
createXtreamDetailsRequestGuard,
resolveXtreamVodDetailsSelection,
} from './xtream-details-request';
import { stripXtreamSerieInfoPlaceholders } from './xtream-detail-metadata';
/** Facade composing the Xtream feature stores and cross-feature workflows. */
export const XtreamStore = signalStore(
@@ -255,6 +256,9 @@ export const XtreamStore = signalStore(
store.setSelectedCategory(params.categoryId);
store.setSelectedItem({
...serialDetails,
info: stripXtreamSerieInfoPlaceholders(
serialDetails?.info
),
series_id: params.serialId,
});
store.resetTmdbSeasonMetadata(params.serialId);
@@ -1,7 +1,7 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { of } from 'rxjs';
import { BehaviorSubject, of } from 'rxjs';
import { DatabaseService } from '@iptvnator/services';
import { SearchResultsComponent } from './search-results.component';
import {
@@ -54,6 +54,9 @@ class MockXtreamStore {
readonly searchFilters = signal(DEFAULT_SEARCH_FILTERS);
readonly searchResults = signal<XtreamContentItem[]>([]);
readonly isSearching = signal(false);
readonly isLoadingContent = signal(false);
readonly isLoadingCategories = signal(false);
readonly isImporting = signal(false);
setSearchTerm = jest.fn((term: string) => {
this.searchTerm.set(term);
@@ -377,7 +380,7 @@ describe('SearchResultsComponent global search', () => {
it('refuses to append pages while an edited query is still debouncing', async () => {
// Regression: the auto-fill can request more results right after the
// reset identity changes, i.e. inside the 300ms search debounce. An
// reset identity changes, before the search effect has run. An
// append with the edited term would fetch a page of the NEW query at
// the OLD offset and interleave it into the old query's results.
const firstPage = Array.from({ length: 101 }, (_, index) =>
@@ -402,7 +405,7 @@ describe('SearchResultsComponent global search', () => {
databaseService.globalSearchContent.mockClear();
// The user edits the query; the fresh offset-zero search has not run
// yet (debounce pending), so appending must be refused entirely.
// yet (effects not flushed), so appending must be refused entirely.
store.searchTerm.set('matrix reloaded');
await component.loadMoreGlobalResults();
@@ -506,3 +509,190 @@ describe('SearchResultsComponent in-portal result window', () => {
}
});
});
describe('SearchResultsComponent search trigger', () => {
let queryParams: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let globalSearchContent: jest.Mock;
function setUp(initialQuery: string): void {
queryParams = new BehaviorSubject(
convertToParamMap({ q: initialQuery })
);
globalSearchContent = jest.fn().mockResolvedValue([]);
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: { isGlobalSearch: true },
queryParamMap: queryParams.value,
},
queryParamMap: queryParams.asObservable(),
},
},
{
provide: DatabaseService,
useValue: { globalSearchContent },
},
],
});
}
function searchedTerms(): string[] {
return globalSearchContent.mock.calls.map(([term]) => term as string);
}
afterEach(() => {
jest.useRealTimers();
});
it('queries an applied term at once, without a second debounce', () => {
// The shell already debounces typing; a second timer here only added
// 300 ms to every search.
jest.useFakeTimers();
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(jest.getTimerCount()).toBe(0);
});
it('queries only the route term when the store still holds an older one', () => {
setUp('system');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.searchTerm.set('matrix');
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
});
it('keeps a term applied ahead of the URL instead of re-querying the stale q', () => {
// The shell sets the store term first and writes `q` afterwards; the
// route sync must not snap the store back to the previous `q`.
setUp('syst');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
globalSearchContent.mockClear();
store.setSearchTerm('system');
TestBed.flushEffects();
queryParams.next(convertToParamMap({ q: 'system' }));
TestBed.flushEffects();
expect(searchedTerms()).toEqual(['system']);
expect(store.searchTerm()).toBe('system');
});
it('ignores an older query that resolves after a newer term', async () => {
setUp('');
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
const older = createDeferred<GlobalSearchResult[]>();
const newer = createDeferred<GlobalSearchResult[]>();
globalSearchContent
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.setSearchTerm('syst');
TestBed.flushEffects();
store.setSearchTerm('system');
TestBed.flushEffects();
newer.resolve([
createSearchItem({ title: 'System' }) as GlobalSearchResult,
]);
await newer.promise;
older.resolve([
createSearchItem({ title: 'Syst' }) as GlobalSearchResult,
]);
await older.promise;
await Promise.resolve();
expect(searchedTerms()).toEqual(['syst', 'system']);
expect(store.searchResults().map((item) => item.title)).toEqual([
'System',
]);
});
});
describe('SearchResultsComponent in-portal search and catalog loading', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: XtreamStore, useClass: MockXtreamStore },
{ provide: Router, useValue: { navigate: jest.fn() } },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({ q: 'matrix' }),
},
queryParamMap: of(convertToParamMap({ q: 'matrix' })),
},
},
{
provide: DatabaseService,
useValue: {
globalSearchContent: jest.fn().mockResolvedValue([]),
},
},
],
});
});
function searchedTerms(store: MockXtreamStore): string[] {
return store.searchContent.mock.calls.map(
([params]) => (params as { term: string }).term
);
}
it('runs a prefilled search again once the catalog has loaded', () => {
// The in-portal search reads the loaded catalog, so a term applied
// while it loads would otherwise stay without results.
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix', 'matrix']);
});
it('searches once when a new term and the loaded catalog arrive together', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
store.isLoadingContent.set(true);
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
store.searchContent.mockClear();
store.setSearchTerm('matrix reloaded');
store.isLoadingContent.set(false);
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix reloaded']);
});
it('searches once when the catalog is already loaded', () => {
const store = TestBed.inject(XtreamStore) as unknown as MockXtreamStore;
TestBed.runInInjectionContext(() => new SearchResultsComponent());
TestBed.flushEffects();
expect(searchedTerms(store)).toEqual(['matrix']);
});
});
@@ -7,6 +7,7 @@ import {
inject,
linkedSignal,
signal,
untracked,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
@@ -121,6 +122,14 @@ export class SearchResultsComponent implements AfterViewInit {
readonly isGlobalSearch =
this.activatedRoute.snapshot.data?.['isGlobalSearch'] === true;
/** Nothing of the portal's catalog is loading or importing any more. */
private readonly isCatalogReady = computed(
() =>
!this.xtreamStore.isLoadingContent() &&
!this.xtreamStore.isLoadingCategories() &&
!this.xtreamStore.isImporting()
);
/** Whether to group global search results by playlist */
readonly groupByPlaylist = signal(
localStorage.getItem(SearchResultsComponent.GROUP_BY_STORAGE_KEY) !==
@@ -249,24 +258,48 @@ export class SearchResultsComponent implements AfterViewInit {
}
}
effect((onCleanup) => {
const term = this.searchTerm();
if (term.length >= this.minSearchLength) {
const timeout = setTimeout(() => this.executeSearch(), 300);
onCleanup(() => clearTimeout(timeout));
} else if (term.length < this.minSearchLength) {
this.clearResultsOnly();
}
});
// Created first so a route `q` reaches the store before the search
// effect's first run: a term left in the store by an earlier visit
// must not be queried on the way to the route's term.
effect(() => {
const queryTerm = this.routeSearchTerm();
if (queryTerm === this.searchTerm()) {
if (queryTerm === untracked(this.searchTerm)) {
return;
}
this.xtreamStore.setSearchTerm(queryTerm);
});
// One effect for both triggers, so a term and the end of catalog
// loading arriving in the same flush run one search, not two.
// - A new term runs at once: typed terms arrive already debounced by
// the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), and a term from
// the URL or a filter change is a single deliberate change. A newer
// term supersedes an older query still in flight (request versions).
// - A prefilled in-portal search (a reload or deep link with `q`) can
// run while the portal's catalog is still loading, and the in-portal
// search reads that catalog: it runs again once loading has finished.
let previousTerm: string | undefined;
let wasCatalogReady: boolean | undefined;
effect(() => {
const term = this.searchTerm();
const ready = this.isCatalogReady();
const termChanged = term !== previousTerm;
const becameReady =
wasCatalogReady === false && ready && !this.isGlobalSearch;
previousTerm = term;
wasCatalogReady = ready;
if (!termChanged && !becameReady) {
return;
}
untracked(() => {
if (term.length >= this.minSearchLength) {
this.executeSearch();
} else if (termChanged) {
this.clearResultsOnly();
}
});
});
}
ngAfterViewInit() {
@@ -174,4 +174,37 @@ describe('vod-details-fallback.util', () => {
year: undefined,
});
});
describe('placeholder ratings', () => {
// Placeholder-only `info` is stripped upstream, which routes the
// movie here — the top-level catalog ratings must not resurface as
// "N/A" or a "0.0" score on this page instead.
const ratingOf = (
detail: Record<string, unknown>,
catalog: Record<string, unknown>
) =>
buildXtreamVodFallbackViewModel({
vodDetails: { info: [], ...detail } as XtreamVodDetails,
catalogItem: catalog as Partial<XtreamVodStream>,
vodId: 31,
})?.rating;
it('skips stand-ins and unrated zeros to the next usable rating', () => {
expect(
ratingOf(
{ rating_imdb: 'N/A', rating: 0 },
{ rating_imdb: '-', rating: '7.25' }
)
).toBe('7.25');
});
it('states no rating when every candidate is a stand-in', () => {
expect(
ratingOf(
{ rating_imdb: 'N/A', rating: '0' },
{ rating_imdb: '0.0', rating: 0 }
)
).toBeUndefined();
});
});
});
@@ -3,6 +3,7 @@ import {
XtreamVodInfo,
XtreamVodStream,
getXtreamVodInfo,
readProviderRating,
} from '@iptvnator/shared/interfaces';
type XtreamCategoryContext = {
@@ -117,9 +118,9 @@ export function buildXtreamVodFallbackViewModel(
sanitizeText(info?.cover_big) ??
undefined;
const rating =
sanitizeText(detailItem?.rating_imdb) ??
formatRating(detailItem?.rating_imdb) ??
formatRating(detailItem?.rating) ??
sanitizeText(catalogItem?.rating_imdb) ??
formatRating(catalogItem?.rating_imdb) ??
formatRating(catalogItem?.rating);
const added = formatUnixDate(
movieData?.added ??
@@ -200,16 +201,16 @@ function numericValue(value: unknown): number | undefined {
return undefined;
}
/**
* Provider stand-ins and an unrated zero read as no rating, so the next
* candidate is reached — see `readProviderRating`.
*/
function formatRating(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value.toFixed(1);
const text = readProviderRating(value);
if (text === undefined) {
return undefined;
}
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return undefined;
return typeof value === 'number' ? value.toFixed(1) : text;
}
function extractYear(value: string | undefined): string | undefined {
@@ -0,0 +1,669 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import {
createPlaylistBackupService,
createPwaXtreamBackupState,
} from './playlist-backup.service.test-helpers';
import { existingXtreamPlaylist } from './playlist-backup.xtream-fixtures';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
USER_DATA_STORAGE,
UserDataStorage,
XTREAM_USER_DATA_KEYS,
} from './user-data-storage';
/**
* The browser build keeps Xtream favorites, history, positions and title
* snapshots in USER_DATA_STORAGE rather than SQLite. These specs run the
* backup with no Electron bridge, against the maps exactly as
* `PwaXtreamDataSource` writes them.
*/
describe('PlaylistBackupService Xtream user state in the PWA', () => {
const KEYS = XTREAM_USER_DATA_KEYS;
const otherPlaylist = {
...existingXtreamPlaylist,
_id: 'xtream-2',
title: 'Other Portal',
serverUrl: 'http://other.example.com',
username: 'other',
} as Playlist;
const heat = {
id: 501,
xtream_id: 501,
type: 'movie',
title: 'Heat',
poster_url: 'https://img.example.com/heat.jpg',
backdrop_url: 'https://img.example.com/heat-wide.jpg',
};
// Written under a bare id by builds that predate typed keys.
const legacyShow = { id: 77, xtream_id: 77, type: 'series', title: 'Show' };
const position = {
contentXtreamId: 501,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 3600,
playlistId: 'xtream-1',
updatedAt: '2026-07-05T20:00:00.000Z',
};
beforeEach(() => {
delete (window as unknown as { electron?: unknown }).electron;
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
TestBed.resetTestingModule();
});
function seed(storage: UserDataStorage = localStorage): void {
const write = (key: string, value: unknown) =>
storage.setItem(key, JSON.stringify(value));
write(KEYS.favorites, {
// 88 is a bare id no snapshot identifies: live, movie or series.
'xtream-1': ['movie:501', 77, 88],
'xtream-2': ['live:9'],
});
write(KEYS.recentItems, {
'xtream-1': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-01T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-old.jpg',
},
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
// Saved by an older build in snake_case.
backdrop_url: 'https://img.example.com/heat-still.jpg',
},
],
'xtream-2': [
{ id: 'live:9', viewedAt: '2026-07-02T10:00:00.000Z' },
],
});
write(KEYS.collectionItems, {
'xtream-1': {
'movie:501': heat,
'77': legacyShow,
'movie:999': { xtream_id: 999, type: 'movie', title: 'Gone' },
},
'xtream-2': { 'live:9': { xtream_id: 9, type: 'live' } },
});
write(KEYS.playbackPositions, {
'xtream-1': [position],
'xtream-2': [{ ...position, playlistId: 'xtream-2' }],
});
}
function read(key: string, storage: UserDataStorage = localStorage) {
return JSON.parse(storage.getItem(key) ?? 'null');
}
function pendingRestore(service: object) {
return (
service as unknown as {
pendingRestoreService: {
getOrThrow(playlistId: string): unknown;
set(playlistId: string, state: unknown): unknown;
};
}
).pendingRestoreService;
}
function xtreamOnly(manifest: PlaylistBackupManifestV1) {
return JSON.stringify({
...manifest,
playlists: [xtreamEntry(manifest)],
});
}
function playlistsService(playlists: Playlist[]) {
return {
getAllData: jest.fn(() => of(playlists)),
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
};
}
function xtreamEntry(
manifest: PlaylistBackupManifestV1,
exportedId = 'xtream-1'
): XtreamPlaylistBackupEntry {
return manifest.playlists.find(
(entry) => entry.exportedId === exportedId
) as XtreamPlaylistBackupEntry;
}
async function exportSeeded(): Promise<PlaylistBackupManifestV1> {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
return (await service.exportBackup()).manifest;
}
describe('export', () => {
it('writes each playlist its own favorites, history, positions and snapshots', async () => {
const manifest = await exportSeeded();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'series', xtreamId: 77 },
],
// One row per title, its newest view.
recentlyViewed: [
{
contentType: 'live',
xtreamId: 601,
viewedAt: '2026-07-04T18:30:00.000Z',
},
{
contentType: 'movie',
xtreamId: 501,
viewedAt: '2026-07-03T10:00:00.000Z',
// The history's own artwork, ahead of the snapshot's.
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
playbackPositions: [position],
// Only what the lists reference, not every cached title.
collectionItems: [heat, legacyShow],
});
expect(xtreamEntry(manifest, 'xtream-2').userState).toEqual({
hiddenCategories: [],
favorites: [{ contentType: 'live', xtreamId: 9 }],
recentlyViewed: [
{
contentType: 'live',
xtreamId: 9,
viewedAt: '2026-07-02T10:00:00.000Z',
},
],
playbackPositions: [{ ...position, playlistId: 'xtream-2' }],
collectionItems: [{ xtream_id: 9, type: 'live' }],
});
// No pin store in the browser: absent, never an authoritative [].
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'sourcePins'
);
});
it('exports empty collections and no snapshots for a playlist with nothing stored', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual({
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
});
expect(xtreamEntry(manifest).userState).not.toHaveProperty(
'collectionItems'
);
});
it('exports a parked restore as it is instead of the stored lists', async () => {
seed();
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const parked = {
hiddenCategories: [],
favorites: [{ contentType: 'series' as const, xtreamId: 3 }],
recentlyViewed: [],
playbackPositions: [],
};
pendingRestore(service).set('xtream-1', parked);
const { manifest } = await service.exportBackup();
expect(xtreamEntry(manifest).userState).toEqual(parked);
});
it.each([
['malformed JSON', '{"xtream-1": ['],
['a value that is not a map', '["movie:501"]'],
[
'a playlist entry that is not a list',
'{"xtream-1": "movie:501"}',
],
])('refuses to export when the favorites hold %s', async (_, raw) => {
seed();
localStorage.setItem(KEYS.favorites, raw);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
await expect(service.exportBackup()).rejects.toThrow(
/xtream-favorites.*unreadable/
);
});
it('refuses to export when the storage itself cannot be read', async () => {
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
await expect(service.exportBackup()).rejects.toThrow('denied');
});
});
describe('import', () => {
it('restores every list under the id the playlist is imported as', async () => {
const manifest = await exportSeeded();
localStorage.clear();
// The same portal, already present under another id: a merge.
const local = { ...existingXtreamPlaylist, _id: 'local-xtream' };
localStorage.setItem(
KEYS.favorites,
JSON.stringify({
'local-xtream': ['live:1'],
unrelated: ['movie:5'],
})
);
localStorage.setItem(
KEYS.collectionItems,
JSON.stringify({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
},
})
);
const service = createPlaylistBackupService({
playlistsService: playlistsService([local]),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary).toMatchObject({ merged: 1, failed: 0 });
// The archive's lists replace the playlist's; others stay put.
expect(read(KEYS.favorites)).toEqual({
'local-xtream': ['movie:501', 'series:77'],
unrelated: ['movie:5'],
});
expect(read(KEYS.recentItems)).toEqual({
'local-xtream': [
{ id: 'live:601', viewedAt: '2026-07-04T18:30:00.000Z' },
{
id: 'movie:501',
viewedAt: '2026-07-03T10:00:00.000Z',
backdropUrl: 'https://img.example.com/heat-still.jpg',
},
],
});
expect(read(KEYS.playbackPositions)).toEqual({
'local-xtream': [{ ...position, playlistId: 'local-xtream' }],
});
// Snapshots are a cache: merged, keyed by typed identity.
expect(read(KEYS.collectionItems)).toEqual({
'local-xtream': {
'live:1': { xtream_id: 1, type: 'live' },
'movie:501': heat,
'series:77': legacyShow,
},
});
// Applied at once, so nothing is left for the store to replay.
expect(
pendingRestore(service).getOrThrow('local-xtream')
).toBeNull();
});
it('round-trips the stored lists through export and import', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const service = createPlaylistBackupService({
playlistsService: playlistsService([]),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary).toMatchObject({ imported: 2, failed: 0 });
const reexport = createPlaylistBackupService({
playlistsService: playlistsService([
existingXtreamPlaylist,
otherPlaylist,
]),
});
const { manifest: again } = await reexport.exportBackup();
expect(xtreamEntry(again).userState).toEqual(
xtreamEntry(manifest).userState
);
expect(xtreamEntry(again, 'xtream-2').userState).toEqual(
xtreamEntry(manifest, 'xtream-2').userState
);
});
it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => {
const manifest = await exportSeeded();
const entry = xtreamEntry(manifest);
const before = read(KEYS.collectionItems);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const withoutSnapshots = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: undefined,
},
},
],
};
await service.importBackup(JSON.stringify(withoutSnapshots));
expect(read(KEYS.collectionItems)).toEqual(before);
const damaged = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
collectionItems: [
null,
{ xtream_id: 5, type: 'radio' },
{ xtream_id: '6', type: 'movie' },
],
},
},
],
};
const summary = await service.importBackup(JSON.stringify(damaged));
expect(summary.failed).toBe(0);
expect(read(KEYS.collectionItems)).toEqual(before);
});
it('drops entries no runtime could address', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const entry = xtreamEntry(manifest);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
const hostile = {
...manifest,
playlists: [
{
...entry,
userState: {
...entry.userState,
favorites: [
{ contentType: 'radio', xtreamId: 1 },
{ contentType: 'movie', xtreamId: -4 },
{ contentType: 'movie', xtreamId: 501 },
{ contentType: 'movie', xtreamId: '501' },
],
recentlyViewed: [
{ contentType: 'live', xtreamId: 601 },
],
playbackPositions: [
{ contentXtreamId: 501, contentType: 'vod' },
{ ...position, updatedAt: undefined },
],
},
},
],
};
await service.importBackup(JSON.stringify(hostile));
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501'],
});
expect(read(KEYS.recentItems)).toEqual({ 'xtream-1': [] });
const [restored] = read(KEYS.playbackPositions)['xtream-1'];
expect(read(KEYS.playbackPositions)['xtream-1']).toHaveLength(1);
expect(typeof restored.updatedAt).toBe('string');
});
it('fails the entry and keeps the restore parked when a write fails', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.recentItems) throw new Error('disk full');
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('disk full');
// The Xtream store replays the whole state when the playlist opens.
expect(
pendingRestore(service).getOrThrow('xtream-1')
).not.toBeNull();
// That replay carries no titles, so they are merged regardless.
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
});
it('replaces a damaged title cache but refuses an unreadable storage', async () => {
const manifest = await exportSeeded();
localStorage.setItem(KEYS.collectionItems, '{"xtream-1": {');
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
});
expect(
(await service.importBackup(xtreamOnly(manifest))).failed
).toBe(0);
expect(read(KEYS.collectionItems)).toEqual({
'xtream-1': { 'movie:501': heat, 'series:77': legacyShow },
});
const denied = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState({
getItem: () => {
throw new DOMException('denied', 'SecurityError');
},
setItem: jest.fn(),
removeItem: jest.fn(),
}),
});
const summary = await denied.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(1);
expect(summary.errors[0]).toContain('denied');
});
it('still restores the lists when the snapshots exceed the quota', async () => {
const manifest = await exportSeeded();
localStorage.clear();
const storage = createBrowserBackedStorage();
storage.setItem.mockImplementation((key: string, value: string) => {
if (key === KEYS.collectionItems) {
throw new DOMException('full', 'QuotaExceededError');
}
localStorage.setItem(key, value);
});
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(storage),
});
const summary = await service.importBackup(
JSON.stringify(manifest)
);
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)['xtream-1']).toEqual([
'movie:501',
'series:77',
]);
expect(read(KEYS.collectionItems)).toBeNull();
});
});
describe('near the storage quota', () => {
const listKeys = [
KEYS.favorites,
KEYS.recentItems,
KEYS.playbackPositions,
];
/** Storage that refuses a write once all values exceed `budget`. */
function quotaStorage(budget: number) {
const data = new Map<string, string>();
const used = () =>
[...data].reduce((sum, [k, v]) => sum + k.length + v.length, 0);
const storage: UserDataStorage = {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => {
const previous = data.get(key);
data.set(key, value);
if (used() <= budget) return;
if (previous === undefined) data.delete(key);
else data.set(key, previous);
throw new DOMException('full', 'QuotaExceededError');
},
removeItem: (key) => void data.delete(key),
};
return { data, storage };
}
async function importInto(budget: number) {
const manifest = await exportSeeded();
const quota = quotaStorage(budget);
const service = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream: createPwaXtreamBackupState(quota.storage),
});
const summary = await service.importBackup(xtreamOnly(manifest));
return { summary, data: quota.data };
}
async function listsSize(): Promise<number> {
const { data } = await importInto(Infinity);
return listKeys.reduce(
(sum, key) => sum + key.length + (data.get(key) ?? '').length,
0
);
}
it('restores the lists that fit without the optional snapshots', async () => {
const { summary, data } = await importInto(await listsSize());
expect(summary.failed).toBe(0);
expect(JSON.parse(data.get(KEYS.favorites) ?? '{}')).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(data.has(KEYS.collectionItems)).toBe(false);
});
it('leaves the space to the replay when a list does not fit', async () => {
const { summary, data } = await importInto((await listsSize()) - 1);
expect(summary.failed).toBe(1);
// The snapshots would only take room the store's replay needs.
expect(data.has(KEYS.collectionItems)).toBe(false);
});
});
it('follows the Xtream data source on a desktop bridge without its SQLite API', async () => {
// A partial or stale preload: XTREAM_DATA_SOURCE falls back to the
// browser storage, so the backup must read and restore that too.
(window as unknown as { electron?: unknown }).electron = {};
const runtime = { supportsXtreamSqliteDataSource: false };
const manifest = await (async () => {
seed();
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
return (await exporter.exportBackup()).manifest;
})();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
localStorage.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
runtime,
});
const summary = await importer.importBackup(xtreamOnly(manifest));
expect(summary.failed).toBe(0);
expect(read(KEYS.favorites)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(pendingRestore(importer).getOrThrow('xtream-1')).toBeNull();
});
it('reads and writes through USER_DATA_STORAGE, not localStorage', async () => {
// The self-hosted server mode provides a server-synced storage here.
const synced = new Map<string, string>();
const serverStorage: UserDataStorage = {
getItem: (key) => synced.get(key) ?? null,
setItem: (key, value) => void synced.set(key, value),
removeItem: (key) => void synced.delete(key),
};
TestBed.configureTestingModule({
providers: [
{ provide: USER_DATA_STORAGE, useValue: serverStorage },
],
});
const pwaXtream = TestBed.inject(PwaXtreamBackupStateService);
seed(serverStorage);
const exporter = createPlaylistBackupService({
playlistsService: playlistsService([existingXtreamPlaylist]),
pwaXtream,
});
const { json, manifest } = await exporter.exportBackup();
expect(xtreamEntry(manifest).userState.favorites).toHaveLength(2);
synced.clear();
const importer = createPlaylistBackupService({
playlistsService: playlistsService([]),
pwaXtream,
});
await importer.importBackup(json);
expect(read(KEYS.favorites, serverStorage)).toEqual({
'xtream-1': ['movie:501', 'series:77'],
});
expect(localStorage.getItem(KEYS.favorites)).toBeNull();
});
function createBrowserBackedStorage() {
return {
getItem: (key: string) => localStorage.getItem(key),
setItem: jest.fn(),
removeItem: (key: string) => localStorage.removeItem(key),
};
}
});
@@ -182,57 +182,49 @@ describe('PlaylistBackupService export → import round-trip', () => {
return entry;
}
it.each([true, false])(
'preserves a fresh pending import on re-export (Electron: %s)',
async (electron) => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
if (!electron) delete electronWindow.electron;
// The browser build applies an import at once (see the PWA Xtream spec),
// so only the desktop leaves one pending while its catalog downloads.
it('preserves a fresh pending import on re-export', async () => {
const state = seedState();
state.xtreamFavorites.push({
xtream_id: 501,
type: 'series',
position: 1,
});
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const original = await service.exportBackup();
const originalState = xtreamEntry(original.manifest).userState;
expect(originalState.sourcePins).toHaveLength(1);
state.xtreamCategories = [];
state.xtreamFavorites = [];
state.xtreamRecent = [];
state.playbackPositions = [];
state.sourcePins = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
);
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(
originalState
);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow(
'xtream-1'
)
).toEqual(snapshot);
}
);
const summary = await service.importBackup(original.json);
expect(summary.failed).toBe(0);
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1');
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
).mockRejectedValue(new Error('Position storage unavailable'));
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState).toEqual(originalState);
expect(
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1')
).toEqual(snapshot);
});
it.each([true, false])(
'honors empty pending collections and optional source pins (present: %s)',
@@ -8,11 +8,29 @@ import {
XtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
import { PlaylistBackupService } from './playlist-backup.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import {
createBrowserUserDataStorage,
UserDataStorage,
} from './user-data-storage';
import {
XtreamPendingRestoreService,
XtreamPendingRestoreSnapshot,
} from './xtream-pending-restore.service';
/**
* The browser build's Xtream backup state over `storage` — jsdom's
* localStorage unless a spec passes its own — without Angular DI.
*/
export function createPwaXtreamBackupState(
storage: UserDataStorage = createBrowserUserDataStorage()
): PwaXtreamBackupStateService {
return Object.assign(
Object.create(PwaXtreamBackupStateService.prototype) as object,
{ storage }
) as unknown as PwaXtreamBackupStateService;
}
/**
* Shared factory for PlaylistBackupService specs. Instantiates the service
* without Angular DI and replaces every collaborator with jest mocks;
@@ -132,6 +150,13 @@ export function createPlaylistBackupService(
locksFor: jest.fn(() => ({ xtream: [], stalker: [], m3u: [] })),
replacePlaylistLocks: jest.fn().mockResolvedValue(true),
},
pwaXtream: createPwaXtreamBackupState(),
// Specs pick the runtime by setting or deleting `window.electron`.
runtime: {
get supportsXtreamSqliteDataSource() {
return !!(window as unknown as { electron?: unknown }).electron;
},
},
...overrides,
});
@@ -4,7 +4,9 @@ import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import { PlaybackPositionService } from './playback-position.service';
import { PwaXtreamBackupStateService } from './pwa-xtream-backup-state.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import { ParentalLockService } from './parental-lock/parental-lock.service';
import { removesParentalLocks } from './parental-lock/parental-lock-store.util';
@@ -62,9 +64,6 @@ export class PlaylistBackupError extends Error {
type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker';
type XtreamContentType = XtreamBackupContentType;
type XtreamCategoryType = XtreamBackupCategoryType;
@Injectable({
providedIn: 'root',
})
@@ -78,6 +77,8 @@ export class PlaylistBackupService {
XtreamPendingRestoreService
);
private readonly parentalLock = inject(ParentalLockService);
private readonly pwaXtream = inject(PwaXtreamBackupStateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private backupImportTail?: Promise<void>;
async exportBackup(): Promise<PlaylistBackupExportPayload> {
@@ -332,7 +333,9 @@ export class PlaylistBackupService {
};
}
// An unavailable pin store is no opinion, never an authoritative [].
if (!this.hasElectronApi()) return entry;
// Same choice as XTREAM_DATA_SOURCE: user state lives where it reads.
if (!this.runtime.supportsXtreamSqliteDataSource)
return this.pwaXtream.exportInto(entry);
const [
liveCategories,
@@ -370,7 +373,7 @@ export class PlaylistBackupService {
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
@@ -378,7 +381,7 @@ export class PlaylistBackupService {
})),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamContentType,
contentType: item.type as XtreamBackupContentType,
viewedAt: item.viewed_at ?? new Date().toISOString(),
})),
playbackPositions: playbackPositions.map((item) => ({
@@ -933,21 +936,20 @@ export class PlaylistBackupService {
);
}
if (!this.hasElectronApi()) {
return;
}
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) {
return;
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
const restoreResult = await this.pendingRestoreService.applyAndConsume(
playlistId,
restoreSnapshot,
(pendingState) =>
this.applyXtreamRestoreState(playlistId, pendingState)
async (state) =>
sqlite
? this.applyXtreamRestoreState(playlistId, state)
: this.pwaXtream.restore(playlistId, state, entry)
);
if (restoreResult !== 'consumed') {
throw new PlaylistBackupError(
@@ -1213,9 +1215,9 @@ export class PlaylistBackupService {
categories: Array<{
hidden: boolean;
xtream_id: number;
type: XtreamCategoryType;
type: XtreamBackupCategoryType;
}>,
categoryType: XtreamCategoryType
categoryType: XtreamBackupCategoryType
): XtreamPendingRestoreState['hiddenCategories'] {
return categories
.filter((category) => category.hidden)
@@ -1359,8 +1361,4 @@ export class PlaylistBackupService {
private getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
private hasElectronApi(): boolean {
return !!(window as Window & { electron?: unknown }).electron;
}
}
@@ -2097,6 +2097,35 @@ describe('PlaylistsService', () => {
}
);
it('reads hidden groups saved by a meta update that is still being written', async () => {
const original = createBasePlaylist('catalog-hidden-groups');
const { store, electron } = createStatefulElectronStore(original);
testWindow.electron = electron;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
electron.dbUpsertAppPlaylist.mockImplementationOnce(
async (playlist) => {
await gate;
store.current = playlist;
}
);
const service = createService();
// The meta effect subscribes while the save is dispatched, so the
// write is queued before the M3U route reopens the playlist.
const write = firstValueFrom(
service.updatePlaylistMeta({
_id: original._id,
hiddenGroupTitles: ['Sports'],
} as PlaylistMeta)
);
const read = firstValueFrom(service.getPlaylist(original._id));
release();
await write;
expect((await read).hiddenGroupTitles).toEqual(['Sports']);
});
it('orders a browser catalog read after a queued IndexedDB refresh', async () => {
const original = createBasePlaylist('browser-catalog');
const state = { current: original };
@@ -0,0 +1,297 @@
import { inject, Injectable } from '@angular/core';
import {
PlaybackPositionData,
XtreamBackupCollectionItem,
XtreamBackupContentType,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
XtreamPendingRestoreState,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { USER_DATA_STORAGE, XTREAM_USER_DATA_KEYS } from './user-data-storage';
type StoredMap = Record<string, unknown>;
type UserDataMap = keyof typeof XTREAM_USER_DATA_KEYS;
interface ResolvedKey {
key: string;
item: { contentType: XtreamBackupContentType; xtreamId: number };
}
const CONTENT_TYPES: readonly unknown[] = ['live', 'movie', 'series'];
/**
* Backup export and restore of the Xtream favorites, recently viewed items,
* playback positions and title snapshots the browser build keeps in
* USER_DATA_STORAGE. `PwaXtreamDataSource` owns these maps in a library this
* one cannot import, so their stored shapes are read here: keys are
* `"<type>:<xtreamId>"`, or a bare provider id written by older builds.
*/
@Injectable({ providedIn: 'root' })
export class PwaXtreamBackupStateService {
private readonly storage = inject(USER_DATA_STORAGE);
/**
* The entry with the playlist's stored lists and snapshots filled in;
* hidden categories and pins, which the browser build does not track,
* stay as `entry` has them. An unreadable store fails the export: restore
* treats these lists as authoritative, so "could not read" written as
* `[]` would turn the archive into an instruction to delete.
*/
exportInto(entry: XtreamPlaylistBackupEntry): XtreamPlaylistBackupEntry {
const playlistId = entry.exportedId;
const stored = this.readMap('collectionItems')[playlistId];
const snapshots = isRecord(stored) ? stored : {};
const favorites = new Map<string, XtreamBackupFavoriteItem>();
for (const key of this.listFor('favorites', playlistId)) {
const ref = resolveKey(key, snapshots);
if (ref) favorites.set(ref.key, ref.item);
}
// Newest view wins, artwork included, as in the data source.
const recent = new Map<string, XtreamBackupRecentlyViewedItem>();
for (const item of this.listFor('recentItems', playlistId)) {
if (!isRecord(item)) continue;
const ref = resolveKey(item['id'], snapshots);
const viewedAt = item['viewedAt'];
if (!ref || typeof viewedAt !== 'string') continue;
const previous = recent.get(ref.key)?.viewedAt;
if (!previous || Date.parse(viewedAt) > Date.parse(previous)) {
recent.set(ref.key, {
...ref.item,
viewedAt,
...backdropOf(item),
});
}
}
const byKey = snapshotsByKey(Object.values(snapshots));
const collectionItems = [
...new Set([...favorites.keys(), ...recent.keys()]),
]
.filter((key) => key in byKey)
.map((key) => byKey[key]);
return {
...entry,
userState: {
...entry.userState,
favorites: [...favorites.values()],
recentlyViewed: [...recent.values()],
playbackPositions: this.listFor('playbackPositions', playlistId)
.filter(isPlaybackPosition)
.map((position) => ({ ...position })),
...(collectionItems.length > 0 ? { collectionItems } : {}),
},
};
}
/**
* Replaces the playlist's favorites, history and positions with the
* restore state, keyed and remapped to `playlistId`, then merges the
* archived title snapshots into the playlist's own.
*
* Lists first, so the optional cache never takes space they need. A
* failed write throws with earlier ones applied, and the caller keeps the
* pending snapshot for the Xtream store to replay on the next open. The
* replay carries no titles (they come from `entry`, not `state`), so they
* are still merged then, unless storage is full and they would crowd it.
*/
async restore(
playlistId: string,
state: XtreamPendingRestoreState,
entry: XtreamPlaylistBackupEntry
): Promise<void> {
// A user-supplied file: the field may be anything.
const archived: unknown = entry.userState.collectionItems;
const snapshots = snapshotsByKey(
Array.isArray(archived) ? archived : []
);
try {
this.replaceLists(playlistId, state);
} catch (error) {
if (!isQuotaExceeded(error)) {
try {
this.mergeSnapshots(playlistId, snapshots);
} catch {
// The list failure is the one to report.
}
}
throw error;
}
this.mergeSnapshots(playlistId, snapshots);
}
private replaceLists(
playlistId: string,
state: XtreamPendingRestoreState
): void {
const favorites = state.favorites
.map((item) => typedKey(item.contentType, item.xtreamId))
.filter((key): key is string => key !== null);
this.writeFor('favorites', playlistId, [...new Set(favorites)]);
const recent: StoredMap[] = [];
for (const item of state.recentlyViewed) {
const id = typedKey(item.contentType, item.xtreamId);
if (id !== null && typeof item.viewedAt === 'string') {
recent.push({
id,
viewedAt: item.viewedAt,
...backdropOf(item),
});
}
}
this.writeFor('recentItems', playlistId, recent);
const now = new Date().toISOString();
this.writeFor(
'playbackPositions',
playlistId,
state.playbackPositions.filter(isPlaybackPosition).map((item) => ({
...item,
playlistId,
updatedAt: item.updatedAt ?? now,
}))
);
}
/**
* A display cache the data source itself writes on a best-effort basis:
* a damaged one is replaced, as the data source would, and a full
* storage skips it. Its titles return once the catalog loads.
*/
private mergeSnapshots(
playlistId: string,
incoming: Record<string, XtreamBackupCollectionItem>
): void {
if (Object.keys(incoming).length === 0) return;
const all = this.readMap('collectionItems', { replaceDamaged: true });
const current = isRecord(all[playlistId]) ? all[playlistId] : {};
try {
this.writeMap('collectionItems', {
...all,
[playlistId]: { ...current, ...incoming },
});
} catch (error) {
if (!isQuotaExceeded(error)) throw error;
}
}
private listFor(name: UserDataMap, playlistId: string): unknown[] {
const list = this.readMap(name)[playlistId];
if (list === undefined) return [];
if (!Array.isArray(list)) {
throw new Error(
`Stored ${XTREAM_USER_DATA_KEYS[name]} of playlist "${playlistId}" are unreadable.`
);
}
return list;
}
private writeFor(name: UserDataMap, playlistId: string, list: unknown[]) {
this.writeMap(name, { ...this.readMap(name), [playlistId]: list });
}
/**
* Strict, unlike the data source: a storage error or a damaged value
* throws rather than read as "nothing stored", so a write never replaces
* entries it could not read. A cache may `replaceDamaged` values.
*/
private readMap(
name: UserDataMap,
{ replaceDamaged = false } = {}
): StoredMap {
const key = XTREAM_USER_DATA_KEYS[name];
const raw = this.storage.getItem(key);
if (raw === null) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
if (isRecord(parsed)) return parsed;
if (replaceDamaged) return {};
throw new Error(`Stored ${key} are unreadable.`);
}
private writeMap(name: UserDataMap, map: StoredMap): void {
this.storage.setItem(XTREAM_USER_DATA_KEYS[name], JSON.stringify(map));
}
}
/**
* A typed key carries its own identity. A bare provider id resolves only
* through the snapshot stored under it, as the data source does without a
* complete catalog: ids repeat across live, movie and series, so a guess
* could restore a different title. An unresolvable id is left out.
*/
function resolveKey(key: unknown, snapshots: StoredMap): ResolvedKey | null {
if (typeof key === 'string' && key.includes(':')) {
const [type, id, extra] = key.split(':');
return extra === undefined ? resolved(type, Number(id)) : null;
}
if (typeof key !== 'number' && typeof key !== 'string') return null;
const snapshot = snapshots[String(key)];
return isRecord(snapshot) && snapshot['xtream_id'] === Number(key)
? resolved(snapshot['type'], Number(key))
: null;
}
function resolved(type: unknown, xtreamId: number): ResolvedKey | null {
const key = typedKey(type, xtreamId);
if (key === null) return null;
const contentType = type as XtreamBackupContentType;
return { key, item: { contentType, xtreamId } };
}
function typedKey(type: unknown, xtreamId: unknown): string | null {
return CONTENT_TYPES.includes(type) &&
typeof xtreamId === 'number' &&
Number.isSafeInteger(xtreamId) &&
xtreamId > 0
? `${type}:${xtreamId}`
: null;
}
/** Snapshots by typed key, whatever key they were stored under. */
function snapshotsByKey(
items: unknown[]
): Record<string, XtreamBackupCollectionItem> {
const byKey: Record<string, XtreamBackupCollectionItem> = {};
for (const item of items) {
if (!isRecord(item)) continue;
const key = typedKey(item['type'], item['xtream_id']);
if (key !== null) byKey[key] = item as XtreamBackupCollectionItem;
}
return byKey;
}
/** Artwork saved with a view, in either spelling the data source reads. */
function backdropOf(item: object): { backdropUrl?: string } {
const record = item as StoredMap;
const value = record['backdropUrl'] ?? record['backdrop_url'];
return typeof value === 'string' && value.trim()
? { backdropUrl: value.trim() }
: {};
}
function isPlaybackPosition(value: unknown): value is PlaybackPositionData {
return (
isRecord(value) &&
Number.isFinite(value['contentXtreamId']) &&
(value['contentType'] === 'vod' ||
value['contentType'] === 'episode') &&
Number.isFinite(value['positionSeconds'])
);
}
function isQuotaExceeded(error: unknown): boolean {
return error instanceof DOMException && error.name === 'QuotaExceededError';
}
function isRecord(value: unknown): value is StoredMap {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
@@ -29,6 +29,18 @@ export function createBrowserSessionStorage(): UserDataStorage {
};
}
/**
* The PWA Xtream user-data maps in USER_DATA_STORAGE, each a JSON object
* keyed by playlist id. Shared with the playlist backup, which reads and
* writes them directly.
*/
export const XTREAM_USER_DATA_KEYS = {
favorites: 'xtream-favorites',
recentItems: 'xtream-recent-items',
collectionItems: 'xtream-collection-items',
playbackPositions: 'xtream-playback-positions',
} as const;
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
* viewed items and playback positions. Defaults to `localStorage`; the
@@ -0,0 +1,80 @@
import { SQL } from 'drizzle-orm';
import { buildPlaylistRow } from './app-playlist-codec';
import { playlistConflictUpdate } from './app-playlist-conflict';
import type { NewPlaylist } from './schema';
function rowOf(document: Record<string, unknown>): NewPlaylist {
const row = buildPlaylistRow(document);
if (!row) {
throw new Error('Expected the document to produce a row');
}
return row;
}
/** Every optional column of a document that only has an id, title and URL. */
const CLEARED_COLUMNS = {
serverUrl: null,
username: null,
password: null,
lastUpdated: null,
userAgent: null,
origin: null,
referrer: null,
filePath: null,
epgUrls: null,
detectedEpgUrls: null,
manualEpgUrls: null,
disabledEpgUrls: null,
macAddress: null,
portalUrl: null,
count: null,
importDate: null,
updateDate: null,
position: null,
favorites: null,
recentlyViewed: null,
};
describe('playlistConflictUpdate', () => {
const document = {
_id: 'm3u-1',
title: 'Family',
url: 'https://example.test/list.m3u',
userAgent: '',
referrer: ' ',
};
it('sets every optional column the document leaves empty to NULL', () => {
const row = rowOf(document);
expect(playlistConflictUpdate(row, document)).toMatchObject({
...CLEARED_COLUMNS,
url: 'https://example.test/list.m3u',
});
});
it('never clears the creation date or the columns every row carries', () => {
const row = rowOf(document);
const update = playlistConflictUpdate(row, document);
expect(update).not.toHaveProperty('dateCreated');
expect(update).toMatchObject({
id: 'm3u-1',
name: 'Family',
type: 'm3u-url',
autoRefresh: false,
lastUsage: row.lastUsage,
});
// A clockless document keeps a stored panel clock through SQL.
expect(update.payload).toBeInstanceOf(SQL);
});
it('clears the same columns when the document carries its own clock', () => {
const withClock = { ...document, serverTimezone: 'UTC' };
const row = rowOf(withClock);
const update = playlistConflictUpdate(row, withClock);
expect(update).toMatchObject(CLEARED_COLUMNS);
expect(update.payload).toBe(row.payload);
});
});
@@ -14,22 +14,70 @@ export type PlaylistConflictUpdate = {
const stored = schema.playlists;
/**
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
* from a snapshot the caller read earlier; when that snapshot carries no
* panel clock while the stored row does — `setPlaylistServerTimezone`
* landed in between — the stored clock is carried over as long as the row
* still points at the same panel, so a favorites, recent-items or metadata
* write cannot hand a clockless payload back to the past. A snapshot that
* carries its own clock, or moves the source, wins as is. Nested CASE, not
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
* malformed payload unless json_valid ran first.
* Columns a whole-document upsert must rewrite even when the document no
* longer has a value: the codec maps empty values to `undefined`, which an
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
* column value and `parseAppPlaylist` would bring it back. `dateCreated`
* belongs to the first insert, and `name`, `type`, `autoRefresh`,
* `payload` and `lastUsage` always carry a value.
*/
const REPLACEABLE_COLUMNS = [
'serverUrl',
'username',
'password',
'lastUpdated',
'userAgent',
'origin',
'referrer',
'filePath',
'epgUrls',
'detectedEpgUrls',
'manualEpgUrls',
'disabledEpgUrls',
'macAddress',
'url',
'portalUrl',
'count',
'importDate',
'updateDate',
'position',
'favorites',
'recentlyViewed',
] as const satisfies readonly (keyof schema.NewPlaylist)[];
function withClearedColumns(row: schema.NewPlaylist): schema.NewPlaylist {
const cleared: Partial<Record<(typeof REPLACEABLE_COLUMNS)[number], null>> =
{};
for (const column of REPLACEABLE_COLUMNS) {
if (row[column] === undefined) {
cleared[column] = null;
}
}
return { ...row, ...cleared };
}
/**
* The `DO UPDATE` half of an app-playlist upsert. The document replaces
* the stored one, so every optional column it leaves empty is set to NULL
* rather than skipped (see REPLACEABLE_COLUMNS).
*
* A full upsert is built from a snapshot the caller read earlier; when
* that snapshot carries no panel clock while the stored row does —
* `setPlaylistServerTimezone` landed in between — the stored clock is
* carried over as long as the row still points at the same panel, so a
* favorites, recent-items or metadata write cannot hand a clockless
* payload back to the past. A snapshot that carries its own clock, or
* moves the source, wins as is. Nested CASE, not AND: SQLite may reorder
* AND terms, and the json_* readers raise on a malformed payload unless
* json_valid ran first.
*/
export function playlistConflictUpdate(
row: schema.NewPlaylist,
playlist: Record<string, unknown>
): PlaylistConflictUpdate {
const replacement = withClearedColumns(row);
if (readString(playlist['serverTimezone'])) {
return row;
return replacement;
}
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
const payload = sql`CASE
@@ -48,7 +96,7 @@ export function playlistConflictUpdate(
END
ELSE ${excludedPayload}
END`;
return { ...row, payload };
return { ...replacement, payload };
}
/**
+1
View File
@@ -103,6 +103,7 @@ export * from './lib/stalker-serial-details.interface';
export * from './lib/stalker-vod-details.interface';
// Unified VOD details (discriminated union + adapters)
export * from './lib/provider-metadata.util';
export * from './lib/vod-details-adapters';
export * from './lib/vod-details-item.interface';
@@ -62,6 +62,12 @@ export interface XtreamBackupRecentlyViewedItem {
contentType: XtreamBackupContentType;
xtreamId: number;
viewedAt: string;
/**
* Artwork the browser build saved with this view, shown ahead of the
* title snapshot's. Optional; the desktop build neither writes nor
* reads it.
*/
backdropUrl?: string;
}
/**
@@ -76,6 +82,17 @@ export interface XtreamBackupSourcePin {
updatedAt?: string;
}
/**
* A favorite or recently viewed title as the browser build last displayed it,
* so the restored lists show titles and artwork before the provider catalog
* is fetched again. Every other field is the item exactly as it was stored.
*/
export interface XtreamBackupCollectionItem {
type: XtreamBackupContentType;
xtream_id: number;
[field: string]: unknown;
}
export interface XtreamBackupUserState {
hiddenCategories: XtreamBackupHiddenCategory[];
favorites: XtreamBackupFavoriteItem[];
@@ -83,6 +100,12 @@ export interface XtreamBackupUserState {
playbackPositions: PlaybackPositionData[];
/** Optional: absent in archives written before multi-source existed. */
sourcePins?: XtreamBackupSourcePin[];
/**
* Optional and written only by the browser build, which keeps no catalog
* between sessions. A cache, not user state: restore merges it into the
* playlist's snapshots, and the desktop build ignores it.
*/
collectionItems?: XtreamBackupCollectionItem[];
/**
* Parental-lock categories, same shape as `hiddenCategories`. Optional
* like `sourcePins`: absence means the archive predates the lock and
@@ -0,0 +1,49 @@
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
describe('readProviderText', () => {
it.each([undefined, null, '', ' ', 'N/A', 'n/a', ' N/a ', '-', ' - '])(
'reads %p as absent',
(value) => {
expect(readProviderText(value)).toBeUndefined();
}
);
it('trims real text and keeps a value that only contains a stand-in', () => {
expect(readProviderText(' Drama ')).toBe('Drama');
expect(readProviderText('Sci-Fi')).toBe('Sci-Fi');
expect(readProviderText('N/A Studios')).toBe('N/A Studios');
});
it('reads finite numbers as text and nothing else', () => {
expect(readProviderText(2011)).toBe('2011');
expect(readProviderText(0)).toBe('0');
expect(readProviderText(Number.NaN)).toBeUndefined();
expect(readProviderText({})).toBeUndefined();
});
});
describe('firstProviderText', () => {
it('skips placeholders and blanks to reach the usable fallback', () => {
expect(firstProviderText('N/A', '', ' ', '2011')).toBe('2011');
expect(firstProviderText('-', undefined)).toBeUndefined();
});
});
describe('readProviderRating', () => {
it.each(['0', '0.0', '0,0', 0, 'N/A', '-', ''])(
'reads %p as unrated',
(value) => {
expect(readProviderRating(value)).toBeUndefined();
}
);
it('keeps real scores as the provider wrote them', () => {
expect(readProviderRating('7.4')).toBe('7.4');
expect(readProviderRating('6,8')).toBe('6,8');
expect(readProviderRating(8)).toBe('8');
});
});
@@ -0,0 +1,59 @@
/**
* Readers for provider detail metadata (`get_vod_info`, Stalker VOD rows).
*
* Portals fill a field they have nothing for with a stand-in instead of
* leaving it empty — `"N/A"` (in any case) or a lone `"-"` — and a detail
* template that renders any truthy value then shows the stand-in as if it
* were data: a `⭐ N/A` chip, an "N/A" director, an "N/A" plot. The detail
* shell's contract is that missing data simply is not rendered, so these
* readers turn every stand-in into "absent" where provider rows are MAPPED,
* and templates keep plain truthiness guards instead of each learning the
* list.
*
* Keep the list to stand-ins providers were actually seen sending: every
* entry also erases a genuine value that happens to read the same.
*/
const PROVIDER_PLACEHOLDERS: ReadonlySet<string> = new Set(['n/a', '-']);
/**
* Trimmed provider text, or `undefined` when it is blank or a placeholder.
* Finite numbers are read as their text form; anything else is absent.
*/
export function readProviderText(value: unknown): string | undefined {
const text =
typeof value === 'string'
? value.trim()
: typeof value === 'number' && Number.isFinite(value)
? String(value)
: '';
return text && !PROVIDER_PLACEHOLDERS.has(text.toLowerCase())
? text
: undefined;
}
/**
* The first candidate that carries real text. `a ?? b` would stop at a
* placeholder (or a blank) and never reach the usable fallback behind it.
*/
export function firstProviderText(...values: unknown[]): string | undefined {
for (const value of values) {
const text = readProviderText(value);
if (text !== undefined) {
return text;
}
}
return undefined;
}
/**
* {@link readProviderText} for an IMDb / Kinopoisk score, where zero is a
* placeholder too: both scales start at 1, so a `"0"` / `"0.0"` is the
* panel's way of saying "unrated" (TMDB's `vote_average` of 0 is read the
* same way by the enrichment merge).
*/
export function readProviderRating(value: unknown): string | undefined {
const text = readProviderText(value);
return text !== undefined && Number(text.replace(',', '.')) === 0
? undefined
: text;
}
@@ -71,6 +71,116 @@ describe('normalized release year', () => {
});
});
describe('provider placeholders in the details view model', () => {
const PLACEHOLDER_FREE_FIELDS = [
'description',
'posterUrl',
'backdropUrl',
'year',
'genre',
'duration',
'country',
'director',
'actors',
'ratingImdb',
'ratingKinopoisk',
] as const;
it('renders none of the stand-ins a Stalker portal sends for empty fields', () => {
const meta = normalizeStalkerVod({
id: '1',
cmd: '',
info: {
name: 'Placeholder Film',
o_name: 'N/A',
movie_image: 'N/A',
description: 'N/A',
actors: 'n/a',
director: '-',
releasedate: 'N/A',
genre: ' N/A ',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
},
});
// A placeholder original title must not shadow the real one
expect(meta.title).toBe('Placeholder Film');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('renders none of the stand-ins in an Xtream detail response', () => {
const meta = normalizeXtreamVod({
info: {
kinopoisk_url: '',
tmdb_id: 0,
name: 'N/A',
o_name: '',
cover_big: '-',
movie_image: 'N/A',
releasedate: 'N/A',
episode_run_time: 0,
youtube_trailer: '',
director: 'N/A',
actors: 'N/A',
cast: '-',
description: 'N/A',
plot: 'n/a',
age: '',
mpaa_rating: '',
rating_count_kinopoisk: 0,
country: 'N/A',
genre: '-',
backdrop_path: ['N/A'],
duration_secs: 0,
duration: 'N/A',
video: [],
audio: [],
bitrate: 0,
rating: 0,
rating_imdb: '0.0',
rating_kinopoisk: 'N/A',
},
movie_data: {
stream_id: 5,
name: 'Catalog Title',
added: '',
category_id: '1',
container_extension: 'mp4',
custom_sid: null,
direct_source: '',
},
});
expect(meta.title).toBe('Catalog Title');
for (const field of PLACEHOLDER_FREE_FIELDS) {
expect(meta[field]).toBeUndefined();
}
});
it('falls through a placeholder to the next usable Xtream field', () => {
const meta = normalizeXtreamVod({
info: {
name: 'Fallback Film',
description: 'N/A',
plot: 'A plot from the second field',
actors: '-',
cast: 'First Actor, Second Actor',
duration: 'N/A',
duration_secs: 5400,
rating_imdb: '7.2',
} as never,
});
expect(meta.description).toBe('A plot from the second field');
expect(meta.actors).toBe('First Actor, Second Actor');
expect(meta.duration).toBe('1h 30m');
expect(meta.ratingImdb).toBe('7.2');
});
});
describe('youtubeEmbedUrl', () => {
it('builds an embed URL from a plain video id (TMDB format)', () => {
expect(youtubeEmbedUrl('zAGVQLHvwOY')).toBe(
@@ -1,5 +1,10 @@
import { XtreamVodDetails, getXtreamVodInfo } from './xtream-vod-details.interface';
import { StalkerVodDetails } from './stalker-vod-details.interface';
import {
firstProviderText,
readProviderRating,
readProviderText,
} from './provider-metadata.util';
import { parseFacetYear } from './tmdb.interface';
import {
NormalizedVodMeta,
@@ -11,24 +16,28 @@ import {
/**
* Normalizes Xtream VOD details into a display-friendly format.
* Handles the various field name variations in Xtream API responses.
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
const info = getXtreamVodInfo(item);
const movieData = item?.movie_data;
return {
title: info?.name || movieData?.name || 'Unknown',
description: info?.description || info?.plot,
posterUrl: info?.movie_image || info?.cover_big,
backdropUrl: info?.backdrop_path?.[0],
year: extractYear(info?.releasedate),
genre: info?.genre,
duration: info?.duration || formatDuration(info?.duration_secs),
country: info?.country,
director: info?.director,
actors: info?.actors || info?.cast,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
title: firstProviderText(info?.name, movieData?.name) ?? 'Unknown',
description: firstProviderText(info?.description, info?.plot),
posterUrl: firstProviderText(info?.movie_image, info?.cover_big),
backdropUrl: readProviderText(info?.backdrop_path?.[0]),
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration:
readProviderText(info?.duration) ??
formatDuration(info?.duration_secs),
country: readProviderText(info?.country),
director: readProviderText(info?.director),
actors: firstProviderText(info?.actors, info?.cast),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
youtubeTrailer: info?.youtube_trailer,
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
@@ -42,24 +51,26 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
* Provider stand-ins ("N/A", "-", an unrated "0") read as absent — see
* `provider-metadata.util.ts`.
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
title: firstProviderText(info?.o_name, info?.name) ?? 'Unknown',
description: readProviderText(info?.description),
posterUrl: readProviderText(info?.movie_image),
// Stalker portals never provide a backdrop; TMDB enrichment can
backdropUrl: info?.tmdb_backdrop,
year: extractYear(info?.releasedate),
genre: info?.genre,
year: extractYear(readProviderText(info?.releasedate)),
genre: readProviderText(info?.genre),
duration: undefined, // Stalker doesn't provide duration
country: undefined, // Stalker doesn't provide country
director: info?.director,
actors: info?.actors,
ratingImdb: info?.rating_imdb,
ratingKinopoisk: info?.rating_kinopoisk,
director: readProviderText(info?.director),
actors: readProviderText(info?.actors),
ratingImdb: readProviderRating(info?.rating_imdb),
ratingKinopoisk: readProviderRating(info?.rating_kinopoisk),
// Stalker portals provide no trailers; TMDB enrichment can
youtubeTrailer: info?.tmdb_trailer,
tmdbCast: info?.tmdb_cast,
@@ -166,6 +166,57 @@ describe('EmbeddedMpvPlayerComponent series navigation', () => {
expect(player.isPlaying()).toBe(false);
});
it('does not end the episode while the main process reloads a dropped stream', () => {
// A connection lost mid-episode reaches mpv as EOF (#1902); the
// reconnect owns it, and autoplay would skip the rest of the episode.
const reconnect = {
attempt: 1,
maxAttempts: 6,
nextAttemptAt: '2026-06-06T12:00:52Z',
};
controller.session.update((session) =>
session
? {
...session,
status: 'ended',
positionSeconds: 50,
reconnect,
updatedAt: '2026-06-06T12:00:50Z',
}
: session
);
fixture.detectChanges();
controller.session.update((session) =>
session
? {
...session,
status: 'loading',
updatedAt: '2026-06-06T12:00:52Z',
}
: session
);
fixture.detectChanges();
expect(fixture.componentInstance.endedCount).toBe(0);
expect(player.statusLabel()).toContain('RECONNECTING');
// The reloaded episode later finishes for real.
controller.session.update((session) =>
session
? {
...session,
status: 'ended',
positionSeconds: 120,
reconnect: undefined,
updatedAt: '2026-06-06T12:02:00Z',
}
: session
);
fixture.detectChanges();
expect(fixture.componentInstance.endedCount).toBe(1);
});
it('hides episode navigation for live playback', () => {
fixture.destroy();
fixture = TestBed.createComponent(EmbeddedMpvPlayerHostComponent);
@@ -578,7 +578,10 @@ export class EmbeddedMpvPlayerComponent implements OnDestroy {
effect(() => {
const session = this.session();
if (!session || session.status !== 'ended') {
// An `ended` the main process is reloading is a dropped
// connection, not the end: autoplay would skip the rest of the
// episode (#1902).
if (!session || session.status !== 'ended' || session.reconnect) {
return;
}
if (this.lastEndedSessionId === session.id) {
@@ -166,18 +166,20 @@ describe('VodDetailsComponent offline playback', () => {
};
const render = async ({
item = STALKER_VOD,
playbackPosition = null,
externalPlayback = null,
providerOnly = false,
isWatched = false,
}: {
item?: VodDetailsItem;
playbackPosition?: number | null;
externalPlayback?: ExternalPlayerSession | null;
providerOnly?: boolean;
isWatched?: boolean;
} = {}) => {
fixture = TestBed.createComponent(VodDetailsComponent);
fixture.componentRef.setInput('item', STALKER_VOD);
fixture.componentRef.setInput('item', item);
fixture.componentRef.setInput(
'playbackSessionKey',
'stalker-host-owned-key'
@@ -378,6 +380,46 @@ describe('VodDetailsComponent offline playback', () => {
fixture?.destroy();
});
it('renders no provider stand-ins in the hero or the About block', async () => {
await render({
item: createStalkerVodItem(
{
id: '43',
cmd: '/media/file_43.mpg',
info: {
name: 'Placeholder Movie',
o_name: 'N/A',
movie_image: 'N/A',
description: 'N/A',
actors: 'N/A',
director: 'N/A',
releasedate: 'N/A',
genre: '-',
rating_imdb: 'N/A',
rating_kinopoisk: '0',
},
},
'stalker-1'
),
});
// Watch state stamps the same templates into the About block
fixture.componentRef.setInput('inlinePlayback', {
streamUrl: 'https://portal.example/movie.mp4',
title: 'Placeholder Movie',
});
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Placeholder Movie');
expect(text).not.toMatch(/N\/A/i);
expect(text).not.toContain('⭐');
expect(
fixture.nativeElement.querySelectorAll(
'.details__tag:not(.details__tag--clickable), .details__meta-item'
).length
).toBe(0);
});
it('renders Offline and plays a completed download from the primary action', async () => {
completeDownload();
await render();
@@ -1,6 +1,22 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
/**
* The one debounce between a keystroke in the header search box and the
* applied term; pages that search on the applied term (global search
* included) run at once. 300 ms sits above the inter-key gap of steady
* typing, so a word typed in one go is applied once.
* See docs/architecture/performance-journeys.md (J4).
*/
export const SEARCH_INPUT_DEBOUNCE_MS = 300;
/**
* How long a purely local Xtream load (reading the saved catalog from SQLite)
* may run before the full-screen import overlay appears. A warm catalog
* finishes in tens of milliseconds; showing the overlay for that only flashes
* it between the skeleton and the loaded page. Remote imports and refresh
* preparation are not delayed.
*/
export const XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS = 300;
export const SEARCH_PLAYLIST_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
@@ -4,6 +4,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
@@ -51,4 +52,120 @@ describe('WorkspaceShellXtreamImportService', () => {
expect(service.xtreamImportTitleLabel()).toBe('Импорт');
});
describe('import overlay visibility', () => {
const activeImportSessionId = signal<string | null>(null);
const isImporting = signal(false);
const refreshPreparation = signal<{ playlistId: string } | null>(
null
);
let service: WorkspaceShellXtreamImportService;
beforeEach(() => {
jest.useFakeTimers();
activeImportSessionId.set(null);
isImporting.set(false);
refreshPreparation.set(null);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
WorkspaceShellXtreamImportService,
{
provide: XtreamStore,
useValue: {
activeImportSessionId,
isImporting,
contentInitBlockReason: signal(null),
},
},
{
provide: PlaylistRefreshActionService,
useValue: { refreshPreparation },
},
{ provide: RuntimeCapabilitiesService, useValue: {} },
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute: signal({ kind: 'xtreams' }),
currentContext: signal({
provider: 'xtreams',
playlistId: 'portal-1',
}),
currentSection: signal('vod'),
},
},
],
});
service = TestBed.inject(WorkspaceShellXtreamImportService);
});
afterEach(() => {
jest.useRealTimers();
});
/** Lets the delay effect react, then advances the fake clock. */
function advance(ms: number): void {
TestBed.tick();
jest.advanceTimersByTime(ms);
TestBed.tick();
}
it('never shows the overlay for a saved-catalog load that finishes quickly', () => {
// A warm open: the session starts and only reads SQLite
// ('loading-cached' leaves isImporting false).
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS - 1);
expect(service.showXtreamImportOverlay()).toBe(false);
activeImportSessionId.set(null);
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('shows the overlay once a saved-catalog load outlasts the delay', () => {
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(true);
activeImportSessionId.set(null);
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('restarts the delay for the next load', () => {
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
activeImportSessionId.set(null);
advance(0);
activeImportSessionId.set('session-2');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('shows the overlay at once for a remote import', () => {
activeImportSessionId.set('session-1');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
// A remote phase marks the session as a real import.
isImporting.set(true);
expect(service.showXtreamImportOverlay()).toBe(true);
});
it('keeps the delay while another playlist prepares a refresh', () => {
refreshPreparation.set({ playlistId: 'other-playlist' });
activeImportSessionId.set('session-1');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(true);
});
it('shows the overlay at once while a refresh prepares the playlist', () => {
refreshPreparation.set({ playlistId: 'portal-1' });
expect(service.showXtreamImportOverlay()).toBe(true);
});
});
});
@@ -1,4 +1,4 @@
import { computed, inject, Injectable } from '@angular/core';
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -6,7 +6,10 @@ import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { XtreamImportPhaseTone } from './helpers/workspace-shell-constants';
import {
XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS,
type XtreamImportPhaseTone,
} from './helpers/workspace-shell-constants';
import {
buildXtreamImportDetailLabel,
buildXtreamImportPhaseLabel,
@@ -76,7 +79,8 @@ export class WorkspaceShellXtreamImportService {
Boolean(this.xtreamStore.activeImportSessionId()) &&
!this.xtreamStore.isCancellingImport()
);
readonly showXtreamImportOverlay = computed(() => {
/** Whether the current route is waiting on an import or refresh. */
private readonly isOverlayWanted = computed(() => {
const route = this.routeState.currentRoute();
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
@@ -103,6 +107,35 @@ export class WorkspaceShellXtreamImportService {
);
});
/**
* Remote imports (the store flags `isImporting` for every phase except
* `loading-cached`) and refresh preparation of the shown playlist (any
* on the dashboard) show the overlay at once. A session that has only
* read the saved catalog so far waits `XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS`,
* so a warm open never flashes it, even while another playlist prepares
* a refresh.
*/
private readonly isOverlayImmediate = computed(() => {
if (this.xtreamStore.isImporting()) {
return true;
}
if (this.routeState.currentRoute().kind === 'dashboard') {
return Boolean(this.refreshPreparation());
}
const playlistId = this.routeState.currentContext()?.playlistId;
return (
playlistId !== undefined &&
this.isRefreshPreparationRunningForPlaylist(playlistId)
);
});
private readonly localLoadDelayElapsed = signal(false);
readonly showXtreamImportOverlay = computed(
() =>
this.isOverlayWanted() &&
(this.isOverlayImmediate() || this.localLoadDelayElapsed())
);
readonly xtreamImportTitleLabel = computed(() => {
this.languageTick();
if (this.activeRefreshPreparation()) {
@@ -198,6 +231,22 @@ export class WorkspaceShellXtreamImportService {
);
});
constructor() {
// Restart the delay for every wait: it runs from the moment the
// route starts waiting and resets as soon as the wait ends.
effect((onCleanup) => {
if (!this.isOverlayWanted()) {
this.localLoadDelayElapsed.set(false);
return;
}
const timer = setTimeout(
() => this.localLoadDelayElapsed.set(true),
XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS
);
onCleanup(() => clearTimeout(timer));
});
}
readonly isImportRunning = computed(
() =>
!this.xtreamStore.contentInitBlockReason() &&
@@ -30,6 +30,7 @@ import {
} from '@iptvnator/workspace/shell/util';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellFacade } from './workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
@@ -503,16 +504,31 @@ describe('WorkspaceShellFacade', () => {
expect(facade.showXtreamImportOverlay()).toBe(true);
});
it('keeps a cache-only Xtream import session blocking and cancellable', () => {
const xtreamStore = TestBed.inject(
XtreamStore
) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
xtreamStore.activeImportSessionId.set('xtream-import-session');
it('keeps a slow cache-only Xtream import session blocking and cancellable', () => {
jest.useFakeTimers();
try {
const xtreamStore = TestBed.inject(
XtreamStore
) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(
WorkspaceShellXtreamImportService
);
xtreamStore.activeImportSessionId.set('xtream-import-session');
TestBed.tick();
expect(xtreamStore.isImporting()).toBe(false);
expect(facade.showXtreamImportOverlay()).toBe(true);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
// A saved-catalog read waits out the show delay so a quick one
// never flashes the overlay, but stays cancellable meanwhile.
expect(xtreamStore.isImporting()).toBe(false);
expect(facade.showXtreamImportOverlay()).toBe(false);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
jest.advanceTimersByTime(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(facade.showXtreamImportOverlay()).toBe(true);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('shows the Xtream overlay during refresh preparation on the dashboard', () => {
+22
View File
@@ -112,6 +112,28 @@
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37184230956",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04"
}
},
"search": {
"renderer.ipcCallsPerSearch": {
"value": 1,
"unit": "calls",
"slack": 0,
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
"updatedAt": "2026-10-12",
"evidencePr": 1899,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38165392046",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 18 measured iterations of 3 runs of #1899 (attempts 1-3), and in all 816 J4 iterations of the 136 CI journey jobs since #1816"
},
"renderer.sqlStatementsPerSearch": {
"value": 2,
"unit": "statements",
"slack": 0,
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
"updatedAt": "2026-10-12",
"evidencePr": 1899,
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38165392046",
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 18 measured iterations of 3 runs of #1899 (attempts 1-3), and in all 816 J4 iterations of the 136 CI journey jobs since #1816"
}
}
}
}