mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
commit
de87c1e4d3
78 files changed
+4367
-276
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
+60
-1
@@ -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));
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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', {
|
||||
|
||||
+130
-3
@@ -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. */
|
||||
|
||||
+58
@@ -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);
|
||||
|
||||
+193
-3
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
+17
-1
@@ -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';
|
||||
|
||||
+117
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+52
-3
@@ -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() &&
|
||||
|
||||
+25
-9
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user