From cb199b6e52ac521d1d21262e1a5bce91eabd22a4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:41:17 +0200 Subject: [PATCH] fix(stalker): migrate legacy collection playback --- .../stream-resolver.service.spec.ts | 261 +++++++++++++++++- .../lib/collection/stream-resolver.service.ts | 134 ++++++++- 2 files changed, 393 insertions(+), 2 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index 3ba7e8835..d45234214 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; import { of } from 'rxjs'; import { XtreamApiService, @@ -22,14 +23,20 @@ import { describe('StreamResolverService', () => { let service: StreamResolverService; - let playlistsService: { getPlaylistById: jest.Mock }; + let playlistsService: { + getPlaylistById: jest.Mock; + persistStalkerConnection: jest.Mock; + }; let xtreamApi: { getShortEpg: jest.Mock }; let xtreamUrl: { constructLiveUrl: jest.Mock }; let dataService: { sendIpcEvent: jest.Mock }; + let store: { dispatch: jest.Mock }; let stalkerSession: { supportsTypedSessions: jest.Mock; getLeaseRef: jest.Mock; open: jest.Mock; + commit: jest.Mock; + discard: jest.Mock; request: jest.Mock; requestForPlaylist: jest.Mock; makeAuthenticatedRequest: jest.Mock; @@ -39,6 +46,9 @@ describe('StreamResolverService', () => { beforeEach(() => { playlistsService = { getPlaylistById: jest.fn(), + persistStalkerConnection: jest.fn((playlist: Playlist) => + of(playlist) + ), }; xtreamApi = { getShortEpg: jest.fn(), @@ -49,10 +59,23 @@ describe('StreamResolverService', () => { dataService = { sendIpcEvent: jest.fn(), }; + store = { + dispatch: jest.fn(), + }; stalkerSession = { supportsTypedSessions: jest.fn().mockReturnValue(true), getLeaseRef: jest.fn(), open: jest.fn(), + commit: jest.fn().mockResolvedValue({ + action: 'commit', + kind: 'success', + requestId: 'request-commit-1', + }), + discard: jest.fn().mockResolvedValue({ + action: 'discard', + kind: 'success', + requestId: 'request-discard-1', + }), request: jest.fn(), requestForPlaylist: jest.fn(), makeAuthenticatedRequest: jest.fn(), @@ -71,6 +94,7 @@ describe('StreamResolverService', () => { { provide: XtreamApiService, useValue: xtreamApi }, { provide: XtreamUrlService, useValue: xtreamUrl }, { provide: DataService, useValue: dataService }, + { provide: Store, useValue: store }, { provide: EpgRuntimeBridgeService, useValue: epgBridge, @@ -607,6 +631,7 @@ describe('StreamResolverService', () => { portalUrl: 'https://stalker.example.com/portal.php', macAddress: '00:11:22:33:44:55', isFullStalkerPortal: true, + stalkerRecipeClassifierVersion: 1, stalkerRequestRecipe: 'stateless-mac', } satisfies Partial) ); @@ -670,6 +695,7 @@ describe('StreamResolverService', () => { portalUrl: 'https://stalker.example.com/server/load.php', macAddress: '00:11:22:33:44:55', isFullStalkerPortal: false, + stalkerRecipeClassifierVersion: 1, stalkerRequestRecipe: 'full-session', userAgent: 'renderer-user-agent', referrer: 'https://stalker.example.com/c/', @@ -719,6 +745,239 @@ describe('StreamResolverService', () => { expect(playback).not.toHaveProperty('origin'); }); + it('classifies and promotes a legacy full playlist before collection playback', async () => { + const legacyPlaylist = { + _id: 'stalker-legacy', + title: 'Legacy Full Stalker', + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '', + autoRefresh: false, + portalUrl: 'https://stalker.example.com/tenant/c/', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + stalkerToken: 'legacy-renderer-token', + } satisfies Playlist; + playlistsService.getPlaylistById.mockReturnValue(of(legacyPlaylist)); + stalkerSession.open.mockResolvedValue({ + attemptRef: 'attempt-legacy-collection', + capabilities: { + authenticatedSession: true, + playbackContext: true, + }, + connectionMode: 'provisional', + endpoint: + 'https://stalker.example.com/tenant/server/load.php', + kind: 'ready', + landingUrl: 'https://stalker.example.com/tenant/c/', + leaseRef: 'lease-legacy-collection', + persistenceDraft: { + isFullStalkerPortal: true, + portalUrl: + 'https://stalker.example.com/tenant/server/load.php', + stalkerLandingUrl: + 'https://stalker.example.com/tenant/c/', + stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'full-session', + stalkerSourceUrl: + 'https://stalker.example.com/tenant/c/', + }, + provisionalReason: 'migration', + recipe: 'full-session', + requestId: 'request-legacy-collection', + }); + stalkerSession.requestForPlaylist.mockResolvedValue({ + streamUrl: 'https://cdn.example.com/live/legacy.m3u8', + playbackContextRef: 'opaque-legacy-playback-context', + }); + epgBridge.supportsProgramLookup = false; + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-legacy::77', + name: 'Legacy Stalker Live', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-legacy', + playlistName: 'Legacy Full Stalker', + stalkerId: '77', + stalkerCmd: 'ffmpeg http://stalker/live/77', + } satisfies UnifiedCollectionItem); + + expect(stalkerSession.open).toHaveBeenCalledWith(legacyPlaylist, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + expect(playlistsService.persistStalkerConnection).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'stalker-legacy', + isFullStalkerPortal: true, + portalUrl: + 'https://stalker.example.com/tenant/server/load.php', + stalkerRequestRecipe: 'full-session', + }) + ); + expect( + playlistsService.persistStalkerConnection.mock.calls[0]?.[0] + ).not.toHaveProperty('stalkerToken'); + expect(stalkerSession.commit).toHaveBeenCalledWith( + 'attempt-legacy-collection' + ); + expect(store.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + playlist: expect.objectContaining({ + stalkerRequestRecipe: 'full-session', + }), + type: expect.any(String), + }) + ); + expect(stalkerSession.requestForPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ + _id: 'stalker-legacy', + stalkerRequestRecipe: 'full-session', + }), + STALKER_SESSION_APPLICATION_OPERATIONS.CreateLink, + { + command: 'ffmpeg http://stalker/live/77', + contentType: 'itv', + } + ); + expect( + playlistsService.persistStalkerConnection.mock.invocationCallOrder[0] + ).toBeLessThan(stalkerSession.commit.mock.invocationCallOrder[0]); + expect( + stalkerSession.commit.mock.invocationCallOrder[0] + ).toBeLessThan( + stalkerSession.requestForPlaylist.mock.invocationCallOrder[0] + ); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + expect(playback).toMatchObject({ + playbackContextRef: 'opaque-legacy-playback-context', + streamUrl: 'https://cdn.example.com/live/legacy.m3u8', + }); + }); + + it('uses the classified stateless recipe for a legacy collection playlist', async () => { + const legacyPlaylist = { + _id: 'stalker-legacy-stateless', + title: 'Legacy Stalker', + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '', + autoRefresh: false, + portalUrl: 'https://stalker.example.com/c/', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + username: 'obsolete-user', + password: 'obsolete-password', + } satisfies Playlist; + playlistsService.getPlaylistById.mockReturnValue(of(legacyPlaylist)); + stalkerSession.open.mockResolvedValue({ + attemptRef: 'attempt-legacy-stateless', + connectionMode: 'provisional', + endpoint: 'https://stalker.example.com/portal.php', + kind: 'ready', + landingUrl: 'https://stalker.example.com/c/', + persistenceDraft: { + isFullStalkerPortal: false, + portalUrl: 'https://stalker.example.com/portal.php', + stalkerLandingUrl: 'https://stalker.example.com/c/', + stalkerLastVerifiedAt: '2026-07-27T12:00:00.000Z', + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'stateless-mac', + stalkerSourceUrl: 'https://stalker.example.com/c/', + }, + provisionalReason: 'migration', + recipe: 'stateless-mac', + requestId: 'request-legacy-stateless', + }); + dataService.sendIpcEvent.mockResolvedValue({ + js: { cmd: 'ffmpeg https://cdn.example.com/live/stateless.m3u8' }, + }); + epgBridge.supportsProgramLookup = false; + + const playback = await service.resolvePlayback({ + uid: 'stalker::stalker-legacy-stateless::77', + name: 'Legacy Stateless Live', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-legacy-stateless', + playlistName: 'Legacy Stalker', + stalkerId: '77', + stalkerCmd: 'ffmpeg http://stalker/live/77', + } satisfies UnifiedCollectionItem); + + expect(playlistsService.persistStalkerConnection).toHaveBeenCalledWith( + expect.objectContaining({ + password: '', + stalkerRequestRecipe: 'stateless-mac', + username: '', + }) + ); + expect(stalkerSession.commit).toHaveBeenCalledWith( + 'attempt-legacy-stateless' + ); + expect(stalkerSession.requestForPlaylist).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({ + action: 'create_link', + }), + url: 'https://stalker.example.com/portal.php', + }) + ); + expect(playback.streamUrl).toBe( + 'https://cdn.example.com/live/stateless.m3u8' + ); + }); + + it('discards a legacy collection attempt that requires route-owned credentials', async () => { + const legacyPlaylist = { + _id: 'stalker-legacy-challenge', + title: 'Legacy Full Stalker', + count: 0, + importDate: '2026-07-27T00:00:00.000Z', + lastUsage: '', + autoRefresh: false, + portalUrl: 'https://stalker.example.com/c/', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: true, + } satisfies Playlist; + playlistsService.getPlaylistById.mockReturnValue(of(legacyPlaylist)); + stalkerSession.open.mockResolvedValue({ + attemptNumber: 1, + attemptRef: 'attempt-legacy-challenge', + challengeRef: 'challenge-legacy-credentials', + kind: 'credentials-required', + requestId: 'request-legacy-challenge', + }); + + await expect( + service.resolvePlayback({ + uid: 'stalker::stalker-legacy-challenge::77', + name: 'Legacy Challenged Live', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-legacy-challenge', + playlistName: 'Legacy Full Stalker', + stalkerId: '77', + stalkerCmd: 'ffmpeg http://stalker/live/77', + } satisfies UnifiedCollectionItem) + ).rejects.toThrow( + 'stalker-session-outcome:credentials-required' + ); + + expect(stalkerSession.discard).toHaveBeenCalledWith( + 'attempt-legacy-challenge' + ); + expect( + playlistsService.persistStalkerConnection + ).not.toHaveBeenCalled(); + expect(stalkerSession.requestForPlaylist).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + }); + it('uses direct Stalker radio HTTP commands without create_link', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 531d81f7f..b8ad3d23c 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -1,7 +1,9 @@ import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; import { firstValueFrom } from 'rxjs'; import { DataService, PlaylistsService } from '@iptvnator/services'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; +import { PlaylistActions } from '@iptvnator/m3u-state'; import { buildStalkerEpgMappingKey, buildXtreamEpgMappingKey, @@ -19,8 +21,10 @@ import { import { executeStalkerRequest, fetchStalkerPlayback, + StalkerSessionOutcomeError, StalkerSessionService, } from '@iptvnator/portal/stalker/data-access'; +import { STALKER_RECIPE_CLASSIFIER_VERSION } from '@iptvnator/portal/stalker/protocol'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; type PlaylistWithChannels = Playlist & { @@ -58,6 +62,7 @@ export interface ResolvedLiveCollectionDetail { @Injectable({ providedIn: 'root' }) export class StreamResolverService { private readonly playlistsService = inject(PlaylistsService); + private readonly store = inject(Store); private readonly xtreamApi = inject(XtreamApiService); private readonly xtreamUrl = inject(XtreamUrlService); private readonly dataService = inject(DataService); @@ -67,6 +72,10 @@ export class StreamResolverService { private readonly portalEpgTimeoutMs = 10000; private readonly xtreamEpgCache = new Map(); private readonly xtreamEpgFailureTimestamps = new Map(); + private readonly stalkerRecipeMigrationByPlaylist = new Map< + string, + Promise + >(); private readonly xtreamEpgCacheTtlMs = 60 * 1000; private readonly xtreamEpgFailureCooldownMs = 30 * 1000; @@ -335,7 +344,7 @@ export class StreamResolverService { } const contentType = item.radio === 'true' ? 'radio' : 'itv'; - const requestPlaylist: Playlist = { + const unresolvedPlaylist: Playlist = { _id: item.playlistId, title: item.playlistName, count: 0, @@ -346,6 +355,10 @@ export class StreamResolverService { portalUrl, macAddress, }; + const requestPlaylist = + playlist === undefined + ? unresolvedPlaylist + : await this.ensureStalkerRequestRecipe(unresolvedPlaylist); const playbackLink = await fetchStalkerPlayback( { dataService: this.dataService, @@ -370,6 +383,125 @@ export class StreamResolverService { }; } + private ensureStalkerRequestRecipe(playlist: Playlist): Promise { + if (!this.shouldClassifyStalkerPlaylist(playlist)) { + return Promise.resolve(playlist); + } + + const active = this.stalkerRecipeMigrationByPlaylist.get(playlist._id); + if (active !== undefined) { + return active; + } + + const migration = this.classifyAndPersistStalkerPlaylist(playlist); + this.stalkerRecipeMigrationByPlaylist.set(playlist._id, migration); + const clear = () => { + if ( + this.stalkerRecipeMigrationByPlaylist.get(playlist._id) === + migration + ) { + this.stalkerRecipeMigrationByPlaylist.delete(playlist._id); + } + }; + migration.then(clear, clear); + return migration; + } + + private shouldClassifyStalkerPlaylist(playlist: Playlist): boolean { + if (!this.stalkerSession.supportsTypedSessions()) { + return false; + } + + const hasCurrentRecipe = + (playlist.stalkerRequestRecipe === 'full-session' || + playlist.stalkerRequestRecipe === 'stateless-mac') && + playlist.stalkerRecipeClassifierVersion === + STALKER_RECIPE_CLASSIFIER_VERSION; + if (hasCurrentRecipe) { + return false; + } + + return !( + playlist.stalkerRequestRecipe === undefined && + playlist.isFullStalkerPortal === false + ); + } + + private async classifyAndPersistStalkerPlaylist( + playlist: Playlist + ): Promise { + const outcome = await this.stalkerSession.open(playlist, { + connectionMode: 'provisional', + provisionalReason: 'migration', + }); + if (outcome.kind !== 'ready') { + await this.discardStalkerAttempt(outcome); + throw new StalkerSessionOutcomeError(outcome); + } + + const draft: Playlist = { + ...playlist, + ...outcome.persistenceDraft, + portalUrl: outcome.persistenceDraft.portalUrl, + }; + delete draft.stalkerToken; + if ( + outcome.recipe === 'stateless-mac' && + (playlist.username !== undefined || playlist.password !== undefined) + ) { + draft.username = ''; + draft.password = ''; + } + + let persisted: Playlist; + try { + persisted = await firstValueFrom( + this.playlistsService.persistStalkerConnection(draft) + ); + } catch (error) { + await this.stalkerSession + .discard(outcome.attemptRef) + .catch(() => undefined); + throw error; + } + this.store.dispatch( + PlaylistActions.stalkerConnectionPersisted({ + playlist: persisted, + }) + ); + + let promotion; + try { + promotion = await this.stalkerSession.commit(outcome.attemptRef); + } catch { + await this.stalkerSession + .discard(outcome.attemptRef) + .catch(() => undefined); + throw new Error('stalker-session-promotion-failed'); + } + if (promotion.kind !== 'success') { + await this.stalkerSession + .discard(outcome.attemptRef) + .catch(() => undefined); + throw new Error('stalker-session-promotion-failed'); + } + + return persisted; + } + + private async discardStalkerAttempt( + outcome: Exclude< + Awaited>, + { kind: 'ready' } + > + ): Promise { + if ('attemptRef' in outcome) { + await this.stalkerSession + .discard(outcome.attemptRef) + .catch(() => undefined); + } + } + private buildStalkerRadioChannel( item: UnifiedCollectionItem, playback: ResolvedPortalPlayback