mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(stalker): migrate legacy collection playback
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user