diff --git a/.changes/stalker-seek-byte-ranges.md b/.changes/stalker-seek-byte-ranges.md new file mode 100644 index 000000000..f95b140ee --- /dev/null +++ b/.changes/stalker-seek-byte-ranges.md @@ -0,0 +1,6 @@ +--- +type: fix +area: stalker +--- + +Seeking in Stalker movies and series now reaches the selected position instead of jumping forward or skipping to the next episode after resuming playback. diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts index 588f76daa..6ef264576 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.spec.ts @@ -7,6 +7,31 @@ import { describe('stalker playback context', () => { const macAddress = '00:1A:79:AA:BB:CC'; + it.each([ + ['http://range-portal.test/c/', 'http://range-cdn.test/episode.mkv'], + [ + 'https://range-downgrade.test/c/', + 'http://range-downgrade.test/episode.mkv', + ], + ['http://range-owned.test/c/', 'http://range-owned.test/episode.mkv'], + ])( + 'leaves seek byte ranges to the player for %s → %s', + (portalUrl, streamUrl) => { + rememberStalkerPlaybackContext({ + streamUrl, + portalUrl, + macAddress, + }); + + const headers = getStalkerPlaybackContextHeaders(streamUrl); + + expect(headers).not.toBeNull(); + expect( + Object.keys(headers ?? {}).map((name) => name.toLowerCase()) + ).not.toContain('range'); + } + ); + function rememberSameOriginContext( name: string, serialNumber?: string @@ -135,7 +160,8 @@ describe('stalker playback context', () => { // token; the lookup keys on origin+path so the second link still // finds its portal headers instead of playing bare. const firstLink = 'http://tmp-link2.example.test/ch/7?token=first'; - const secondLink = 'http://tmp-link2.example.test/ch/7?token=second'; + const secondLink = + 'http://tmp-link2.example.test/ch/7?token=second'; rememberStalkerPlaybackContext({ streamUrl: firstLink, portalUrl: diff --git a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts index 74b38359e..1c6a454a6 100644 --- a/apps/electron-backend/src/app/services/stalker-playback-context.service.ts +++ b/apps/electron-backend/src/app/services/stalker-playback-context.service.ts @@ -6,7 +6,6 @@ import { } from '@iptvnator/shared/interfaces'; const STALKER_STREAM_USER_AGENT = 'KSPlayer'; -const STALKER_STREAM_RANGE_HEADER = 'bytes=0-'; const CONTEXT_TTL_MS = 15 * 60 * 1000; @@ -111,10 +110,10 @@ export function rememberStalkerPlaybackContext(input: { const headers: Record = crossOriginStream ? { - // Align with known working clients for direct tokenized stream URLs. + // Match the renderer's direct profile; the player owns Range + // so each seek can request its own byte offset. 'User-Agent': STALKER_STREAM_USER_AGENT, Accept: '*/*', - Range: STALKER_STREAM_RANGE_HEADER, 'Icy-MetaData': '1', Connection: 'keep-alive', } diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 9e077c038..cac48758d 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1184,10 +1184,18 @@ Two stream profiles exist, selected by one shared predicate: `Origin`/`Referer` set to the portal origin. - **Foreign / direct** (different host, or an https→http downgrade): the credential-free `KSPlayer` direct-stream profile (`User-Agent: KSPlayer`, - `Accept`, `Range`, `Icy-MetaData`, `Connection`). Portal credentials must + `Accept`, `Icy-MetaData`, `Connection`). Portal credentials must never reach a third-party host; direct stream URLs carry their access token in the URL minted by `create_link`. +Neither playback profile sets `Range`; byte ranges belong to the media +transport and must change with each seek. A static `Range: bytes=0-` overrides +mpv's requested offset, making the server return the beginning again. After +resuming a movie or episode this can send playback forward or to EOF instead +of the selected time. Regression coverage in +`stalker-live-playback.utils.spec.ts` checks both profiles and TLS downgrades; +`with-stalker-player.feature.spec.ts` verifies the resumed CDN episode path. + **The token is bound to the endpoint the headers claim.** Both header inputs — the portal coordinates and the Bearer token — must describe the same portal, or a session negotiated for one host is presented to another. In @@ -1220,6 +1228,10 @@ same shared predicate — if the two ever diverged, renderer's credentialed headers for streams the main process misread as direct. +The fallback also leaves `Range` unset, covered by +`stalker-playback-context.service.spec.ts`, so external launches cannot +reintroduce a fixed byte offset when renderer headers are absent. + The mock server's `gated-stream` scenario (MAC `00:1A:79:00:00:09`) makes `create_link` return a local `/stream/gated/video.mp4` that answers 403 without the mac cookie and current Bearer token; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts index 4e6fdfb31..197b1f837 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.spec.ts @@ -7,9 +7,7 @@ import { } from './stalker-live-playback.utils'; import { STALKER_SERIAL_NUMBER } from './stalker-session.service'; -function createPlaylist( - overrides: Partial = {} -): PlaylistMeta { +function createPlaylist(overrides: Partial = {}): PlaylistMeta { return { _id: 'playlist-1', title: 'Playlist', @@ -36,6 +34,27 @@ function createPlaylist( } describe('buildStalkerExternalPlaybackHeaders', () => { + it.each([ + ['http://portal.test/c/', 'http://cdn.other.test/episode.mkv'], + ['https://portal.test/c/', 'http://portal.test/episode.mkv'], + ['http://portal.test/c/', 'http://portal.test/episode.mkv'], + ])( + 'leaves HTTP byte ranges to the player for %s → %s', + (portalUrl, streamUrl) => { + const headers = buildStalkerExternalPlaybackHeaders( + createPlaylist({ portalUrl }), + 'TOKEN', + streamUrl + ); + + // A static bytes=0- overrides mpv's per-seek byte offset. The server + // then returns the beginning again instead of the requested segment. + expect( + Object.keys(headers).map((name) => name.toLowerCase()) + ).not.toContain('range'); + } + ); + it('treats the legacy default serial as absent', () => { const headers = buildStalkerExternalPlaybackHeaders( createPlaylist({ stalkerSerialNumber: STALKER_SERIAL_NUMBER }), diff --git a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts index c085dbef8..03c8b2f0b 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-live-playback.utils.ts @@ -59,11 +59,11 @@ export function buildStalkerExternalPlaybackHeaders( // KSPlayer direct-stream profile: their access token travels in the URL // that create_link minted, and the portal's mac cookie/Bearer token must // never reach a third-party host. + // Leave Range to the player: a fixed bytes=0- overrides its seek offset. if (isCrossOriginStalkerStream(playlist, streamUrl)) { return { 'User-Agent': STALKER_STREAM_USER_AGENT, Accept: '*/*', - Range: 'bytes=0-', Connection: 'keep-alive', 'Icy-MetaData': '1', }; diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts index 6012af0a7..ce6850aba 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-player.feature.spec.ts @@ -6,7 +6,10 @@ import { TranslateService } from '@ngx-translate/core'; import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { of } from 'rxjs'; -import { PlaylistMeta, StalkerPortalActions } from '@iptvnator/shared/interfaces'; +import { + PlaylistMeta, + StalkerPortalActions, +} from '@iptvnator/shared/interfaces'; import { StalkerSessionService } from '../../stalker-session.service'; import { withStalkerPlayer } from './with-stalker-player.feature'; @@ -304,9 +307,7 @@ describe('withStalkerPlayer', () => { ); expect(session.getCachedToken).toHaveBeenCalledWith(PLAYLIST._id); - expect(playback.headers?.['Cookie']).toContain( - 'mac=00:1A:79:00:00:01' - ); + expect(playback.headers?.['Cookie']).toContain('mac=00:1A:79:00:00:01'); expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99'); expect(playback.headers?.['X-User-Agent']).toBeDefined(); expect(playback.userAgent).toBe(playback.headers?.['User-Agent']); @@ -338,6 +339,29 @@ describe('withStalkerPlayer', () => { expect(playback.origin).toBeUndefined(); }); + it('resumes a CDN episode without fixing subsequent media requests at byte zero', async () => { + dataService.sendIpcEvent.mockResolvedValueOnce({ + js: { cmd: 'ffmpeg http://cdn.example/episode_3.mkv' }, + }); + + const playback = await store.resolveVodPlayback( + 'ffmpeg http://cdn.example/episode_3.mkv', + 'Episode 3', + undefined, + 3, + 123, + 780 + ); + + expect(playback.startTime).toBe(780); + expect(playback.contentInfo).toMatchObject({ + contentType: 'episode', + contentXtreamId: 123, + }); + expect(playback.headers?.['User-Agent']).toBe('KSPlayer'); + expect(playback.headers).not.toHaveProperty('Range'); + }); + describe('temporary-link semantics', () => { const CHANNEL = { id: '10001', @@ -536,9 +560,7 @@ describe('withStalkerPlayer', () => { const playback = await store.resolveRadioPlayback(radioItem); - expect(playback.headers?.['Cookie']).toContain( - 'mac=00:1A:79:00:00:01' - ); + expect(playback.headers?.['Cookie']).toContain('mac=00:1A:79:00:00:01'); expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN99'); expect(playback.referer).toBe('http://demo.example'); expect(playback.origin).toBe('http://demo.example');