diff --git a/.changes/backup-pwa-xtream-user-state.md b/.changes/backup-pwa-xtream-user-state.md new file mode 100644 index 000000000..a2e8acec6 --- /dev/null +++ b/.changes/backup-pwa-xtream-user-state.md @@ -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. diff --git a/.changes/m3u-hidden-groups-restart.md b/.changes/m3u-hidden-groups-restart.md new file mode 100644 index 000000000..4c08c74fd --- /dev/null +++ b/.changes/m3u-hidden-groups-restart.md @@ -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. diff --git a/.changes/playback-embedded-mpv-mid-episode-drop.md b/.changes/playback-embedded-mpv-mid-episode-drop.md new file mode 100644 index 000000000..3175454e5 --- /dev/null +++ b/.changes/playback-embedded-mpv-mid-episode-drop.md @@ -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. diff --git a/.changes/playlist-clear-optional-fields.md b/.changes/playlist-clear-optional-fields.md new file mode 100644 index 000000000..237e86795 --- /dev/null +++ b/.changes/playlist-clear-optional-fields.md @@ -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. diff --git a/.changes/portals-hide-na-metadata.md b/.changes/portals-hide-na-metadata.md new file mode 100644 index 000000000..baace44be --- /dev/null +++ b/.changes/portals-hide-na-metadata.md @@ -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. diff --git a/.changes/search-single-debounce.md b/.changes/search-single-debounce.md new file mode 100644 index 000000000..1b00e5520 --- /dev/null +++ b/.changes/search-single-debounce.md @@ -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. diff --git a/.changes/workspace-cached-open-no-overlay-flash.md b/.changes/workspace-cached-open-no-overlay-flash.md new file mode 100644 index 000000000..9a5b222ea --- /dev/null +++ b/.changes/workspace-cached-open-no-overlay-flash.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5a2e1418..b5a581c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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() diff --git a/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts new file mode 100644 index 000000000..e04fbdc8a --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-series-stream-drop.e2e.ts @@ -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 { + 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; + }; + 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 { + return page.evaluate(() => { + const episodeBySession = new Map(); + 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); + } +}); diff --git a/apps/electron-backend-e2e/src/journeys/search-journey-app.ts b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts index 7d76b54fe..b57418091 100644 --- a/apps/electron-backend-e2e/src/journeys/search-journey-app.ts +++ b/apps/electron-backend-e2e/src/journeys/search-journey-app.ts @@ -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; diff --git a/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts new file mode 100644 index 000000000..1dfcf7f82 --- /dev/null +++ b/apps/electron-backend-e2e/src/m3u-group-management.e2e.ts @@ -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; + release: () => void; +} + +type ReconcileGlobal = typeof globalThis & { + __e2eReconcileScript?: ReconcileScript; +}; + +// Serialized into the test bootstrap; all runtime dependencies are arguments. +function installReconcileScript( + { ipcMain }: Pick, + 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 { + 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((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 { + 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 { + 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 { + 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 { + 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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/performance/search-journey-record.ts b/apps/electron-backend-e2e/src/performance/search-journey-record.ts index 556f378e8..0e941379c 100644 --- a/apps/electron-backend-e2e/src/performance/search-journey-record.ts +++ b/apps/electron-backend-e2e/src/performance/search-journey-record.ts @@ -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. */ diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts index 3370d633e..c827d4f8f 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.spec.ts @@ -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>; + metas: Record>; + rows: Record; + }; + + 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([ { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts index d682ff2ce..e621b58ce 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.reconnect.spec.ts @@ -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(() => ({ 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(); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index bd95e81eb..1b539e4a6 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -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; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts index 249d65a11..dbb5283aa 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.spec.ts @@ -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); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts index 2dc888792..d4940d450 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-reconnect.ts @@ -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; @@ -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 diff --git a/apps/web-backend/src/app/storage/server-playlist-repository.ts b/apps/web-backend/src/app/storage/server-playlist-repository.ts index 0f7f18d9d..6d27ba3c8 100644 --- a/apps/web-backend/src/app/storage/server-playlist-repository.ts +++ b/apps/web-backend/src/app/storage/server-playlist-repository.ts @@ -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 { - const cleared: Partial> = {}; - for (const column of REPLACEABLE_COLUMNS) { - if (row[column] === undefined) { - cleared[column] = null; - } - } - return cleared as Partial; -} - /** 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) diff --git a/apps/xtream-mock-server/src/app/dropping-episode.ts b/apps/xtream-mock-server/src/app/dropping-episode.ts new file mode 100644 index 000000000..7ab708600 --- /dev/null +++ b/apps/xtream-mock-server/src/app/dropping-episode.ts @@ -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(); + +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)); +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 7878733c0..c9fa0f731 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -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 = { 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: diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9ce71d931..bdffbe350 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -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('Aurora News'); 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 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index 4e24f7341..41f73eb86 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -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); diff --git a/apps/xtream-mock-server/src/fixtures/README.md b/apps/xtream-mock-server/src/fixtures/README.md index 330cf7f64..bacea0682 100644 --- a/apps/xtream-mock-server/src/fixtures/README.md +++ b/apps/xtream-mock-server/src/fixtures/README.md @@ -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. diff --git a/apps/xtream-mock-server/src/fixtures/episode.mkv b/apps/xtream-mock-server/src/fixtures/episode.mkv new file mode 100644 index 000000000..ea412ad81 Binary files /dev/null and b/apps/xtream-mock-server/src/fixtures/episode.mkv differ diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 628a4ad89..64cbe46f9 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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 `