fix(stalker): migrate legacy collection playback

This commit is contained in:
4gray committed 2026-07-27 12:41:17 +02:00
1 parent 9aa93079bd
commit cb199b6e52
2 files changed
+393 -2

No files matched your search

@@ -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<Playlist>)
);
@@ -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({
@@ -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<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly stalkerRecipeMigrationByPlaylist = new Map<
string,
Promise<Playlist>
>();
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<Playlist> {
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<Playlist> {
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<ReturnType<StalkerSessionService['open']>>,
{ kind: 'ready' }
>
): Promise<void> {
if ('attemptRef' in outcome) {
await this.stalkerSession
.discard(outcome.attemptRef)
.catch(() => undefined);
}
}
private buildStalkerRadioChannel(
item: UnifiedCollectionItem,
playback: ResolvedPortalPlayback