feat(playback): recommend recovery actions (#1374)

* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
This commit is contained in:
4gray authored and GitHub committed 2026-08-08 01:04:39 +02:00
1 parent 5e4f2ca3dd
commit fd96b85c19
176 files changed
+15228 -1908

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
{
"name": "playback-util",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/playback/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:shared", "domain:playback", "type:util"],
"targets": {
"test": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"command": [
"node",
"./tools/testing/run-web-esm-lib-tests.mjs",
"libs/playback/util/src"
],
"env": {
"NODE_OPTIONS": "--experimental-vm-modules"
},
"forwardAllArgs": true
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
export * from './lib/diagnostics/playback-diagnostics.util';
export * from './lib/diagnostics/shaka-error-classifier';
export * from './lib/diagnostics/shaka-error-contract';
export * from './lib/diagnostics/shaka-error-lifecycle';
export type * from './lib/diagnostics/shaka-error.types';
export * from './lib/playback-recommendation.model';
export * from './lib/playback-recommendation-policy';
export * from './lib/playback-session-key';
export * from './lib/playback-target-capabilities';
@@ -154,7 +154,7 @@ describe('HLS playback evidence', () => {
url: `https://provider.example/context?token=${secret}`,
responseType: 'text',
headers: { Authorization: `Bearer ${secret}` },
} as ErrorData['context'],
} as unknown as ErrorData['context'],
networkDetails: {
responseURL: `https://provider.example/xhr?token=${secret}`,
responseText: secret,
@@ -29,8 +29,7 @@ describe('mpegts.js playback evidence', () => {
OTHER_ERROR: MpegTsPlaybackEngineType.Other,
});
expect(mpegts.ErrorDetails).toEqual({
NETWORK_EXCEPTION:
MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_EXCEPTION: MpegTsPlaybackEngineDetails.NetworkException,
NETWORK_STATUS_CODE_INVALID:
MpegTsPlaybackEngineDetails.HttpStatusCodeInvalid,
NETWORK_TIMEOUT: MpegTsPlaybackEngineDetails.ConnectingTimeout,
@@ -46,47 +45,25 @@ describe('mpegts.js playback evidence', () => {
});
it.each([
[
'NetworkError',
'HttpStatusCodeInvalid',
'loader',
'http',
],
['NetworkError', 'HttpStatusCodeInvalid', 'loader', 'http'],
['NetworkError', 'ConnectingTimeout', 'loader', 'timeout'],
['NetworkError', 'Exception', 'loader', 'network'],
[
'NetworkError',
'UnrecoverableEarlyEof',
'loader',
'truncated-stream',
],
['NetworkError', 'UnrecoverableEarlyEof', 'loader', 'truncated-stream'],
['MediaError', 'FormatError', 'demux', 'format'],
['MediaError', 'FormatUnsupported', 'demux', 'format'],
['MediaError', 'CodecUnsupported', 'demux', 'codec'],
[
'MediaError',
'MediaMSEError',
'media-source',
'media-source',
],
])(
'maps %s + %s to %s/%s',
(engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(
engineType,
engineDetails,
undefined
)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
}
);
['MediaError', 'MediaMSEError', 'media-source', 'media-source'],
])('maps %s + %s to %s/%s', (engineType, engineDetails, stage, failure) => {
expect(
createMpegTsPlaybackEvidence(engineType, engineDetails, undefined)
).toEqual({
engineType,
engineDetails,
disposition: 'terminal',
stage,
failure,
});
});
it.each([
['OtherError', 'CodecUnsupported'],
@@ -96,15 +73,18 @@ describe('mpegts.js playback evidence', () => {
['NetworkError', 'httpstatuscodeinvalid'],
[{ type: 'NetworkError' }, 'Exception'],
['MediaError', { details: 'FormatError' }],
])('keeps inconsistent or malformed %p + %p evidence unknown', (type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
])(
'keeps inconsistent or malformed %p + %p evidence unknown',
(type, details) => {
const evidence = createMpegTsPlaybackEvidence(type, details, {
code: 503,
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
});
expect(evidence.stage).toBe('unknown');
expect(evidence.failure).toBe('unknown');
expect(evidence.httpStatus).toBeUndefined();
}
);
it.each([399, 600, 404.5, '404', null, undefined])(
'rejects invalid HTTP status %p',
@@ -163,45 +143,24 @@ describe('mpegts.js playback evidence', () => {
});
it.each([
['NetworkError', 'HttpStatusCodeInvalid', 'network-error', false],
['NetworkError', 'ConnectingTimeout', 'network-error', false],
['NetworkError', 'Exception', 'network-error', false],
[
'NetworkError',
'UnrecoverableEarlyEof',
'media-decode-error',
true,
],
['MediaError', 'FormatError', 'media-decode-error', true],
[
'MediaError',
'FormatUnsupported',
'unsupported-container',
true,
],
[
'MediaError',
'CodecUnsupported',
'unsupported-codec',
true,
],
['MediaError', 'MediaMSEError', 'media-decode-error', true],
['OtherError', 'Exception', 'unknown-playback-error', false],
])(
'classifies %s + %s as %s with fallback=%s',
(type, details, code, externalFallbackRecommended) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
['NetworkError', 'HttpStatusCodeInvalid', 'network-error'],
['NetworkError', 'ConnectingTimeout', 'network-error'],
['NetworkError', 'Exception', 'network-error'],
['NetworkError', 'UnrecoverableEarlyEof', 'media-decode-error'],
['MediaError', 'FormatError', 'media-decode-error'],
['MediaError', 'FormatUnsupported', 'unsupported-container'],
['MediaError', 'CodecUnsupported', 'unsupported-codec'],
['MediaError', 'MediaMSEError', 'media-decode-error'],
['OtherError', 'Exception', 'unknown-playback-error'],
])('classifies %s + %s as %s', (type, details, code) => {
const issue = classifyMpegTsPlaybackIssue(
createMpegTsPlaybackEvidence(type, details, undefined),
METADATA
);
expect(issue.code).toBe(code);
expect(issue.externalFallbackRecommended).toBe(
externalFallbackRecommended
);
expect(issue.details).toBeUndefined();
}
);
expect(issue.code).toBe(code);
expect(issue.details).toBeUndefined();
});
it('preserves an exact HTTP failure without retaining provider details', () => {
const secret = 'mpegts-http-secret';
@@ -230,7 +189,6 @@ describe('mpegts.js playback evidence', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
expect(issue.details).toBeUndefined();
@@ -32,6 +32,13 @@ export const PlaybackDiagnosticSource = {
export type PlaybackDiagnosticSource =
(typeof PlaybackDiagnosticSource)[keyof typeof PlaybackDiagnosticSource];
export const PlaybackRuntimeSupport = {
ShakaBrowserUnsupported: 'shaka-browser-unsupported',
} as const;
export type PlaybackRuntimeSupport =
(typeof PlaybackRuntimeSupport)[keyof typeof PlaybackRuntimeSupport];
export const InlinePlaybackPlayer = {
VideoJs: 'videojs',
Html5: 'html5',
@@ -255,6 +262,7 @@ export interface PlaybackDiagnostic {
readonly player?: InlinePlaybackPlayer;
readonly audioCodecs: readonly string[];
readonly videoCodecs: readonly string[];
readonly runtimeSupport?: PlaybackRuntimeSupport;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
@@ -264,7 +272,6 @@ export interface PlaybackDiagnostic {
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
readonly externalFallbackRecommended: boolean;
}
export interface PlaybackFallbackRequest {
@@ -10,6 +10,7 @@ import {
classifyHlsPlaybackIssue,
classifyMpegTsPlaybackIssue,
classifyNativePlaybackIssue,
classifyUnsupportedHlsManifestCodecs,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
getLikelyBrowserUnsupportedCodecLabels,
@@ -38,6 +39,38 @@ type VhsClassifier = (
) => PlaybackDiagnostic;
describe('playback diagnostics', () => {
it('classifies manifest codecs only when the explicit support probe rejects them', () => {
const metadata = createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'html5',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
});
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
() => false
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
});
it.each([true, undefined])(
'does not classify manifest codecs when the support probe returns %s',
(supportResult) => {
const issue = classifyUnsupportedHlsManifestCodecs(
createPlaybackSourceMetadata({
url: 'https://example.com/live/index.m3u8',
player: 'html5',
videoCodecs: ['avc1.64001f'],
}),
() => supportResult
);
expect(issue).toBeNull();
}
);
it('classifies HLS incompatible codec errors as unsupported codec fallbacks', () => {
const issue = classifyStructuredHlsPlaybackIssue(
{
@@ -57,7 +90,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
expect(issue?.audioCodecs).toEqual(['ac-3']);
expect(issue?.videoCodecs).toEqual(['avc1.64001f']);
});
@@ -78,7 +110,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies native decode and unsupported source errors without using network wording', () => {
@@ -100,11 +131,9 @@ describe('playback diagnostics', () => {
);
expect(decodeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(decodeIssue.externalFallbackRecommended).toBe(true);
expect(unsupportedSourceIssue.code).toBe(
PlaybackDiagnosticCode.UnsupportedContainer
);
expect(unsupportedSourceIssue.externalFallbackRecommended).toBe(true);
});
it('classifies MIME-only unsupported containers as container diagnostics', () => {
@@ -133,7 +162,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.container).toBe('mp2t');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies native HTTP source failures from a safe status and error type', () => {
@@ -153,7 +181,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.httpStatus).toBe(404);
expect(issue.nativeErrorType).toBe('networkrequestfailed');
expect(issue.externalFallbackRecommended).toBe(false);
});
it('classifies exact VHS network evidence without using provider text', () => {
@@ -177,7 +204,6 @@ describe('playback diagnostics', () => {
source: 'vhs',
httpStatus: undefined,
nativeErrorMessage: undefined,
externalFallbackRecommended: false,
vhs: {
engineType: 'networkrequestfailed',
mediaErrorCode: 4,
@@ -205,7 +231,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue.source).toBe('vhs');
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.nativeErrorMessage).toBeUndefined();
expect(issue.vhs).toEqual({
engineType: 'streamingfailedtodecryptsegment',
@@ -247,7 +272,6 @@ describe('playback diagnostics', () => {
);
expect(vhsIssue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(vhsIssue.externalFallbackRecommended).toBe(false);
expect(vhsIssue.nativeErrorMessage).toBeUndefined();
expect(nativeIssue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
});
@@ -310,7 +334,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(issue.httpStatus).toBeUndefined();
});
@@ -331,7 +354,6 @@ describe('playback diagnostics', () => {
expect(issue.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue.httpStatus).toBeUndefined();
expect(issue.nativeErrorType).toBeUndefined();
expect(issue.externalFallbackRecommended).toBe(false);
});
it.each([
@@ -400,7 +422,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('retains structured HLS HTTP and stage evidence', () => {
@@ -431,7 +452,6 @@ describe('playback diagnostics', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
})
);
});
@@ -471,7 +491,6 @@ describe('playback diagnostics', () => {
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.httpStatus).toBeUndefined();
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies exact HLS decrypt evidence as DRM or encryption', () => {
@@ -490,7 +509,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.DrmOrEncryption);
expect(issue?.externalFallbackRecommended).toBe(true);
});
it('classifies exact HLS key load failures as network errors', () => {
@@ -509,7 +527,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it.each([ErrorTypes.MEDIA_ERROR, ErrorTypes.MUX_ERROR])(
@@ -549,7 +566,6 @@ describe('playback diagnostics', () => {
);
expect(issue?.code).toBe(PlaybackDiagnosticCode.UnknownPlaybackError);
expect(issue?.externalFallbackRecommended).toBe(false);
});
it('classifies native CORS failures as browser access errors', () => {
@@ -566,7 +582,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe('browser-access-error');
expect(issue.externalFallbackRecommended).toBe(true);
});
it('keeps opaque native network failures generic when the browser hides access details', () => {
@@ -582,7 +597,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
});
it('does not guess mpegts browser access from exception messages', () => {
@@ -599,7 +613,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.NetworkError);
expect(issue.externalFallbackRecommended).toBe(false);
expect(JSON.stringify(issue)).not.toContain(secret);
});
@@ -618,7 +631,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.MediaDecodeError);
expect(issue.externalFallbackRecommended).toBe(true);
expect(issue.mpegTs?.failure).toBe('truncated-stream');
expect(issue.details).toBeUndefined();
});
@@ -638,7 +650,6 @@ describe('playback diagnostics', () => {
);
expect(issue.code).toBe(PlaybackDiagnosticCode.UnsupportedCodec);
expect(issue.externalFallbackRecommended).toBe(true);
});
it('prefers stream extension query metadata over web script path extensions', () => {
@@ -5,6 +5,7 @@ import type {
PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRuntimeSupport,
PlaybackSourceMetadata,
ShakaPlaybackEvidence,
VhsPlaybackEngineType as VhsPlaybackEngineTypeValue,
@@ -50,17 +51,13 @@ const VHS_NETWORK_ERROR_TYPES: ReadonlySet<VhsPlaybackEngineTypeValue> =
]);
export function classifyNativePlaybackIssue(
error: NativePlaybackErrorInput | MediaError | null | undefined,
error: NativePlaybackErrorInput | null | undefined,
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
const nativeErrorCode = error?.code;
const nativeErrorMessage = error?.message || undefined;
const nativeErrorInput = error as
NativePlaybackErrorInput | null | undefined;
const httpStatus = getNativeHttpStatus(nativeErrorInput?.status);
const nativeErrorType = getNativeErrorType(
nativeErrorInput?.metadata?.errorType
);
const httpStatus = getNativeHttpStatus(error?.status);
const nativeErrorType = getNativeErrorType(error?.metadata?.errorType);
const lowerNativeErrorMessage = nativeErrorMessage?.toLowerCase() ?? '';
if (httpStatus !== undefined) {
@@ -175,17 +172,20 @@ export function classifyMpegTsPlaybackIssue(
});
}
export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined;
export function classifyUnsupportedHlsManifestCodecs(
metadata: PlaybackSourceMetadata
metadata: PlaybackSourceMetadata,
isTypeSupported: MediaTypeSupportProbe
): PlaybackDiagnostic | null {
if (!hasCodecs(metadata) || typeof MediaSource === 'undefined') {
if (!hasCodecs(metadata)) {
return null;
}
const codecList = [...metadata.videoCodecs, ...metadata.audioCodecs];
const mimeType = `video/mp4; codecs="${codecList.join(',')}"`;
if (MediaSource.isTypeSupported(mimeType)) {
if (isTypeSupported(mimeType) !== false) {
return null;
}
@@ -201,6 +201,7 @@ export function createPlaybackDiagnostic(options: {
readonly code: PlaybackDiagnosticCode;
readonly source: PlaybackDiagnosticSource;
readonly metadata: PlaybackSourceMetadata;
readonly runtimeSupport?: PlaybackRuntimeSupport;
readonly details?: string;
readonly nativeErrorCode?: number;
readonly nativeErrorMessage?: string;
@@ -210,14 +211,12 @@ export function createPlaybackDiagnostic(options: {
readonly hls?: HlsPlaybackEvidence;
readonly mpegTs?: MpegTsPlaybackEvidence;
readonly shaka?: ShakaPlaybackEvidence;
/** Overrides the code-derived recommendation, e.g. when external players
* are known to be unable to handle the stream either. */
readonly externalFallbackRecommended?: boolean;
}): PlaybackDiagnostic {
const {
code,
source,
metadata,
runtimeSupport,
details,
nativeErrorCode,
nativeErrorMessage,
@@ -238,6 +237,7 @@ export function createPlaybackDiagnostic(options: {
player: metadata.player,
audioCodecs: metadata.audioCodecs,
videoCodecs: metadata.videoCodecs,
runtimeSupport,
details: details || undefined,
nativeErrorCode,
nativeErrorMessage,
@@ -247,9 +247,6 @@ export function createPlaybackDiagnostic(options: {
hls,
mpegTs,
shaka,
externalFallbackRecommended:
options.externalFallbackRecommended ??
isExternalFallbackRecommended(code),
};
}
@@ -323,16 +320,6 @@ function getNativeErrorType(errorType: unknown): string | undefined {
: undefined;
}
function isExternalFallbackRecommended(code: PlaybackDiagnosticCode): boolean {
return (
code === DiagnosticCode.UnsupportedContainer ||
code === DiagnosticCode.UnsupportedCodec ||
code === DiagnosticCode.MediaDecodeError ||
code === DiagnosticCode.BrowserAccessError ||
code === DiagnosticCode.DrmOrEncryption
);
}
function hasCodecs(metadata: PlaybackSourceMetadata): boolean {
return metadata.audioCodecs.length > 0 || metadata.videoCodecs.length > 0;
}
@@ -2,8 +2,8 @@ import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackSourceMetadata } from '../playback-diagnostics/playback-diagnostics.util';
} from './playback-diagnostics.model';
import { createPlaybackSourceMetadata } from './playback-diagnostics.util';
import {
classifyShakaPlaybackIssue,
createUnsupportedDrmDiagnostic,
@@ -216,8 +216,5 @@ describe('createUnsupportedDrmDiagnostic', () => {
expect(issue.details).toBe('Unsupported DRM license configuration');
expect(JSON.stringify(issue)).not.toContain(secret);
expect(JSON.stringify(issue)).not.toContain('provider.example');
// MPV/VLC cannot receive KODIPROP license config, so the diagnostic
// must not offer them as a fallback.
expect(issue.externalFallbackRecommended).toBe(false);
});
});
@@ -4,18 +4,19 @@ import type {
PlaybackSourceMetadata,
ShakaPlaybackDisposition,
ShakaPlaybackEvidence,
} from '../playback-diagnostics/playback-diagnostics.model';
} from './playback-diagnostics.model';
import {
PlaybackDiagnosticCode as DiagnosticCode,
PlaybackDiagnosticSource as DiagnosticSource,
ShakaPlaybackCategory,
ShakaPlaybackDisposition as ShakaDisposition,
ShakaPlaybackFailure,
} from '../playback-diagnostics/playback-diagnostics.model';
import { createPlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
PlaybackRuntimeSupport,
} from './playback-diagnostics.model';
import { createPlaybackDiagnostic } from './playback-diagnostics.util';
import { SHAKA_ERROR_CODE } from './shaka-error-contract';
import { createShakaPlaybackEvidence } from './shaka-playback-evidence.util';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export {
SHAKA_DIAGNOSTIC_VERSION,
@@ -88,9 +89,19 @@ export function createUnsupportedDrmDiagnostic(
source: DiagnosticSource.Shaka,
metadata,
details: 'Unsupported DRM license configuration',
// External MPV/VLC cannot receive the KODIPROP license config either,
// so offering them as a fallback would just fail differently.
externalFallbackRecommended: false,
});
}
/** Exact app-owned evidence for Shaka's browser-support preflight. */
export function createShakaBrowserUnsupportedDiagnostic(
metadata: PlaybackSourceMetadata
): PlaybackDiagnostic {
return createPlaybackDiagnostic({
code: DiagnosticCode.UnknownPlaybackError,
source: DiagnosticSource.Shaka,
metadata,
runtimeSupport: PlaybackRuntimeSupport.ShakaBrowserUnsupported,
shaka: createShakaPlaybackEvidence(null, ShakaDisposition.Terminal),
});
}
@@ -1,10 +1,10 @@
import { ShakaPlaybackDisposition as ShakaDisposition } from '../playback-diagnostics/playback-diagnostics.model';
import { ShakaPlaybackDisposition as ShakaDisposition } from './playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
SHAKA_ERROR_SEVERITY,
} from './shaka-error-contract';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export function getShakaErrorEventDisposition(
error: Partial<ShakaErrorLike> | null
@@ -0,0 +1,6 @@
export interface ShakaErrorLike {
readonly severity: number;
readonly category: number;
readonly code: number;
readonly data?: readonly unknown[];
}
@@ -9,7 +9,7 @@ import {
ShakaPlaybackSeverity,
ShakaPlaybackStage,
ShakaPlaybackUnknownCode,
} from '../playback-diagnostics/playback-diagnostics.model';
} from './playback-diagnostics.model';
import {
SHAKA_ERROR_CATEGORY,
SHAKA_ERROR_CODE,
@@ -29,7 +29,7 @@ import {
SHAKA_PUBLIC_CODES,
SHAKA_SEGMENT_STAGE_CODES,
} from './shaka-error-mapping';
import type { ShakaErrorLike } from './shaka-module.types';
import type { ShakaErrorLike } from './shaka-error.types';
export function createShakaPlaybackEvidence(
error: Partial<ShakaErrorLike> | null | undefined,
@@ -0,0 +1,144 @@
import { InlinePlaybackPlayer } from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import { getInlinePlaybackEngineFamily } from './playback-target-capabilities';
interface RuntimeCapabilityRecord {
readonly target: unknown;
readonly kind: unknown;
readonly available: unknown;
readonly engineFamily?: unknown;
}
export type PlaybackCapabilityIndex = ReadonlyMap<
PlaybackRecommendationTarget,
PlaybackTargetCapability
>;
const CANONICAL_TARGETS: readonly PlaybackRecommendationTarget[] = [
InlinePlaybackPlayer.VideoJs,
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
'mpv',
'vlc',
];
export function createPlaybackCapabilityIndex(
context: PlaybackRecommendationContext
): PlaybackCapabilityIndex | null {
const index = new Map<
PlaybackRecommendationTarget,
PlaybackTargetCapability
>();
for (const capability of context.targetCapabilities) {
if (!isValidCapability(context, capability, index)) {
return null;
}
index.set(capability.target, capability);
}
if (index.size !== CANONICAL_TARGETS.length) {
return null;
}
const active = index.get(context.activeTarget);
return active?.kind === 'inline' &&
active.available &&
active.engineFamily !== null
? index
: null;
}
export function hasAvailableCapability(
capabilityIndex: PlaybackCapabilityIndex | null,
target: PlaybackRecommendationTarget
): boolean {
return capabilityIndex?.get(target)?.available === true;
}
export function getAttemptedInlineFamilies(
context: PlaybackRecommendationContext,
capabilityIndex: PlaybackCapabilityIndex | null
): ReadonlySet<PlaybackEngineFamily> {
const families = new Set<PlaybackEngineFamily>();
if (capabilityIndex === null) {
return families;
}
for (const target of context.attemptedTargets) {
const capability = capabilityIndex.get(target);
if (
capability?.kind === 'inline' &&
capability.available &&
capability.engineFamily !== null
) {
families.add(capability.engineFamily);
}
}
return families;
}
export function isInlineFamilyAttempted(
target: PlaybackRecommendationTarget,
capabilityIndex: PlaybackCapabilityIndex | null,
attemptedFamilies: ReadonlySet<PlaybackEngineFamily>
): boolean {
const capability = capabilityIndex?.get(target);
return (
capability?.kind === 'inline' &&
capability.engineFamily !== null &&
attemptedFamilies.has(capability.engineFamily)
);
}
function isValidCapability(
context: PlaybackRecommendationContext,
capability: unknown,
index: PlaybackCapabilityIndex
): capability is PlaybackTargetCapability {
if (
!hasRequiredCapabilityFields(capability) ||
!isKnownTarget(capability.target) ||
index.has(capability.target) ||
typeof capability.available !== 'boolean'
) {
return false;
}
if (!isInlineTarget(capability.target)) {
return capability.kind === 'external';
}
if (capability.kind !== 'inline' || !('engineFamily' in capability)) {
return false;
}
const expectedFamily = getInlinePlaybackEngineFamily(
context.source.kind,
capability.target
);
return (
capability.engineFamily === expectedFamily &&
!(expectedFamily === null && capability.available)
);
}
function hasRequiredCapabilityFields(
value: unknown
): value is RuntimeCapabilityRecord {
return (
typeof value === 'object' &&
value !== null &&
'target' in value &&
'kind' in value &&
'available' in value
);
}
function isKnownTarget(value: unknown): value is PlaybackRecommendationTarget {
return CANONICAL_TARGETS.includes(value as PlaybackRecommendationTarget);
}
function isInlineTarget(
value: PlaybackRecommendationTarget
): value is InlinePlaybackPlayer {
return value !== 'mpv' && value !== 'vlc';
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,302 @@
import type { ExternalPlayerName } from '@iptvnator/shared/interfaces';
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRuntimeSupport,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackRecommendationReason,
PlaybackSourceKind,
type PlaybackRecommendation,
type PlaybackRecommendationContext,
type PlaybackRecommendationTarget,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
import {
createPlaybackCapabilityIndex,
getAttemptedInlineFamilies,
hasAvailableCapability,
isInlineFamilyAttempted,
type PlaybackCapabilityIndex,
} from './playback-recommendation-capabilities';
type WithoutPriority<T> = T extends unknown ? Omit<T, 'priority'> : never;
type PlaybackRecommendationCandidate = WithoutPriority<PlaybackRecommendation>;
type InlineCapability = Extract<
PlaybackTargetCapability,
{ readonly kind: 'inline' }
>;
type AvailableInlineCapability = InlineCapability & {
readonly available: true;
readonly engineFamily: PlaybackEngineFamily;
};
export function recommendPlaybackRecovery(
context: PlaybackRecommendationContext
): readonly PlaybackRecommendation[] {
const capabilityIndex = hasPlayerRecoveryEvidence(context)
? createPlaybackCapabilityIndex(context)
: null;
const attemptedInlineFamilies = getAttemptedInlineFamilies(
context,
capabilityIndex
);
const seenTargets = new Set<PlaybackRecommendationTarget>();
const candidates = buildCandidates(context, capabilityIndex).filter(
isCandidate
);
const filtered = candidates.filter((candidate) => {
if (candidate.action !== 'player') {
return true;
}
if (
candidate.target === context.activeTarget ||
context.attemptedTargets.has(candidate.target) ||
seenTargets.has(candidate.target) ||
!hasAvailableCapability(capabilityIndex, candidate.target)
) {
return false;
}
if (
isInlineFamilyAttempted(
candidate.target,
capabilityIndex,
attemptedInlineFamilies
)
) {
return false;
}
if (
isExternalTarget(candidate.target) &&
!canTransferExternally(context)
) {
return false;
}
seenTargets.add(candidate.target);
return true;
});
return filtered.slice(0, 3).map((candidate, index) => ({
...candidate,
priority: index === 0 ? 'primary' : 'secondary',
}));
}
function buildCandidates(
context: PlaybackRecommendationContext,
capabilityIndex: PlaybackCapabilityIndex | null
): readonly (PlaybackRecommendationCandidate | null)[] {
if (hasPlayerRecoveryEvidence(context) && capabilityIndex === null) {
return [retryUnknown(), alternative(context)];
}
if (isShakaBrowserUnsupported(context)) {
return [
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
}
switch (context.diagnostic.code) {
case PlaybackDiagnosticCode.NetworkError:
return [retryTransient(), alternative(context)];
case PlaybackDiagnosticCode.BrowserAccessError:
return [
external(
'mpv',
PlaybackRecommendationReason.ExternalBrowserAccess
),
external(
'vlc',
PlaybackRecommendationReason.ExternalBrowserAccess
),
alternative(context),
];
case PlaybackDiagnosticCode.UnsupportedCodec:
case PlaybackDiagnosticCode.UnsupportedContainer:
return [
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
case PlaybackDiagnosticCode.MediaDecodeError:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.DifferentEngineFamily
),
externalCodecSupport('mpv'),
externalCodecSupport('vlc'),
alternative(context),
];
case PlaybackDiagnosticCode.DrmOrEncryption:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.CompatibleDrmPath
),
alternative(context),
external('mpv', PlaybackRecommendationReason.CompatibleDrmPath),
external('vlc', PlaybackRecommendationReason.CompatibleDrmPath),
];
case PlaybackDiagnosticCode.UnknownPlaybackError:
default:
return [retryUnknown(), alternative(context)];
}
}
function retryTransient(): PlaybackRecommendationCandidate {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryTransientFailure,
};
}
function retryUnknown(): PlaybackRecommendationCandidate {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryUnknownFailure,
};
}
function alternative(
context: PlaybackRecommendationContext
): PlaybackRecommendationCandidate | null {
return !Number.isSafeInteger(context.alternativeSourceCount) ||
context.alternativeSourceCount <= 0
? null
: {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
};
}
function distinctInline(
context: PlaybackRecommendationContext,
capabilityIndex: PlaybackCapabilityIndex | null,
reason: PlaybackRecommendationCandidate['reason']
): PlaybackRecommendationCandidate | null {
const activeCapability = getActiveInlineCapability(
context,
capabilityIndex
);
if (activeCapability === null) {
return null;
}
const canonicalTarget = getCanonicalDistinctInlineTarget(
activeCapability.engineFamily
);
if (canonicalTarget === null) {
return null;
}
const capability = capabilityIndex?.get(canonicalTarget);
return capability?.kind !== 'inline' ||
!capability.available ||
capability.engineFamily === null ||
capability.engineFamily === activeCapability.engineFamily
? null
: { action: 'player', target: capability.target, reason };
}
function getCanonicalDistinctInlineTarget(
activeFamily: PlaybackEngineFamily
): InlinePlaybackPlayer | null {
if (activeFamily === PlaybackEngineFamily.Vhs) {
return InlinePlaybackPlayer.Html5;
}
return activeFamily === PlaybackEngineFamily.HlsJs
? InlinePlaybackPlayer.VideoJs
: null;
}
function getActiveInlineCapability(
context: PlaybackRecommendationContext,
capabilityIndex: PlaybackCapabilityIndex | null
): AvailableInlineCapability | null {
const capability = capabilityIndex?.get(context.activeTarget);
return capability?.kind === 'inline' &&
capability.available &&
capability.engineFamily !== null
? (capability as AvailableInlineCapability)
: null;
}
function externalCodecSupport(
target: ExternalPlayerName
): PlaybackRecommendationCandidate {
return external(
target,
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
);
}
function external(
target: ExternalPlayerName,
reason: PlaybackRecommendationCandidate['reason']
): PlaybackRecommendationCandidate {
return { action: 'player', target, reason };
}
function isPlayerOrientedDiagnostic(
code: PlaybackRecommendationContext['diagnostic']['code']
): boolean {
return (
code === PlaybackDiagnosticCode.BrowserAccessError ||
code === PlaybackDiagnosticCode.UnsupportedCodec ||
code === PlaybackDiagnosticCode.UnsupportedContainer ||
code === PlaybackDiagnosticCode.MediaDecodeError ||
code === PlaybackDiagnosticCode.DrmOrEncryption
);
}
function hasPlayerRecoveryEvidence(
context: PlaybackRecommendationContext
): boolean {
return (
isPlayerOrientedDiagnostic(context.diagnostic.code) ||
isShakaBrowserUnsupported(context)
);
}
function isShakaBrowserUnsupported(
context: PlaybackRecommendationContext
): boolean {
return (
context.diagnostic.code ===
PlaybackDiagnosticCode.UnknownPlaybackError &&
context.diagnostic.source === PlaybackDiagnosticSource.Shaka &&
context.diagnostic.runtimeSupport ===
PlaybackRuntimeSupport.ShakaBrowserUnsupported &&
context.source.kind === PlaybackSourceKind.Dash
);
}
function isExternalTarget(
target: PlaybackRecommendationTarget
): target is ExternalPlayerName {
return target === 'mpv' || target === 'vlc';
}
function canTransferExternally(
context: PlaybackRecommendationContext
): boolean {
return (
context.source.drm !== 'untransferable' &&
context.source.externalTransferable
);
}
function isCandidate(
candidate: PlaybackRecommendationCandidate | null
): candidate is PlaybackRecommendationCandidate {
return candidate !== null;
}
@@ -0,0 +1,95 @@
import type {
ExternalPlayerName,
} from '@iptvnator/shared/interfaces';
import type {
InlinePlaybackPlayer,
PlaybackDiagnostic,
} from './diagnostics/playback-diagnostics.model';
export const PlaybackRecommendationReason = {
RetryTransientFailure: 'retry-transient-failure',
RetryUnknownFailure: 'retry-unknown-failure',
AlternativeSourceAvailable: 'alternative-source-available',
DifferentEngineFamily: 'different-engine-family',
ExternalCodecOrContainerSupport: 'external-codec-or-container-support',
ExternalBrowserAccess: 'external-browser-access',
CompatibleDrmPath: 'compatible-drm-path',
} as const;
export type PlaybackRecommendationReason =
(typeof PlaybackRecommendationReason)[keyof typeof PlaybackRecommendationReason];
export type PlaybackRecommendationPriority = 'primary' | 'secondary';
export type PlaybackRecommendationTarget =
| InlinePlaybackPlayer
| ExternalPlayerName;
export const PlaybackSourceKind = {
Hls: 'hls',
MpegTs: 'mpegts',
Dash: 'dash',
Native: 'native',
Unknown: 'unknown',
} as const;
export type PlaybackSourceKind =
(typeof PlaybackSourceKind)[keyof typeof PlaybackSourceKind];
export const PlaybackEngineFamily = {
Vhs: 'vhs',
HlsJs: 'hls.js',
MpegTsJs: 'mpegts.js',
Shaka: 'shaka',
NativeMedia: 'native-media',
} as const;
export type PlaybackEngineFamily =
(typeof PlaybackEngineFamily)[keyof typeof PlaybackEngineFamily];
export type PlaybackTargetCapability =
| {
readonly kind: 'inline';
readonly target: InlinePlaybackPlayer;
readonly available: boolean;
readonly engineFamily: PlaybackEngineFamily | null;
}
| {
readonly kind: 'external';
readonly target: ExternalPlayerName;
readonly available: boolean;
};
export interface PlaybackRecommendationSourceContext {
readonly kind: PlaybackSourceKind;
readonly isLive: boolean;
readonly drm: 'none' | 'untransferable';
readonly externalTransferable: boolean;
}
export interface PlaybackRecommendationContext {
readonly diagnostic: PlaybackDiagnostic;
readonly activeTarget: PlaybackRecommendationTarget;
readonly attemptedTargets: ReadonlySet<PlaybackRecommendationTarget>;
readonly targetCapabilities: readonly PlaybackTargetCapability[];
readonly source: PlaybackRecommendationSourceContext;
readonly alternativeSourceCount: number;
}
export type PlaybackRecommendation =
| {
readonly action: 'retry';
readonly reason: PlaybackRecommendationReason;
readonly priority: PlaybackRecommendationPriority;
}
| {
readonly action: 'alternative-source';
readonly reason: PlaybackRecommendationReason;
readonly priority: PlaybackRecommendationPriority;
}
| {
readonly action: 'player';
readonly target: PlaybackRecommendationTarget;
readonly reason: PlaybackRecommendationReason;
readonly priority: PlaybackRecommendationPriority;
};
@@ -0,0 +1,196 @@
import * as playbackSessionKey from './playback-session-key';
import type { PlaybackSessionIdentity } from './playback-session-key';
const { createPlaybackSessionKey } = playbackSessionKey;
describe('playback session key', () => {
it('length-prefixes every identity part', () => {
expect(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'list:west|hd',
contentId: 'ep:4|finale',
seriesId: 'series|2:remaster',
seasonNumber: 3,
episodeNumber: 4,
})
).toBe(
'7:episode|12:list:west|hd|11:ep:4|finale|17:series|2:remaster|1:3|1:4'
);
});
it.each([
[
{ kind: 'live', sourceId: 'a', contentId: 'b|c' },
{ kind: 'live', sourceId: 'a|b', contentId: 'c' },
],
[
{ kind: 'vod', sourceId: 'a:1', contentId: '2' },
{ kind: 'vod', sourceId: 'a', contentId: '1:2' },
],
[
{
kind: 'episode',
sourceId: 'source',
contentId: 'content',
seriesId: 'series:1|2',
seasonNumber: 3,
episodeNumber: 4,
},
{
kind: 'episode',
sourceId: 'source',
contentId: 'content',
seriesId: 'series',
seasonNumber: 1,
episodeNumber: 2,
},
],
] as const)(
'does not collide delimiter-bearing identities %#',
(left, right) => {
expect(createPlaybackSessionKey(left)).not.toBe(
createPlaybackSessionKey(right)
);
}
);
it('reuses canonical movie identity across provider copies and source URLs', () => {
const logicalIdentity: PlaybackSessionIdentity = {
kind: 'vod',
sourceId: 'catalog:featured',
contentId: 'movie:42',
};
const originalCopy = {
providerId: 'provider:one',
sourceUrl: 'https://one.example/movie/42',
logicalIdentity,
};
const alternativeCopy = {
providerId: 'provider|two',
sourceUrl: 'https://two.example/alternate/9001',
logicalIdentity,
};
expect(createPlaybackSessionKey(originalCopy.logicalIdentity)).toBe(
createPlaybackSessionKey(alternativeCopy.logicalIdentity)
);
});
it.each([
[{ kind: 'live', sourceId: 'playlist:2', contentId: 42 }],
[{ kind: 'live', sourceId: 'playlist:1', contentId: 43 }],
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 42 }],
] as const)(
'changes when a live channel identity changes: %o',
(changedIdentity) => {
const original = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist:1',
contentId: 42,
});
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
original
);
}
);
it.each([
[{ kind: 'vod', sourceId: 'playlist:2', contentId: 42 }],
[{ kind: 'vod', sourceId: 'playlist:1', contentId: 43 }],
] as const)(
'changes when a logical movie identity changes: %o',
(changedIdentity) => {
const original = createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist:1',
contentId: 42,
});
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
original
);
}
);
it.each([
[
'source',
{
kind: 'episode',
sourceId: 'playlist:2',
contentId: 42,
seriesId: 7,
seasonNumber: 3,
episodeNumber: 4,
},
],
[
'content',
{
kind: 'episode',
sourceId: 'playlist:1',
contentId: 43,
seriesId: 7,
seasonNumber: 3,
episodeNumber: 4,
},
],
[
'series',
{
kind: 'episode',
sourceId: 'playlist:1',
contentId: 42,
seriesId: 8,
seasonNumber: 3,
episodeNumber: 4,
},
],
[
'season',
{
kind: 'episode',
sourceId: 'playlist:1',
contentId: 42,
seriesId: 7,
seasonNumber: 4,
episodeNumber: 4,
},
],
[
'episode',
{
kind: 'episode',
sourceId: 'playlist:1',
contentId: 42,
seriesId: 7,
seasonNumber: 3,
episodeNumber: 5,
},
],
] as const)(
'changes when the logical episode %s identity changes',
(_field, changedIdentity) => {
const original = createPlaybackSessionKey({
kind: 'episode',
sourceId: 'playlist:1',
contentId: 42,
seriesId: 7,
seasonNumber: 3,
episodeNumber: 4,
});
expect(createPlaybackSessionKey(changedIdentity)).not.toBe(
original
);
}
);
it('does not expose a provider-scoped content-info adapter', () => {
expect(playbackSessionKey).not.toHaveProperty(
'createPlaybackSessionKeyFromContentInfo'
);
});
});
@@ -0,0 +1,38 @@
/**
* Host-owned canonical logical content identity. Source and content IDs must
* not come from the currently selected provider copy or playback URL.
*/
export type PlaybackSessionIdentity =
| {
readonly kind: 'live';
readonly sourceId: string;
readonly contentId: string | number;
}
| {
readonly kind: 'vod';
readonly sourceId: string;
readonly contentId: string | number;
}
| {
readonly kind: 'episode';
readonly sourceId: string;
readonly contentId: string | number;
readonly seriesId?: string | number;
readonly seasonNumber?: number;
readonly episodeNumber?: number;
};
export function createPlaybackSessionKey(
identity: PlaybackSessionIdentity
): string {
const parts = [
identity.kind,
identity.sourceId,
String(identity.contentId),
identity.kind === 'episode' ? String(identity.seriesId ?? '') : '',
identity.kind === 'episode' ? String(identity.seasonNumber ?? '') : '',
identity.kind === 'episode' ? String(identity.episodeNumber ?? '') : '',
];
return parts.map((part) => `${part.length}:${part}`).join('|');
}
@@ -0,0 +1,303 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackSourceKind,
} from './playback-recommendation.model';
import {
createPlaybackTargetCapabilities,
getInlinePlaybackEngineFamily,
resolvePlaybackSourceKind,
} from './playback-target-capabilities';
function diagnostic(
overrides: Partial<PlaybackDiagnostic>
): PlaybackDiagnostic {
return {
code: PlaybackDiagnosticCode.UnknownPlaybackError,
source: PlaybackDiagnosticSource.Source,
sourceUrl: 'https://example.com/stream',
container: '',
audioCodecs: [],
videoCodecs: [],
...overrides,
};
}
describe('playback target capabilities', () => {
it.each([
[
PlaybackSourceKind.Hls,
InlinePlaybackPlayer.VideoJs,
PlaybackEngineFamily.Vhs,
],
[
PlaybackSourceKind.Hls,
InlinePlaybackPlayer.Html5,
PlaybackEngineFamily.HlsJs,
],
[
PlaybackSourceKind.Hls,
InlinePlaybackPlayer.ArtPlayer,
PlaybackEngineFamily.HlsJs,
],
[
PlaybackSourceKind.MpegTs,
InlinePlaybackPlayer.VideoJs,
PlaybackEngineFamily.MpegTsJs,
],
[
PlaybackSourceKind.MpegTs,
InlinePlaybackPlayer.Html5,
PlaybackEngineFamily.MpegTsJs,
],
[
PlaybackSourceKind.MpegTs,
InlinePlaybackPlayer.ArtPlayer,
PlaybackEngineFamily.MpegTsJs,
],
[PlaybackSourceKind.Dash, InlinePlaybackPlayer.VideoJs, null],
[
PlaybackSourceKind.Dash,
InlinePlaybackPlayer.Html5,
PlaybackEngineFamily.Shaka,
],
[
PlaybackSourceKind.Dash,
InlinePlaybackPlayer.ArtPlayer,
PlaybackEngineFamily.Shaka,
],
[
PlaybackSourceKind.Native,
InlinePlaybackPlayer.VideoJs,
PlaybackEngineFamily.NativeMedia,
],
[
PlaybackSourceKind.Native,
InlinePlaybackPlayer.Html5,
PlaybackEngineFamily.NativeMedia,
],
[
PlaybackSourceKind.Native,
InlinePlaybackPlayer.ArtPlayer,
PlaybackEngineFamily.NativeMedia,
],
] as const)('maps %s on %s to %s', (sourceKind, target, expectedFamily) => {
expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe(
expectedFamily
);
});
it.each([
[PlaybackDiagnosticSource.Hls, {}, PlaybackSourceKind.Hls],
[
PlaybackDiagnosticSource.Vhs,
{ container: 'm3u8' },
PlaybackSourceKind.Hls,
],
[
PlaybackDiagnosticSource.Source,
{ container: 'm3u' },
PlaybackSourceKind.Hls,
],
[
PlaybackDiagnosticSource.Vhs,
{
mimeType: ' Application/Vnd.Apple.MPEGURL; charset=UTF-8 ',
},
PlaybackSourceKind.Hls,
],
[
PlaybackDiagnosticSource.Source,
{ mimeType: 'application/x-mpegurl' },
PlaybackSourceKind.Hls,
],
[
PlaybackDiagnosticSource.Vhs,
{ mimeType: 'audio/x-mpegurl' },
PlaybackSourceKind.Hls,
],
[PlaybackDiagnosticSource.Shaka, {}, PlaybackSourceKind.Dash],
[
PlaybackDiagnosticSource.Source,
{ container: 'mpd' },
PlaybackSourceKind.Dash,
],
[
PlaybackDiagnosticSource.Vhs,
{ container: 'mpd' },
PlaybackSourceKind.Dash,
],
[
PlaybackDiagnosticSource.Source,
{ mimeType: ' Application/Dash+XML; profile=live ' },
PlaybackSourceKind.Dash,
],
[PlaybackDiagnosticSource.MpegTs, {}, PlaybackSourceKind.MpegTs],
[PlaybackDiagnosticSource.Native, {}, PlaybackSourceKind.Native],
] as const)(
'resolves %s diagnostics with %o as %s',
(source, metadata, expectedKind) => {
expect(
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
).toBe(expectedKind);
}
);
it.each([
diagnostic({ source: PlaybackDiagnosticSource.Source }),
diagnostic({
source: PlaybackDiagnosticSource.Source,
container: 'mp4',
mimeType: 'video/mp4',
}),
diagnostic({
source: PlaybackDiagnosticSource.Vhs,
container: 'mpd',
mimeType: 'application/vnd.apple.mpegurl',
}),
diagnostic({
source: PlaybackDiagnosticSource.Source,
container: 'm3u8',
mimeType: 'application/dash+xml',
}),
diagnostic({
source: PlaybackDiagnosticSource.Vhs,
mimeType: 'application/not-mpegurl',
}),
])('keeps insufficient or contradictory evidence unknown', (issue) => {
expect(resolvePlaybackSourceKind(issue)).toBe(
PlaybackSourceKind.Unknown
);
});
it.each([
[
PlaybackDiagnosticSource.Hls,
{ container: 'mpd', mimeType: 'application/dash+xml' },
PlaybackSourceKind.Hls,
],
[
PlaybackDiagnosticSource.MpegTs,
{ container: 'mpd', mimeType: 'application/dash+xml' },
PlaybackSourceKind.MpegTs,
],
[
PlaybackDiagnosticSource.Shaka,
{
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
PlaybackSourceKind.Dash,
],
[
PlaybackDiagnosticSource.Native,
{
container: 'm3u8',
mimeType: 'application/vnd.apple.mpegurl',
},
PlaybackSourceKind.Native,
],
] as const)(
'treats engine-specific %s source evidence as authoritative',
(source, metadata, expectedKind) => {
expect(
resolvePlaybackSourceKind(diagnostic({ source, ...metadata }))
).toBe(expectedKind);
}
);
it('creates capabilities in canonical order', () => {
expect(
createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Hls,
managedExternalPlayersAvailable: true,
})
).toEqual([
{
kind: 'inline',
target: InlinePlaybackPlayer.VideoJs,
available: true,
engineFamily: PlaybackEngineFamily.Vhs,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.Html5,
available: true,
engineFamily: PlaybackEngineFamily.HlsJs,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.ArtPlayer,
available: true,
engineFamily: PlaybackEngineFamily.HlsJs,
},
{ kind: 'external', target: 'mpv', available: true },
{ kind: 'external', target: 'vlc', available: true },
]);
});
it('marks Video.js unavailable for DASH and mirrors external availability', () => {
expect(
createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Dash,
managedExternalPlayersAvailable: false,
})
).toEqual([
{
kind: 'inline',
target: InlinePlaybackPlayer.VideoJs,
available: false,
engineFamily: null,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.Html5,
available: true,
engineFamily: PlaybackEngineFamily.Shaka,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.ArtPlayer,
available: true,
engineFamily: PlaybackEngineFamily.Shaka,
},
{ kind: 'external', target: 'mpv', available: false },
{ kind: 'external', target: 'vlc', available: false },
]);
});
it('fails closed for unknown inline source capabilities', () => {
expect(
createPlaybackTargetCapabilities({
sourceKind: PlaybackSourceKind.Unknown,
managedExternalPlayersAvailable: true,
})
).toEqual([
{
kind: 'inline',
target: InlinePlaybackPlayer.VideoJs,
available: false,
engineFamily: null,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.Html5,
available: false,
engineFamily: null,
},
{
kind: 'inline',
target: InlinePlaybackPlayer.ArtPlayer,
available: false,
engineFamily: null,
},
{ kind: 'external', target: 'mpv', available: true },
{ kind: 'external', target: 'vlc', available: true },
]);
});
});
@@ -0,0 +1,133 @@
import {
InlinePlaybackPlayer,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from './diagnostics/playback-diagnostics.model';
import {
PlaybackEngineFamily,
PlaybackSourceKind,
type PlaybackTargetCapability,
} from './playback-recommendation.model';
const HLS_MIME_TYPES = new Set([
'application/vnd.apple.mpegurl',
'application/x-mpegurl',
'audio/x-mpegurl',
]);
const DASH_MIME_TYPE = 'application/dash+xml';
export function resolvePlaybackSourceKind(
diagnostic: PlaybackDiagnostic
): PlaybackSourceKind {
switch (diagnostic.source) {
// Engine-specific sources identify the boundary that emitted the
// failure, so generic container/MIME metadata cannot override them.
case PlaybackDiagnosticSource.Hls:
return PlaybackSourceKind.Hls;
case PlaybackDiagnosticSource.MpegTs:
return PlaybackSourceKind.MpegTs;
case PlaybackDiagnosticSource.Shaka:
return PlaybackSourceKind.Dash;
case PlaybackDiagnosticSource.Source:
case PlaybackDiagnosticSource.Vhs:
return resolveSourceOrVhsKind(diagnostic);
case PlaybackDiagnosticSource.Native:
return PlaybackSourceKind.Native;
default:
return PlaybackSourceKind.Unknown;
}
}
export function getInlinePlaybackEngineFamily(
sourceKind: PlaybackSourceKind,
target: InlinePlaybackPlayer
): PlaybackEngineFamily | null {
switch (sourceKind) {
case PlaybackSourceKind.Hls:
return target === InlinePlaybackPlayer.VideoJs
? PlaybackEngineFamily.Vhs
: PlaybackEngineFamily.HlsJs;
case PlaybackSourceKind.MpegTs:
return PlaybackEngineFamily.MpegTsJs;
case PlaybackSourceKind.Dash:
return target === InlinePlaybackPlayer.VideoJs
? null
: PlaybackEngineFamily.Shaka;
case PlaybackSourceKind.Native:
return PlaybackEngineFamily.NativeMedia;
case PlaybackSourceKind.Unknown:
default:
return null;
}
}
export function createPlaybackTargetCapabilities(options: {
readonly sourceKind: PlaybackSourceKind;
readonly managedExternalPlayersAvailable: boolean;
}): readonly PlaybackTargetCapability[] {
const inlineTargets = [
InlinePlaybackPlayer.VideoJs,
InlinePlaybackPlayer.Html5,
InlinePlaybackPlayer.ArtPlayer,
] as const;
const inlineCapabilities = inlineTargets.map((target) => {
const engineFamily = getInlinePlaybackEngineFamily(
options.sourceKind,
target
);
return {
kind: 'inline' as const,
target,
available: engineFamily !== null,
engineFamily,
};
});
return [
...inlineCapabilities,
{
kind: 'external',
target: 'mpv',
available: options.managedExternalPlayersAvailable,
},
{
kind: 'external',
target: 'vlc',
available: options.managedExternalPlayersAvailable,
},
];
}
function resolveSourceOrVhsKind(
diagnostic: PlaybackDiagnostic
): PlaybackSourceKind {
const containerKind = resolveContainerKind(diagnostic.container);
const mimeTypeKind = resolveMimeTypeKind(diagnostic.mimeType);
if (
containerKind !== null &&
mimeTypeKind !== null &&
containerKind !== mimeTypeKind
) {
return PlaybackSourceKind.Unknown;
}
return containerKind ?? mimeTypeKind ?? PlaybackSourceKind.Unknown;
}
function resolveContainerKind(container: string): PlaybackSourceKind | null {
if (container === 'm3u' || container === 'm3u8') {
return PlaybackSourceKind.Hls;
}
return container === 'mpd' ? PlaybackSourceKind.Dash : null;
}
function resolveMimeTypeKind(
mimeType: string | undefined
): PlaybackSourceKind | null {
const baseMimeType = mimeType?.split(';', 1)[0]?.trim().toLowerCase() ?? '';
if (HLS_MIME_TYPES.has(baseMimeType)) {
return PlaybackSourceKind.Hls;
}
return baseMimeType === DASH_MIME_TYPE ? PlaybackSourceKind.Dash : null;
}
@@ -0,0 +1,347 @@
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import ts from 'typescript';
const PROJECT_SOURCE_ROOT = join(process.cwd(), 'libs/playback/util/src');
const FORBIDDEN_IMPORT_PREFIXES = [
'@angular/',
'@iptvnator/ui/',
'electron',
'@ngx-pwa/',
] as const;
const FORBIDDEN_GLOBALS = new Set([
'window',
'document',
'localStorage',
'sessionStorage',
'MediaSource',
'MediaError',
'HTMLMediaElement',
'HTMLElement',
'navigator',
'indexedDB',
]);
describe('playback util boundary', () => {
it('keeps production sources independent of UI and browser globals', () => {
const violations = collectProductionSources(
PROJECT_SOURCE_ROOT
).flatMap(findBoundaryViolations);
expect(violations).toEqual([]);
});
it.each([
['import declaration', "import '@angular/core';", '@angular/core'],
[
're-export declaration',
"export * from '@iptvnator/ui/components';",
'@iptvnator/ui/components',
],
[
'import type node',
"type AngularType = import('@angular/core').Type;",
'@angular/core',
],
[
'dynamic import',
"void import('@ngx-pwa/service-worker');",
'@ngx-pwa/service-worker',
],
[
'import-equals declaration',
"import Electron = require('electron'); void Electron;",
'electron',
],
])('rejects forbidden sources in a %s', (_label, source, moduleName) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([expect.stringContaining(moduleName)]);
});
it('allows non-forbidden sources in every supported import form', () => {
const violations = findFixtureViolations(`
import './imported';
export * from './exported';
type LocalType = import('./typed').Type;
void import('./dynamic');
import Local = require('./required');
void Local;
`);
expect(violations).toEqual([]);
});
it.each([
['direct identifier', 'void navigator;', 'navigator'],
['direct storage identifier', 'void indexedDB;', 'indexedDB'],
[
'globalThis property access',
'void globalThis.MediaSource;',
'MediaSource',
],
['self property access', 'void self.indexedDB;', 'indexedDB'],
[
'globalThis element access',
"void globalThis['localStorage'];",
'localStorage',
],
[
'self element access',
"void self['sessionStorage'];",
'sessionStorage',
],
[
'globalThis destructuring',
'const { navigator } = globalThis;',
'navigator',
],
[
'self destructuring',
'const { indexedDB: database } = self; void database;',
'indexedDB',
],
])(
'rejects forbidden browser globals through %s',
(_label, source, name) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([expect.stringContaining(name)]);
}
);
it.each([
['property access', 'void source.navigator;'],
['element access', "void source['indexedDB'];"],
['destructuring', 'const { navigator } = source;'],
[
'property declaration',
'const config = { MediaSource: source }; void config;',
],
['shadowed self', 'const self = source; void self.MediaSource;'],
[
'local forbidden-name binding',
'const navigator = source.navigator; void navigator;',
],
])('allows ordinary local %s names', (_label, source) => {
const violations = findFixtureViolations(source);
expect(violations).toEqual([]);
});
});
function findFixtureViolations(source: string): string[] {
const directory = mkdtempSync(join(tmpdir(), 'playback-util-boundary-'));
const filePath = join(directory, 'fixture.ts');
try {
writeFileSync(filePath, `${source}\nexport {};`);
return findBoundaryViolations(filePath);
} finally {
rmSync(directory, { force: true, recursive: true });
}
}
function collectProductionSources(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) {
return collectProductionSources(path);
}
return entry.isFile() &&
entry.name.endsWith('.ts') &&
!entry.name.endsWith('.spec.ts') &&
!entry.name.endsWith('.test.ts')
? [path]
: [];
});
}
function findBoundaryViolations(filePath: string): string[] {
const program = ts.createProgram({
rootNames: [filePath],
options: {
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts'],
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
noEmit: true,
noResolve: true,
skipLibCheck: true,
target: ts.ScriptTarget.ESNext,
},
});
const sourceFile = program.getSourceFile(filePath);
if (!sourceFile) {
throw new Error(`Unable to parse ${filePath}`);
}
const checker = program.getTypeChecker();
const violations: string[] = [];
sourceFile.forEachChild(function visit(node): void {
const moduleSpecifier = getExternalModuleSpecifier(node);
if (moduleSpecifier && isForbiddenModuleSpecifier(moduleSpecifier)) {
violations.push(formatViolation(sourceFile, node, moduleSpecifier));
}
for (const globalName of getForbiddenGlobalPropertyAccesses(
node,
checker,
sourceFile
)) {
violations.push(formatViolation(sourceFile, node, globalName));
}
if (
ts.isIdentifier(node) &&
FORBIDDEN_GLOBALS.has(node.text) &&
isAmbientIdentifierReference(node, checker, sourceFile)
) {
violations.push(formatViolation(sourceFile, node, node.text));
}
node.forEachChild(visit);
});
return violations;
}
function getExternalModuleSpecifier(node: ts.Node): string | undefined {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier
) {
return getStaticString(node.moduleSpecifier);
}
if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
return getStaticString(node.argument.literal);
}
if (
ts.isCallExpression(node) &&
node.expression.kind === ts.SyntaxKind.ImportKeyword
) {
return getStaticString(node.arguments[0]);
}
if (
ts.isImportEqualsDeclaration(node) &&
ts.isExternalModuleReference(node.moduleReference)
) {
return getStaticString(node.moduleReference.expression);
}
return undefined;
}
function getStaticString(node?: ts.Node): string | undefined {
return node &&
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
? node.text
: undefined;
}
function isForbiddenModuleSpecifier(moduleSpecifier: string): boolean {
return FORBIDDEN_IMPORT_PREFIXES.some((prefix) =>
moduleSpecifier.startsWith(prefix)
);
}
function getForbiddenGlobalPropertyAccesses(
node: ts.Node,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): string[] {
if (
ts.isPropertyAccessExpression(node) &&
isAmbientGlobalRoot(node.expression, checker, sourceFile) &&
FORBIDDEN_GLOBALS.has(node.name.text)
) {
return [node.name.text];
}
if (
ts.isElementAccessExpression(node) &&
isAmbientGlobalRoot(node.expression, checker, sourceFile)
) {
const propertyName = getStaticString(node.argumentExpression);
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
? [propertyName]
: [];
}
if (
ts.isVariableDeclaration(node) &&
ts.isObjectBindingPattern(node.name) &&
node.initializer &&
isAmbientGlobalRoot(node.initializer, checker, sourceFile)
) {
return node.name.elements.flatMap((element) => {
const propertyName = getBindingPropertyName(element);
return propertyName && FORBIDDEN_GLOBALS.has(propertyName)
? [propertyName]
: [];
});
}
return [];
}
function getBindingPropertyName(
element: ts.BindingElement
): string | undefined {
const property = element.propertyName ?? element.name;
return ts.isIdentifier(property) || ts.isStringLiteral(property)
? property.text
: undefined;
}
function isAmbientGlobalRoot(
expression: ts.Expression,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
return (
ts.isIdentifier(expression) &&
(expression.text === 'globalThis' || expression.text === 'self') &&
isAmbientIdentifier(expression, checker, sourceFile)
);
}
function isAmbientIdentifierReference(
identifier: ts.Identifier,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
return (
!isPropertyName(identifier) &&
isAmbientIdentifier(identifier, checker, sourceFile)
);
}
function isAmbientIdentifier(
identifier: ts.Identifier,
checker: ts.TypeChecker,
sourceFile: ts.SourceFile
): boolean {
const symbol = checker.getSymbolAtLocation(identifier);
return !symbol?.declarations?.some(
(declaration) => declaration.getSourceFile() === sourceFile
);
}
function isPropertyName(identifier: ts.Identifier): boolean {
const { parent } = identifier;
return (
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
(ts.isBindingElement(parent) &&
(parent.name === identifier ||
parent.propertyName === identifier)) ||
((ts.isPropertyAssignment(parent) ||
ts.isPropertyDeclaration(parent) ||
ts.isPropertySignature(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isMethodSignature(parent) ||
ts.isEnumMember(parent)) &&
parent.name === identifier)
);
}
function formatViolation(
sourceFile: ts.SourceFile,
node: ts.Node,
detail: string
): string {
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart());
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}: ${detail}`;
}
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "ESNext",
"target": "ESNext",
"esModuleInterop": true,
"types": ["jest", "node"],
"moduleResolution": "bundler"
},
"include": ["src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
}
@@ -79,6 +79,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="embeddedPlayback()?.streamUrl ?? ''"
[title]="inlinePlayerTitle()"
[playback]="embeddedPlayback()"
@@ -112,7 +113,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -138,7 +141,9 @@
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -41,12 +41,14 @@ import {
import {
Channel,
EpgProgram,
ResolvedPortalPlayback,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { Overlay } from '@angular/cdk/overlay';
import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
jest.unstable_mockModule('video.js', () => ({
@@ -113,6 +115,7 @@ class StubAudioPlayerComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
@@ -311,6 +314,7 @@ describe('VideoPlayerComponent', () => {
} as typeof window.electron;
syncStoreState(null);
playlistId.set('playlist-1');
localStorage.removeItem('m3u-sidebar-width');
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
player.set(VideoPlayer.VideoJs);
@@ -535,7 +539,9 @@ describe('VideoPlayerComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(headerContext.action()).toBeNull();
});
@@ -564,12 +570,19 @@ describe('VideoPlayerComponent', () => {
},
} as Channel);
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
isLive: true,
headers: {
'user-agent': 'IPTVnator Test',
Referer: 'https://referrer.example.com',
Origin: 'https://origin.example.com',
},
};
component.handleExternalFallbackRequest({
player: 'mpv',
playback: {
streamUrl: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Archive Sample',
},
playback,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -577,19 +590,18 @@ describe('VideoPlayerComponent', () => {
container: 'm3u8',
audioCodecs: ['ac-3'],
videoCodecs: ['avc1.64001f'],
externalFallbackRecommended: true,
},
} satisfies PlaybackFallbackRequest);
expect(dataServiceMock.sendIpcEvent).toHaveBeenCalledWith(
'OPEN_MPV_PLAYER',
expect.objectContaining({
{
url: 'https://archive.example.com/live.m3u8?utc=1',
title: 'Sample TV',
'user-agent': 'IPTVnator Test',
referer: 'https://referrer.example.com',
origin: 'https://origin.example.com',
})
}
);
});
@@ -640,12 +652,36 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled();
});
it('owns a collision-safe live session key that ignores resolved URL changes', () => {
playlistId.set('playlist|one');
syncStoreState({ ...sampleChannel, id: 'channel|one' } as Channel);
activePlaybackUrl.set('https://archive.example/first.m3u8');
fixture.detectChanges();
const playerView = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist|one',
contentId: 'channel|one',
});
expect(playerView.playbackSessionKey()).toBe(expected);
activePlaybackUrl.set('https://archive.example/second.m3u8');
fixture.detectChanges();
expect(playerView.playbackSessionKey()).toBe(expected);
syncStoreState({ ...sampleChannel, id: 'channel|two' } as Channel);
fixture.detectChanges();
expect(playerView.playbackSessionKey()).not.toBe(expected);
});
it('routes catch-up playback that resolves to a DASH URL inline as well', () => {
syncStoreState(sampleChannel);
activePlaybackUrl.set('http://localhost/archive/replay.mpd');
@@ -657,8 +693,7 @@ describe('VideoPlayerComponent', () => {
By.directive(StubWebPlayerViewComponent)
);
expect(playerView).not.toBeNull();
const stub =
playerView.componentInstance as StubWebPlayerViewComponent;
const stub = playerView.componentInstance as StubWebPlayerViewComponent;
expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player);
});
@@ -675,9 +710,7 @@ describe('VideoPlayerComponent', () => {
// The external-player guard declines DASH channels, so the inline
// player must stay — otherwise the session has no player at all.
expect(
fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
)
fixture.debugElement.query(By.directive(StubWebPlayerViewComponent))
).not.toBeNull();
});
@@ -90,6 +90,7 @@ import {
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { ChannelListLoadingStateComponent } from '@iptvnator/ui/components';
import {
DataService,
@@ -166,13 +167,18 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly activePlaybackUrl = this.store.selectSignal(
selectActivePlaybackUrl
);
readonly activeEpgProgram = this.store.selectSignal(
selectActiveEpgProgram
);
readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram);
readonly activeEpgProgramOrNull = computed(
() => this.activeEpgProgram() ?? null
);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly playbackSessionKey = computed(() => {
const sourceId = this.activePlaylistId();
const contentId = this.activeChannel()?.id;
return sourceId && contentId !== undefined
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
});
readonly channels = this.store.selectSignal(selectChannels);
readonly channelsLoading = this.store.selectSignal(selectChannelsLoading);
readonly archivePlaybackAvailable = computed(() =>
@@ -235,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
private readonly epgChannelLogo = toSignal(
toObservable(this.activeChannel).pipe(
switchMap((channel) => {
const key = channel
? resolveChannelEpgLookupKey(channel)
: '';
const key = channel ? resolveChannelEpgLookupKey(channel) : '';
if (!key) {
return of('');
}
@@ -587,9 +591,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
const currentEpgProgram = epgProgram as
| EpgProgram
| null
| undefined;
EpgProgram | null | undefined;
const currentIndex = channels.findIndex(
(channel) => channel.url === activeChannel.url
);
@@ -383,6 +383,10 @@ describe('UnifiedFavoritesDataService', () => {
'https://example.com/2.m3u8',
'https://example.com/1.m3u8',
]);
expect(items.map((item) => item.uid)).toEqual([
'm3u::m3u-1::https://example.com/2.m3u8',
'm3u::m3u-1::https://example.com/1.m3u8',
]);
expect(items[1].channelId).toBe('channel-1');
expect(items[0].radio).toBe('true');
expect(items[0].m3uChannel).toBe(m3uChannels[1]);
@@ -195,6 +195,7 @@ describe('UnifiedRecentDataService', () => {
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
sourceType: 'm3u',
streamUrl: 'https://example.com/2.m3u8',
channelId: 'channel-2',
@@ -0,0 +1,47 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
describe('createUnifiedLivePlaybackSessionKey', () => {
it('keeps an M3U key across transport changes and separates channels', () => {
const first = m3uItem({
uid: 'm3u::pl-1::https://old.example/live.m3u8',
streamUrl: 'https://old.example/live.m3u8',
channelId: 'provider-channel-7',
});
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'pl-1',
contentId: 'provider-channel-7',
});
expect(createUnifiedLivePlaybackSessionKey(first)).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
uid: 'm3u::pl-1::https://new.example/timeshift.m3u8',
streamUrl: 'https://new.example/timeshift.m3u8',
})
).toBe(expected);
expect(
createUnifiedLivePlaybackSessionKey({
...first,
channelId: 'provider-channel-8',
})
).not.toBe(expected);
});
});
function m3uItem(
overrides: Partial<UnifiedCollectionItem>
): UnifiedCollectionItem {
return {
uid: 'm3u::pl-1::channel',
name: 'Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-1',
playlistName: 'Playlist',
...overrides,
};
}
@@ -0,0 +1,23 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
export function createUnifiedLivePlaybackSessionKey(
item: UnifiedCollectionItem | null
): string {
const sourceId = item?.playlistId.trim() ?? '';
const contentId = item ? getUnifiedLiveContentId(item) : '';
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
}
function getUnifiedLiveContentId(item: UnifiedCollectionItem): string {
switch (item.sourceType) {
case 'm3u':
return item.channelId?.trim() || item.m3uChannel?.id?.trim() || '';
case 'xtream':
return item.xtreamId == null ? '' : String(item.xtreamId);
case 'stalker':
return item.stalkerId == null ? '' : String(item.stalkerId).trim();
}
}
@@ -59,6 +59,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="currentStreamUrl()"
[title]="activeDetail()?.playback?.title ?? ''"
[playback]="inlinePlayback()"
@@ -86,7 +87,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -113,7 +116,9 @@
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -25,10 +25,7 @@ import {
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
@@ -46,6 +43,7 @@ import {
StreamResolverService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { createPlaybackSessionKey as sessionKey } from '@iptvnator/playback/util';
@Directive({
selector: '[appResizable]',
@@ -125,6 +123,7 @@ class StubAudioPlayerComponent {
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input.required<string>();
readonly title = input('');
readonly playback = input<ResolvedPortalPlayback | null>(null);
@@ -586,6 +585,13 @@ describe('UnifiedLiveTabComponent', () => {
expect(webPlayer.streamUrl()).toBe('https://example.com/xtream.m3u8');
expect(webPlayer.title()).toBe('Xtream Live');
const sourceId = item.playlistId;
const contentId = String(item.xtreamId);
const key = sessionKey({ kind: 'live', sourceId, contentId });
expect(webPlayer.playbackSessionKey()).toBe(key);
component.activeItem.set({ ...item, playlistId: 'p2', uid: 'c2' });
expect(component.playbackSessionKey()).not.toBe(key);
component.activeItem.set(item);
const playback = webPlayer.playback();
expect(playback).toEqual(
expect.objectContaining({
@@ -597,15 +603,10 @@ describe('UnifiedLiveTabComponent', () => {
throw new Error('Expected wrapper playback to be set');
}
expect(webPlayer.playerOverride()).toBe(VideoPlayer.VideoJs);
expect(
fixture.nativeElement.querySelector('app-vjs-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-html-video-player')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-art-player')
).toBeNull();
const host = fixture.nativeElement;
expect(host.querySelector('app-vjs-player')).toBeNull();
expect(host.querySelector('app-html-video-player')).toBeNull();
expect(host.querySelector('app-art-player')).toBeNull();
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
@@ -618,16 +619,13 @@ describe('UnifiedLiveTabComponent', () => {
mimeType: '',
videoCodecs: [],
audioCodecs: [],
externalFallbackRecommended: false,
},
});
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/xtream.m3u8',
}),
'mpv'
);
const [forwardedPlayback, forwardedPlayer] =
portalPlayer.openExternalPlayback.mock.calls[0];
expect(forwardedPlayback).toBe(playback);
expect(forwardedPlayer).toBe('mpv');
});
it('does not wait for M3U program lookup before opening playback', async () => {
@@ -749,9 +747,9 @@ describe('UnifiedLiveTabComponent', () => {
// auth-gated streams) before the audio element gets the URL, and
// clears it again on close.
const setUserAgent = jest.fn().mockResolvedValue(true);
(
window.electron as unknown as Record<string, unknown>
)['setUserAgent'] = setUserAgent;
(window.electron as unknown as Record<string, unknown>)[
'setUserAgent'
] = setUserAgent;
const item = {
...buildLiveItem('stalker'),
name: 'Jazz Radio',
@@ -1177,6 +1175,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
const item = await selectXtreamArchiveChannel();
const key = component.playbackSessionKey();
component.onTimelineProgramActivated(timeshiftEvent());
await fixture.whenStable();
@@ -1192,6 +1191,7 @@ describe('UnifiedLiveTabComponent', () => {
'https://example.com/timeshift.m3u8'
);
expect(component.inlinePlayback()?.isLive).toBe(false);
expect(component.playbackSessionKey()).toBe(key);
expect(component.activeTimeshiftProgram()?.title).toBe(
'Xtream Show'
);
@@ -62,6 +62,7 @@ import {
import { ResizableDirective } from '@iptvnator/ui/components';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-session-key';
@Component({
selector: 'app-unified-live-tab',
@@ -123,6 +124,9 @@ export class UnifiedLiveTabComponent {
* portal archive fields (tvArchive/tvArchiveDuration) and to
* supply credentials for Xtream catch-up URL resolution. */
readonly activeItem = signal<UnifiedCollectionItem | null>(null);
readonly playbackSessionKey = computed(() =>
createUnifiedLivePlaybackSessionKey(this.activeItem())
);
readonly isSelecting = signal(false);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
@@ -266,9 +270,7 @@ export class UnifiedLiveTabComponent {
return getLiveEpgPanelSummary(this.activeDetail());
});
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
@@ -480,10 +482,7 @@ export class UnifiedLiveTabComponent {
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(
program.stopTimestamp,
program.stop
);
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
if (startEpoch == null || stopEpoch == null) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
@@ -709,5 +708,4 @@ export class UnifiedLiveTabComponent {
};
});
}
}
@@ -8,6 +8,7 @@
<app-stalker-series-view [providerOnly]="providerOnly()" />
} @else if (vodDetailsItem()) {
<app-vod-details
[playbackSessionKey]="playbackSessionKey()"
[item]="vodDetailsItem()!"
[isFavorite]="isSelectedVodFavorite()"
[playbackPosition]="selectedVodPlaybackPosition()"
@@ -19,9 +19,21 @@ import { EMPTY, of } from 'rxjs';
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { StalkerCatalogDetailComponent } from './stalker-catalog-detail.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly playbackSessionKey = input.required<string>();
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
@@ -47,7 +59,15 @@ class StubStalkerSeriesViewComponent {
describe('StalkerCatalogDetailComponent provider presentation', () => {
let fixture: ComponentFixture<StalkerCatalogDetailComponent>;
const resolveVodPlayback = jest.fn();
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
const contentType = signal<'vod' | 'series'>('vod');
const catalogPlaylist = signal({ id: 'stalker-1' });
const snackBar = { open: jest.fn() };
const selectedItem = signal<unknown>({
id: '42',
cmd: '/media/42',
@@ -61,6 +81,10 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
cmd: '/media/42',
info: { name: 'Portal movie' },
});
resolveVodPlayback.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
catalogPlaylist.set({ id: 'stalker-1' });
snackBar.open.mockReset();
await TestBed.configureTestingModule({
imports: [StalkerCatalogDetailComponent],
@@ -70,8 +94,9 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
useValue: {
contentType,
selectedItem,
playlist: signal({ id: 'stalker-1' }),
playlist: catalogPlaylist,
clearSelectedItem: jest.fn(),
resolveVodPlayback,
},
},
{
@@ -83,7 +108,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
},
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: jest.fn() },
useValue: portalPlayer,
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
@@ -99,7 +124,7 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
},
{ provide: DownloadsService, useValue: {} },
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: {
@@ -139,6 +164,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(true);
expect(child.providerOnly()).toBe(true);
expect(child.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '42',
})
);
});
it('keeps provider-only presentation disabled for a regular VOD open', async () => {
@@ -154,6 +186,13 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
).componentInstance as StubVodDetailsComponent;
expect(fixture.componentInstance.providerOnly()).toBe(false);
expect(child.providerOnly()).toBe(false);
expect(child.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '99',
})
);
});
it.each([
@@ -178,4 +217,106 @@ describe('StalkerCatalogDetailComponent provider presentation', () => {
expect(child.providerOnly()).toBe(true);
}
);
it('does not mount a VOD resolution after the catalog owner changes', async () => {
let resolve!: (value: { streamUrl: string }) => void;
resolveVodPlayback.mockReturnValueOnce(
new Promise((resolvePromise) => {
resolve = resolvePromise;
})
);
fixture.detectChanges();
fixture.componentInstance.onVodPlay({
type: 'stalker',
cmd: '/media/42',
data: selectedItem(),
} as never);
selectedItem.set({
id: '99',
cmd: '/media/99',
info: { name: 'Replacement movie' },
});
fixture.detectChanges();
resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
});
it('clears committed VOD playback when the playlist changes with the same provider id', async () => {
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-2',
contentId: '42',
})
);
});
it('does not mount a pending VOD resolution after only the playlist owner changes', async () => {
const pending = deferred<{ streamUrl: string }>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
});
it('suppresses a pending VOD error after the playlist owner changes', async () => {
const pending = deferred<{ streamUrl: string }>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
catalogPlaylist.set({ id: 'stalker-2' });
fixture.detectChanges();
pending.reject(new Error('stale playlist failure'));
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('preserves committed playback across a same-owner item refresh', async () => {
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.detectChanges();
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
selectedItem.set({
...(selectedItem() as Record<string, unknown>),
screenshot_uri: 'refreshed.jpg',
});
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
});
});
@@ -23,6 +23,7 @@ import {
createRefreshTrigger,
isStalkerSeriesFlag,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
@@ -46,6 +47,7 @@ import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
import { startStalkerVodDownload } from './stalker-vod-download';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-stalker-catalog-detail',
@@ -76,6 +78,8 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly downloadsService = inject(DownloadsService);
private readonly logger = createLogger('StalkerCatalogDetail');
private readonly favoritesRefresh = createRefreshTrigger();
private playbackRequestId = 0;
private currentPlaybackOwnerKey = '';
readonly contentType = this.catalog.contentType;
readonly selectedItem = computed<StalkerSelectedVodItem | null>(
@@ -85,6 +89,16 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly providerOnly = input(false);
readonly playbackSessionKey = computed(() => {
const sourceId = this.catalog.playlist()?.id;
const contentId = normalizeStalkerEntityId(this.selectedItem()?.id);
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.contentType()])
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
@@ -147,8 +161,9 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
});
effect(() => {
const selectedItemId = this.selectedItem()?.id;
void selectedItemId;
const ownerKey = this.playbackOwnerKey();
if (ownerKey === this.currentPlaybackOwnerKey) return;
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
});
@@ -236,6 +251,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
closeInlinePlayer(): void {
this.playbackRequestId += 1;
this.inlinePlayback.set(null);
this.positionWriter.reset();
}
@@ -305,6 +321,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail?: string,
startTime?: number
): Promise<void> {
const requestId = ++this.playbackRequestId;
const sessionKey = this.playbackSessionKey();
const ownerKey = this.playbackOwnerKey();
const usesEmbeddedPlayer = this.portalPlayer.isEmbeddedPlayer();
if (usesEmbeddedPlayer && !sessionKey) return;
try {
const playback = await this.catalog.resolveVodPlayback(
cmd,
@@ -312,9 +334,15 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
thumbnail,
startTime
);
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
if (usesEmbeddedPlayer) {
this.inlinePlayback.set(playback);
return;
}
@@ -322,6 +350,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (
requestId !== this.playbackRequestId ||
this.playbackOwnerKey() !== ownerKey
) {
return;
}
this.logger.error('Failed to start inline VOD playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
@@ -1,5 +1,6 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '@iptvnator/ui/components';
@@ -20,6 +21,7 @@ import {
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-content-hero',
@@ -36,6 +38,7 @@ class StubContentHeroComponent {
template: '',
})
class StubStalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
@@ -89,6 +92,8 @@ describe('StalkerCollectionDetailComponent', () => {
getAllPlaybackPositions: jest.Mock;
clearPlaybackPosition: jest.Mock;
};
let snackBar: { open: jest.Mock };
let playlistsService: { getPlaylistById: jest.Mock };
const playlist = {
_id: 'stalker-1',
@@ -140,6 +145,12 @@ describe('StalkerCollectionDetailComponent', () => {
getAllPlaybackPositions: jest.fn(),
clearPlaybackPosition: jest.fn(),
};
snackBar = { open: jest.fn() };
playlistsService = {
getPlaylistById: jest.fn((playlistId: string) =>
of({ ...playlist, _id: playlistId })
),
};
await TestBed.configureTestingModule({
imports: [StalkerCollectionDetailComponent],
@@ -167,9 +178,7 @@ describe('StalkerCollectionDetailComponent', () => {
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
useValue: snackBar,
},
{
provide: TranslateService,
@@ -185,7 +194,7 @@ describe('StalkerCollectionDetailComponent', () => {
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() => of(playlist)),
getPlaylistById: playlistsService.getPlaylistById,
getPortalFavorites: jest.fn(() => of([])),
},
},
@@ -315,9 +324,7 @@ describe('StalkerCollectionDetailComponent', () => {
).toEqual([1, 2]);
// The snapshot renders immediately, but a background portal
// re-fetch must be triggered so new episodes can appear.
expect(
stalkerStore.refreshEmbeddedSeriesSelection
).toHaveBeenCalled();
expect(stalkerStore.refreshEmbeddedSeriesSelection).toHaveBeenCalled();
});
it('shows newly released episodes when the background snapshot refresh patches the selection', async () => {
@@ -393,6 +400,131 @@ describe('StalkerCollectionDetailComponent', () => {
expect(stalkerStore.createLinkToPlayVod).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.componentInstance.inlinePlayback()).toEqual(playback);
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
expect(detail.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '1701',
})
);
});
it('ignores a pending payload after the collection detail owner changes and lets the new owner commit', async () => {
const pendingA = deferred<ResolvedPortalPlayback>();
const pendingB = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockImplementation((cmd: string) =>
cmd.includes('1701') ? pendingA.promise : pendingB.promise
);
const itemA = buildCollectionItem({
stalkerId: '1701',
stalkerItem: buildVodSource('1701', 'Movie A'),
});
const itemB = buildCollectionItem({
stalkerId: '1702',
stalkerItem: buildVodSource('1702', 'Movie B'),
});
fixture.componentRef.setInput('item', itemA);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', itemB);
await settleDetail(fixture);
pendingA.resolve({ streamUrl: 'https://streams.test/a.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-1',
contentId: '1702',
})
);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemB.stalkerItem as never, itemB.playlistId)
);
pendingB.resolve({ streamUrl: 'https://streams.test/b.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/b.mp4',
});
});
it('suppresses a pending error after the collection playlist owner changes', async () => {
const pending = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
const source = buildVodSource('1701', 'Movie A');
const itemA = buildCollectionItem({
playlistId: 'stalker-1',
stalkerId: '1701',
stalkerItem: source,
});
const itemB = buildCollectionItem({
playlistId: 'stalker-2',
stalkerId: '1701',
stalkerItem: { ...source },
});
fixture.componentRef.setInput('item', itemA);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(itemA.stalkerItem as never, itemA.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', itemB);
await settleDetail(fixture);
pending.reject(new Error('stale A failure'));
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'stalker-2',
contentId: '1701',
})
);
});
it('keeps a pending request when the same canonical owner is refreshed with a new object', async () => {
const pending = deferred<ResolvedPortalPlayback>();
stalkerStore.resolveVodPlayback.mockReturnValue(pending.promise);
const item = buildCollectionItem({
stalkerId: '1701',
stalkerItem: buildVodSource('1701', 'Movie A'),
});
fixture.componentRef.setInput('item', item);
await settleDetail(fixture);
fixture.componentInstance.onVodPlay(
createStalkerVodItem(item.stalkerItem as never, item.playlistId)
);
await Promise.resolve();
fixture.componentRef.setInput('item', {
...item,
stalkerItem: { ...(item.stalkerItem as object) },
});
await settleDetail(fixture);
pending.resolve({ streamUrl: 'https://streams.test/a.mp4' });
await settleDetail(fixture);
expect(fixture.componentInstance.inlinePlayback()).toEqual({
streamUrl: 'https://streams.test/a.mp4',
});
expect(snackBar.open).not.toHaveBeenCalled();
});
it('does not load VOD playback position when the playlist id is missing', async () => {
@@ -444,6 +576,26 @@ function buildCollectionItem(
};
}
function buildVodSource(id: string, title: string) {
return {
id,
title,
category_id: 'vod',
cmd: `/media/file_${id}.mpg`,
info: { name: title },
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
async function settleDetail(
fixture: ComponentFixture<StalkerCollectionDetailComponent>
): Promise<void> {
@@ -48,6 +48,7 @@ import {
import { firstValueFrom } from 'rxjs';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
import { StalkerVodPlaybackController } from './stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
interface StalkerCollectionStateSnapshot {
currentPlaylist: Playlist | undefined;
@@ -65,12 +66,19 @@ interface StalkerCollectionDetailMode {
needsSeriesFetch: boolean;
}
interface StalkerCollectionPlaybackOwner {
readonly sourceId: string;
readonly contentId: string;
readonly sessionKey: string;
}
@Component({
selector: 'app-stalker-collection-detail',
imports: [PortalDetailShellComponent, StalkerInlineDetailComponent],
template: `
@if (inlineDetail().categoryId) {
<app-stalker-inline-detail
[playbackSessionKey]="playbackSessionKey()"
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
@@ -124,6 +132,12 @@ export class StalkerCollectionDetailComponent {
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly playbackOwner = computed(() =>
captureStalkerCollectionPlaybackOwner(this.item())
);
readonly playbackSessionKey = computed(
() => this.playbackOwner()?.sessionKey ?? ''
);
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
@@ -149,6 +163,7 @@ export class StalkerCollectionDetailComponent {
);
private initRequestId = 0;
private currentPlaybackOwnerKey = '';
private readonly vodPlayback = new StalkerVodPlaybackController({
inlinePlayback: this.inlinePlayback,
selectedVodPosition: this.selectedVodPosition,
@@ -158,6 +173,7 @@ export class StalkerCollectionDetailComponent {
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start collection VOD playback',
playbackOwnerKey: () => this.playbackSessionKey(),
});
constructor() {
@@ -168,7 +184,12 @@ export class StalkerCollectionDetailComponent {
effect(() => {
const item = this.item();
const playbackOwnerKey = this.playbackSessionKey();
untracked(() => {
if (playbackOwnerKey !== this.currentPlaybackOwnerKey) {
this.currentPlaybackOwnerKey = playbackOwnerKey;
this.closeInlinePlayer();
}
void this.prepareDetail(item);
});
});
@@ -490,3 +511,31 @@ export class StalkerCollectionDetailComponent {
await this.vodPlayback.loadSelectedVodPosition(playlistId, vodId);
}
}
function captureStalkerCollectionPlaybackOwner(
item: UnifiedCollectionItem | null
): StalkerCollectionPlaybackOwner | null {
if (!item) return null;
const sourceId = item.playlistId.trim();
const providerItem = item.stalkerItem as
{ id?: unknown; stream_id?: unknown } | undefined;
const uidParts = item.uid.split('::');
const contentId = normalizeStalkerEntityId(
providerItem?.id ??
providerItem?.stream_id ??
item.stalkerId ??
uidParts[uidParts.length - 1]
);
if (!sourceId || !contentId) return null;
return Object.freeze({
sourceId,
contentId,
sessionKey: createPlaybackSessionKey({
kind: 'vod',
sourceId,
contentId,
}),
});
}
@@ -15,6 +15,7 @@
} @else if (vodDetailsItem(); as item) {
<!-- Regular VOD item -->
<app-vod-details
[playbackSessionKey]="playbackSessionKey()"
[item]="item"
[isFavorite]="isFavorite()"
[playbackPosition]="playbackPosition()"
@@ -8,6 +8,7 @@ import { StalkerInlineDetailComponent } from './stalker-inline-detail.component'
@Component({ selector: 'app-vod-details', template: '' })
class StubVodDetailsComponent {
readonly playbackSessionKey = input.required<string>();
readonly item = input.required<unknown>();
readonly providerOnly = input(false);
readonly isFavorite = input(false);
@@ -57,6 +58,10 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
.compileComponents();
fixture = TestBed.createComponent(StalkerInlineDetailComponent);
fixture.componentRef.setInput('providerOnly', true);
fixture.componentRef.setInput(
'playbackSessionKey',
'collection-owned-vod-key'
);
});
afterEach(() => fixture.destroy());
@@ -70,6 +75,7 @@ describe('StalkerInlineDetailComponent provider presentation', () => {
By.directive(StubVodDetailsComponent)
).componentInstance as StubVodDetailsComponent;
expect(child.providerOnly()).toBe(true);
expect(child.playbackSessionKey()).toBe('collection-owned-vod-key');
});
it.each([
@@ -32,6 +32,7 @@ import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-serie
],
})
export class StalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<StalkerSelectedVodItem | null>(null);
readonly isSeries = input<boolean>(false);
@@ -32,7 +32,9 @@
mat-icon-button
type="button"
[disabled]="isFullListLoading()"
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
[attr.aria-label]="
'PORTALS.REFRESH_CHANNEL_LIST' | translate
"
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
(click)="refreshChannels()"
>
@@ -83,7 +85,9 @@
<div class="empty-search-state">
<app-portal-empty-state
icon="tv_off"
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
[message]="
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
"
/>
</div>
} @else {
@@ -120,7 +124,9 @@
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
(contextMenuRequested)="
onChannelContextMenu(item, $event)
"
/>
}
}
@@ -180,6 +186,7 @@
/>
} @else {
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl()"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
@@ -17,11 +17,11 @@ import {
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import {
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
AudioPlayerComponent,
type PlaybackFallbackRequest,
} from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
import { MockPipe } from 'ng-mocks';
@@ -31,7 +31,10 @@ import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import {
EpgProgram,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { MatDialog } from '@angular/material/dialog';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
@@ -66,10 +69,11 @@ class StubChannelListItemComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
@Component({
@@ -202,9 +206,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
loaded: number;
total: number;
} | null>(null);
const itvFullChannelList = signal<
ReturnType<typeof defaultItvChannels>
>([]);
const itvFullChannelList = signal<ReturnType<typeof defaultItvChannels>>(
[]
);
const itvSelectedCategoryFromCache = signal(false);
const isPaginatedContentLoading = signal(false);
@@ -266,6 +270,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const portalPlayer = {
isEmbeddedPlayer: jest.fn(() => true),
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
@@ -277,6 +282,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
// The store mock is module-scoped: reset so a failed test can't leak
// 'list' into siblings.
settingsStore.resolvedEpgViewMode.set('timeline');
playlist.set({ _id: 'playlist-1', title: 'Demo Stalker' });
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
@@ -327,6 +333,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
portalPlayer.isEmbeddedPlayer.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
portalPlayer.openResolvedPlayback.mockClear();
portalPlayer.openExternalPlayback.mockClear();
fetchChannelEpg.mockReset();
fetchChannelEpg.mockResolvedValue([]);
ensureBulkItvEpg.mockReset();
@@ -407,9 +414,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest
.fn()
.mockResolvedValue(null),
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
},
},
],
@@ -503,7 +508,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
const channelRows = fixture.debugElement.queryAll(
By.directive(StubChannelListItemComponent)
);
@@ -526,6 +533,43 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toBe(true);
});
it('forwards the exact resolved live playback to external fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/stalker-fallback.m3u8',
title: 'Alpha TV',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 10001,
contentType: 'live',
},
};
const request: PlaybackFallbackRequest = {
player: 'vlc',
playback,
diagnostic: {
code: 'network-error',
player: 'html5',
source: 'hls',
container: '',
mimeType: '',
videoCodecs: [],
audioCodecs: [],
},
};
component.handleExternalFallbackRequest(request);
expect(portalPlayer.openExternalPlayback).toHaveBeenCalledWith(
playback,
'vlc'
);
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
playback
);
});
it('renders radio channel rows with compact no-EPG density', () => {
stalkerStore.selectedContentType.set('radio');
@@ -1115,7 +1159,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
@@ -1185,14 +1231,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
);
// Once: only the apply call gets the pending promise — the destroy
// clear below also calls the bridge and must not steal the resolver.
(
window.electron?.setUserAgent as jest.Mock
).mockImplementationOnce(() => {
signalApplyIssued();
return new Promise<boolean>((resolve) => {
resolveApply = () => resolve(true);
});
});
(window.electron?.setUserAgent as jest.Mock).mockImplementationOnce(
() => {
signalApplyIssued();
return new Promise<boolean>((resolve) => {
resolveApply = () => resolve(true);
});
}
);
resolveRadioPlayback.mockResolvedValue({
streamUrl: 'http://portal.example/radio_2.mpg',
title: 'Portal FM',
@@ -76,12 +76,24 @@ import {
normalizeStalkerEntityId,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerItvAllItemsComponent } from './stalker-itv-all-items.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type StalkerPlayableChannel = StalkerPortalItem & {
cmd?: string;
has_files?: unknown;
};
interface StalkerActiveLivePlaybackIdentity {
readonly sourceId: string;
readonly contentId: string;
}
interface StalkerPlaybackResolutionOwner {
readonly sourceId: string;
readonly contentType: string;
readonly channelId: string;
}
/** Channels rendered per "page" when the full list is served from the cache. */
const FULL_LIST_RENDER_CHUNK = 100;
@@ -270,6 +282,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.portalPlayer.isEmbeddedPlayer()
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activePlaybackIdentity =
signal<StalkerActiveLivePlaybackIdentity | null>(null);
readonly playbackSessionKey = computed(() => {
const identity = this.activePlaybackIdentity();
return identity
? createPlaybackSessionKey({ kind: 'live', ...identity })
: '';
});
readonly streamUrl = computed(() => this.activePlayback()?.streamUrl ?? '');
readonly activePlaybackTitle = computed(
() =>
@@ -366,7 +386,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
/** Stream URL of the radio playback whose header override this layout configured. */
private radioHeaderScopeUrl: string | null = null;
private playbackResolution: {
channelId: string;
ownerKey: string;
promise: Promise<ResolvedPortalPlayback>;
} | null = null;
private lastPlaylistId: string | null | undefined = undefined;
@@ -414,9 +434,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
untracked(() => {
if (contentType === 'radio') {
this.stalkerStore.setRadioChannels([]);
} else if (
!this.stalkerStore.itvSelectedCategoryFromCache()
) {
} else if (!this.stalkerStore.itvSelectedCategoryFromCache()) {
this.stalkerStore.setItvChannels([]);
}
this.stalkerStore.setPage(0);
@@ -449,8 +467,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// store dedupes per channel id, so this is cheap on rerenders.
if (this.supportsEpgMapping && channels.length > 0) {
const channelIds = channels.map((channel) => channel.id);
untracked(() =>
void this.stalkerStore.applyMappedItvEpg(channelIds)
untracked(
() => void this.stalkerStore.applyMappedItvEpg(channelIds)
);
}
});
@@ -567,6 +585,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// override slot.
this.playbackRequestId += 1;
this.streamHeaders.clear(this.radioHeaderScopeUrl);
this.clearActivePlayback();
}
isSelectedChannel(item: StalkerItvChannel): boolean {
@@ -581,6 +600,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
) {
const requestId = ++this.playbackRequestId;
const channelId = normalizeStalkerEntityId(item.id);
const sourceId = normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id
);
const contentType = this.stalkerStore.selectedContentType();
const isRadioMode = this.isRadioMode();
this.stalkerStore.setSelectedItem(item);
this.ensureChannelWithinRenderWindow(channelId);
// A previously owned radio override must not survive into a
@@ -591,15 +615,13 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
this.radioHeaderScopeUrl = null;
try {
const isRadioMode = this.isRadioMode();
const playback = await this.resolvePlaybackForChannel(
item,
channelId
{ sourceId, contentType, channelId },
isRadioMode
);
if (
requestId !== this.playbackRequestId ||
this.selectedChannelId() !== channelId
) {
const owner = { sourceId, contentType, channelId };
if (!this.isPlaybackRequestCurrent(requestId, owner)) {
return;
}
@@ -618,12 +640,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
const stillCurrent = headerSync ? await headerSync : true;
if (
!stillCurrent ||
requestId !== this.playbackRequestId ||
this.selectedChannelId() !== channelId
!this.isPlaybackRequestCurrent(requestId, owner)
) {
return;
}
this.activePlayback.set(playback);
this.setActivePlayback(playback, null);
return;
}
@@ -632,12 +653,22 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
if (this.usesEmbeddedPlayer()) {
this.activePlayback.set(playback);
if (!sourceId || !channelId) return;
this.setActivePlayback(playback, {
sourceId,
contentId: channelId,
});
} else if (startPlayback) {
void this.portalPlayer.openResolvedPlayback(playback, true);
}
} catch (error) {
if (requestId !== this.playbackRequestId) {
if (
!this.isPlaybackRequestCurrent(requestId, {
sourceId,
contentType,
channelId,
})
) {
return;
}
@@ -652,21 +683,19 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private resolvePlaybackForChannel(
item: StalkerItvChannel,
channelId: string
owner: StalkerPlaybackResolutionOwner,
isRadioMode: boolean
): Promise<ResolvedPortalPlayback> {
const playbackChannelId = [
this.stalkerStore.selectedContentType(),
channelId,
].join(':');
if (this.playbackResolution?.channelId === playbackChannelId) {
const ownerKey = JSON.stringify(owner);
if (this.playbackResolution?.ownerKey === ownerKey) {
return this.playbackResolution.promise;
}
const playableItem = this.toPlayableChannel(item);
const promise = this.isRadioMode()
const promise = isRadioMode
? this.stalkerStore.resolveRadioPlayback(playableItem)
: this.stalkerStore.resolveItvPlayback(playableItem);
this.playbackResolution = { channelId: playbackChannelId, promise };
this.playbackResolution = { ownerKey, promise };
const cleanup = () => {
if (this.playbackResolution?.promise === promise) {
@@ -679,6 +708,33 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
return promise;
}
private isPlaybackRequestCurrent(
requestId: number,
owner: StalkerPlaybackResolutionOwner
): boolean {
return (
requestId === this.playbackRequestId &&
this.selectedChannelId() === owner.channelId &&
normalizeStalkerEntityId(
this.stalkerStore.currentPlaylist()?._id
) === owner.sourceId &&
this.stalkerStore.selectedContentType() === owner.contentType
);
}
private setActivePlayback(
playback: ResolvedPortalPlayback,
identity: StalkerActiveLivePlaybackIdentity | null
): void {
this.activePlaybackIdentity.set(identity);
this.activePlayback.set(playback);
}
private clearActivePlayback(): void {
this.activePlaybackIdentity.set(null);
this.activePlayback.set(null);
}
toggleFavorite(item: StalkerItvChannel) {
const itemId = normalizeStalkerEntityId(item.id);
if (this.favorites.has(itemId)) {
@@ -0,0 +1,294 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { ElectronStreamHeadersService } from '@iptvnator/ui/playback';
import { StalkerLiveStreamLayoutComponent } from './stalker-live-stream-layout.component';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
let fixture: ComponentFixture<StalkerLiveStreamLayoutComponent>;
let component: StalkerLiveStreamLayoutComponent;
const playlist = signal({ _id: 'playlist-one', title: 'Portal One' });
const channels = [
{
id: 'channel-one',
cmd: 'ffrt4://itv/channel-one',
name: 'One',
o_name: 'One',
logo: 'one.png',
},
{
id: 'channel-two',
cmd: 'ffrt4://itv/channel-two',
name: 'Two',
o_name: 'Two',
logo: 'two.png',
},
];
const itvChannels = signal(channels);
const selectedItvId = signal<string | undefined>(channels[0].id);
const selectedItem = signal<(typeof channels)[number] | null>(channels[0]);
const selectedContentType = signal<'itv' | 'radio'>('itv');
const resolveItvPlayback = jest.fn();
const snackBar = { open: jest.fn() };
const store = {
getSelectedCategoryName: signal('All'),
currentPlaylist: playlist,
selectedContentType,
selectedCategoryId: signal<string | null>('all'),
selectedItvId,
selectedItem,
itvChannels,
radioChannels: signal([]),
searchPhrase: signal(''),
hasMoreChannels: signal(false),
itvFullListActive: signal(false),
itvSelectedCategoryFromCache: signal(false),
itvFullListLoading: signal(false),
itvFullListProgress: signal(null),
itvFullChannelList: signal([]),
isPaginatedContentLoading: signal(false),
selectedItvEpgPrograms: signal([]),
bulkItvEpgByChannel: signal({}),
isLoadingBulkItvEpg: signal(false),
setItvChannels: jest.fn(),
setRadioChannels: jest.fn(),
setPage: jest.fn(),
preloadItvChannels: jest.fn(),
applyMappedItvEpg: jest.fn(),
clearBulkItvEpgCache: jest.fn(),
ensureBulkItvEpg: jest.fn(),
fetchChannelEpg: jest.fn(),
resolveItvPlayback,
resolveRadioPlayback: jest.fn(),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
setSelectedItem: jest.fn((item: (typeof channels)[number]) => {
selectedItem.set(item);
selectedItvId.set(String(item.id));
}),
};
beforeEach(async () => {
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
selectedContentType.set('itv');
selectedItvId.set(channels[0].id);
selectedItem.set(channels[0]);
resolveItvPlayback.mockReset();
snackBar.open.mockReset();
store.setSelectedItem.mockClear();
await TestBed.configureTestingModule({
imports: [StalkerLiveStreamLayoutComponent],
providers: [
{ provide: StalkerStore, useValue: store },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsEpgMapping: false,
},
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: SettingsStore,
useValue: { openStreamOnDoubleClick: signal(false) },
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
},
},
{
provide: ElectronStreamHeadersService,
useValue: { apply: jest.fn(), clear: jest.fn() },
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
})
.overrideComponent(StalkerLiveStreamLayoutComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
afterEach(() => fixture.destroy());
it('changes identity only when the current embedded playback commits', async () => {
const firstPlayback = { streamUrl: 'https://one.example/live.m3u8' };
resolveItvPlayback.mockResolvedValueOnce(firstPlayback);
await component.playChannel(channels[0]);
const firstKey = component.playbackSessionKey();
const session = () => [
component.activePlayback(),
component.playbackSessionKey(),
];
expect(session()).toEqual([firstPlayback, firstKey]);
const failed = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValueOnce(failed.promise);
const failedSelection = component.playChannel(channels[1]);
failed.reject(new Error('resolution failed'));
await failedSelection;
expect(session()).toEqual([firstPlayback, firstKey]);
const stale = deferred<ResolvedPortalPlayback>();
const current = deferred<ResolvedPortalPlayback>();
resolveItvPlayback
.mockReturnValueOnce(stale.promise)
.mockReturnValueOnce(current.promise);
const staleSelection = component.playChannel(channels[0]);
const currentSelection = component.playChannel(channels[1]);
const currentPlayback = {
streamUrl: 'https://two.example/live.m3u8',
};
current.resolve(currentPlayback);
stale.resolve({ streamUrl: 'https://stale.example/live.m3u8' });
await Promise.all([staleSelection, currentSelection]);
expect(session()).toEqual([
currentPlayback,
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-one',
contentId: 'channel-two',
}),
]);
});
it('does not dedupe the same channel id across playlist owners', async () => {
const sourceA = deferred<ResolvedPortalPlayback>();
const sourceB = deferred<ResolvedPortalPlayback>();
resolveItvPlayback
.mockReturnValueOnce(sourceA.promise)
.mockReturnValueOnce(sourceB.promise);
const shared = { ...channels[0], id: 'shared-channel' };
playlist.set({ _id: 'playlist-a', title: 'Portal A' });
const requestA = component.playChannel(shared);
playlist.set({ _id: 'playlist-b', title: 'Portal B' });
const requestB = component.playChannel(shared);
const playbackB = { streamUrl: 'https://b.example/shared.m3u8' };
sourceB.resolve(playbackB);
sourceA.resolve({ streamUrl: 'https://a.example/shared.m3u8' });
await Promise.all([requestA, requestB]);
expect(resolveItvPlayback).toHaveBeenCalledTimes(2);
expect(component.activePlayback()).toBe(playbackB);
expect(component.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-b',
contentId: 'shared-channel',
})
);
});
it('preserves the committed pair when source or channel identity is absent', async () => {
const valid = { streamUrl: 'https://valid.example/live.m3u8' };
resolveItvPlayback.mockResolvedValueOnce(valid);
await component.playChannel(channels[0]);
const key = component.playbackSessionKey();
playlist.set({ _id: '', title: 'Missing source' });
resolveItvPlayback.mockResolvedValueOnce({
streamUrl: 'https://invalid.example/source.m3u8',
});
await component.playChannel(channels[1]);
expect([
component.activePlayback(),
component.playbackSessionKey(),
]).toEqual([valid, key]);
playlist.set({ _id: 'playlist-one', title: 'Portal One' });
resolveItvPlayback.mockResolvedValueOnce({
streamUrl: 'https://invalid.example/channel.m3u8',
});
await component.playChannel({ ...channels[1], id: '' });
expect([
component.activePlayback(),
component.playbackSessionKey(),
]).toEqual([valid, key]);
});
it('does not commit a pending ITV request after the mode changes to radio with the same channel id', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValue(pending.promise);
const request = component.playChannel(channels[0]);
selectedContentType.set('radio');
pending.resolve({ streamUrl: 'https://stale.example/itv.m3u8' });
await request;
expect(component.activePlayback()).toBeNull();
expect(component.playbackSessionKey()).toBe('');
});
it('suppresses a stale rejection after playlist, mode, and channel ownership change', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveItvPlayback.mockReturnValue(pending.promise);
const request = component.playChannel(channels[0]);
playlist.set({ _id: 'playlist-two', title: 'Portal Two' });
selectedContentType.set('radio');
selectedItvId.set(channels[1].id);
pending.reject(new Error('stale owner failure'));
await request;
expect(snackBar.open).not.toHaveBeenCalled();
});
it('still reports an error for the current full owner', async () => {
resolveItvPlayback.mockRejectedValueOnce(new Error('current failure'));
await component.playChannel(channels[0]);
expect(snackBar.open).toHaveBeenCalledWith(
'PORTALS.PLAYBACK_ERROR',
undefined,
{ duration: 3000 }
);
});
});
@@ -12,6 +12,7 @@
@if (showingDetails) {
<app-stalker-inline-detail
details
[playbackSessionKey]="playbackSessionKey()"
[categoryId]="inlineDetail().categoryId"
[seriesItem]="inlineDetail().seriesItem"
[isSeries]="inlineDetail().isSeries"
@@ -0,0 +1,273 @@
import { Location } from '@angular/common';
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerPortalRepairService,
StalkerSessionService,
StalkerStore,
} from '@iptvnator/portal/stalker/data-access';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import { DataService, PlaylistsService } from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSearchComponent } from './stalker-search.component';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, reject, resolve };
}
@Component({
selector: 'app-stalker-inline-detail',
standalone: true,
template: '',
})
class StubStalkerInlineDetailComponent {
readonly playbackSessionKey = input.required<string>();
readonly inlinePlayback = input<unknown>(null);
readonly inlinePlaybackClosed = output<void>();
}
describe('StalkerSearchComponent playback session key', () => {
let fixture: ComponentFixture<StalkerSearchComponent>;
const playlist = signal({
_id: 'playlist|one',
title: 'Search portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
const selectedItem = signal<unknown>(null);
const resolveVodPlayback = jest.fn();
const snackBar = { open: jest.fn() };
beforeEach(async () => {
playlist.set({
_id: 'playlist|one',
title: 'Search portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
selectedItem.set(null);
resolveVodPlayback.mockReset();
snackBar.open.mockReset();
await TestBed.configureTestingModule({
imports: [StalkerSearchComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
queryParamMap: of(convertToParamMap({})),
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
routeConfig: { path: 'search' },
},
},
},
{ provide: Location, useValue: { back: jest.fn() } },
{ provide: DataService, useValue: {} },
{
provide: PlaylistContextFacade,
useValue: { activePlaylist: playlist },
},
{
provide: PlaylistsService,
useValue: { getPortalFavorites: () => of([]) },
},
{
provide: StalkerStore,
useValue: {
selectedItem,
setSelectedContentType: jest.fn(),
setSelectedItem: jest.fn((item) =>
selectedItem.set(item)
),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
resolveVodPlayback,
},
},
{ provide: StalkerSessionService, useValue: {} },
{ provide: StalkerPortalRepairService, useValue: {} },
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
openExternalPlayback: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: snackBar },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
],
})
.overrideComponent(StalkerSearchComponent, {
set: {
imports: [StubStalkerInlineDetailComponent],
template: `
@if (showingDetails) {
<app-stalker-inline-detail
[playbackSessionKey]="playbackSessionKey()"
[inlinePlayback]="inlinePlayback()"
(inlinePlaybackClosed)="closeInlinePlayer()"
/>
}
`,
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSearchComponent);
});
afterEach(() => fixture?.destroy());
it('owns the original item identity and threads it unchanged through payload replacement', () => {
const first = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
fixture.componentInstance.selectItem(first);
fixture.detectChanges();
const expected = createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist|one',
contentId: 'movie|one',
});
const detail = fixture.debugElement.query(
By.directive(StubStalkerInlineDetailComponent)
).componentInstance as StubStalkerInlineDetailComponent;
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
expect(detail.playbackSessionKey()).toBe(expected);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replaced.mkv',
title: 'Alternative movie',
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 999,
contentType: 'vod',
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
fixture.componentInstance.selectItem({
...first,
id: 'movie|two',
});
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
expected
);
const itemChangedKey = fixture.componentInstance.playbackSessionKey();
playlist.set({ ...playlist(), _id: 'playlist|two' });
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
itemChangedKey
);
});
it('clears committed playback when the route playlist owner changes for the same provider id', async () => {
const item = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
resolveVodPlayback.mockResolvedValue({
streamUrl: 'https://a.example/movie.mpg',
});
fixture.componentInstance.selectItem(item);
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
playlist.set({ ...playlist(), _id: 'playlist|two' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist|two',
contentId: 'movie|one',
})
);
});
it('suppresses a pending old-playlist error for the same provider id', async () => {
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValue(pending.promise);
fixture.componentInstance.selectItem({
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
});
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await Promise.resolve();
playlist.set({ ...playlist(), _id: 'playlist|two' });
fixture.detectChanges();
pending.reject(new Error('stale playlist failure'));
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(snackBar.open).not.toHaveBeenCalled();
});
it('preserves committed playback when the same canonical owner object refreshes', async () => {
const item = {
id: 'movie|one',
cmd: 'ffrt4://movie/one',
name: 'Movie One',
info: { name: 'Movie One' },
};
const playback = { streamUrl: 'https://a.example/movie.mpg' };
resolveVodPlayback.mockResolvedValue(playback);
fixture.componentInstance.selectItem(item);
fixture.componentInstance.onVodPlay(
fixture.componentInstance.vodDetailsItem()!
);
await fixture.whenStable();
selectedItem.set({ ...item, screenshot_uri: 'refreshed.jpg' });
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
});
});
@@ -6,6 +6,7 @@ import {
inject,
resource,
signal,
untracked,
} from '@angular/core';
import { Location } from '@angular/common';
import { FormsModule } from '@angular/forms';
@@ -57,6 +58,7 @@ import {
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
interface StalkerFilter {
key: StalkerSearchContentType;
@@ -103,6 +105,7 @@ export class StalkerSearchComponent {
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('StalkerSearch');
private currentPlaybackOwnerKey = '';
readonly filters = signal<Record<StalkerSearchContentType, boolean>>({
series: false,
@@ -141,6 +144,16 @@ export class StalkerSearchComponent {
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly playbackSessionKey = computed(() => {
const sourceId = this.currentPlaylist()?._id;
const contentId = normalizeStalkerEntityId(this.itemDetails()?.id);
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
private readonly playbackOwnerKey = computed(() =>
JSON.stringify([this.playbackSessionKey(), this.selectedFilterType()])
);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackPosition = computed<number | null>(
() => this.selectedVodPosition()?.positionSeconds ?? null
@@ -154,6 +167,7 @@ export class StalkerSearchComponent {
translateService: this.translateService,
logger: this.logger,
playbackErrorLogMessage: 'Failed to start search VOD playback',
playbackOwnerKey: () => this.playbackOwnerKey(),
});
readonly portalFavorites = createPortalFavoritesResource(
@@ -197,16 +211,15 @@ export class StalkerSearchComponent {
// executeStalkerRequest owns the portal-mode decision (shared
// predicate with URL fallback for legacy rows) and the lazy
// portal repair, so search cannot drift from the catalog paths.
const response =
await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const response = await executeStalkerRequest<StalkerSearchResponse>(
{
dataService: this.dataService,
stalkerSession: this.stalkerSession,
portalRepair: this.portalRepair,
},
playlist,
requestParams
);
const items = response.js?.data || [];
return items.map((item: StalkerVodSource) =>
this.processItemUrls(item, portalUrl)
@@ -217,6 +230,12 @@ export class StalkerSearchComponent {
readonly isSelectedVodFavorite = signal<boolean>(false);
constructor() {
this.currentPlaybackOwnerKey = this.playbackOwnerKey();
effect(() => {
const ownerKey = this.playbackOwnerKey();
untracked(() => this.syncPlaybackOwner(ownerKey));
});
effect(() => {
const routeTerm = this.routeSearchTerm();
if (routeTerm !== this.searchTerm()) {
@@ -293,7 +312,6 @@ export class StalkerSearchComponent {
}
selectItem(item: StalkerVodSource) {
this.closeInlinePlayer();
const filterType = this.selectedFilterType();
const hasEmbeddedSeries = (item.series?.length ?? 0) > 0;
const needsSeriesFetch =
@@ -338,6 +356,7 @@ export class StalkerSearchComponent {
default:
break;
}
this.syncPlaybackOwner(this.playbackOwnerKey());
}
onVodPlay(item: VodDetailsItem): void {
@@ -430,6 +449,12 @@ export class StalkerSearchComponent {
);
}
private syncPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentPlaybackOwnerKey) return;
this.currentPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
inlineDetail() {
return createStalkerInlineDetailState(
this.itemDetails(),
@@ -0,0 +1,278 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { StalkerMappedEpisode } from '@iptvnator/portal/stalker/data-access';
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
import {
captureStalkerEpisodePlaybackSessionIdentity,
createStalkerEpisodePlaybackSessionKey,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
} from './stalker-episode-playback-session-key';
const episodeState = (
episode: Partial<StalkerMappedEpisode>,
seasonKey = 'season|1'
) => ({
seasonKey,
seasonNumber: 1,
episodeNumber: 2,
episode: {
id: 'synthesized-32-bit-id',
title: 'Episode 2',
episode_num: 2,
season: 1,
...episode,
} as StalkerMappedEpisode,
previous: null,
next: null,
});
describe('createStalkerEpisodePlaybackSessionKey', () => {
it.each([
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
])(
'keeps %s provider credentials out of the structural key',
(seriesMode) => {
const originalCmdA =
'https://user:password@stream.example/episode.mpg?access_token=secret-a';
const originalCmdB =
'https://user:password@stream.example/episode.mpg?access_token=secret-b';
const stateA = episodeState({ originalCmd: originalCmdA });
const stateB = episodeState({ originalCmd: originalCmdB });
const composite = JSON.stringify([
seriesMode,
'parent|series',
stateA.seasonKey,
stateA.seasonNumber,
stateA.episodeNumber,
]);
const keyA = createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist|source',
parentSeriesId: 'parent|series',
seriesMode,
episodeState: stateA,
});
const keyB = createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist|source',
parentSeriesId: 'parent|series',
seriesMode,
episodeState: stateB,
});
expect(keyA).toBe(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'playlist|source',
contentId: composite,
seriesId: 'parent|series',
seasonNumber: 1,
episodeNumber: 2,
})
);
expect(keyB).toBe(keyA);
expect(keyA).not.toContain(originalCmdA);
expect(keyA).not.toContain('password');
expect(keyA).not.toContain('secret-a');
}
);
it('retains the exact pending guard without a synthesized tracking hash', () => {
const originalCmd =
'https://stream.example.invalid/episode.mpg?access_token=example-token';
const state = episodeState({
id: 'command-derived-tracking-hash',
originalCmd,
});
const identity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
});
if (!identity) throw new Error('Expected a captured request identity');
expect(identity).toEqual({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
originalEpisodeIdentity: originalCmd,
seasonKey: state.seasonKey,
seasonNumber: state.seasonNumber,
episodeNumber: state.episodeNumber,
sessionKey: identity.sessionKey,
});
});
it('freezes only credential-free structural fields for a mounted session', () => {
const originalCmd =
'https://user:password@stream.example/episode.mpg?access_token=secret';
const identity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd }),
});
if (!identity) throw new Error('Expected a captured request identity');
const retained = toStalkerEpisodePlaybackStructuralIdentity(identity);
expect(Object.isFrozen(retained)).toBe(true);
expect(retained).toEqual({
sourceId: identity.sourceId,
parentSeriesId: identity.parentSeriesId,
seriesMode: identity.seriesMode,
seasonKey: identity.seasonKey,
seasonNumber: identity.seasonNumber,
episodeNumber: identity.episodeNumber,
sessionKey: identity.sessionKey,
});
expect(JSON.stringify(retained)).not.toContain(originalCmd);
expect(retained).not.toHaveProperty('originalEpisodeIdentity');
});
it('fails closed when structural coordinates match multiple episodes', () => {
const state = episodeState({ originalCmd: 'command-a' });
const requestIdentity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: state,
});
if (!requestIdentity) {
throw new Error('Expected a captured request identity');
}
const identity =
toStalkerEpisodePlaybackStructuralIdentity(requestIdentity);
expect(
resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: {
[state.seasonKey]: [
state.episode,
{
...state.episode,
id: 'another-tracking-id',
originalCmd: 'command-b',
},
],
},
identity,
})
).toBeNull();
});
it('keeps lazy provider ids transient while rejecting an id refresh for a pending request', () => {
const stateA = episodeState({ originalId: 'provider|episode:token-a' });
const stateB = episodeState({ originalId: 'provider|episode:token-b' });
const options = {
sourceId: 'playlist',
parentSeriesId: '50001',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
} as const;
const identityA = captureStalkerEpisodePlaybackSessionIdentity({
...options,
episodeState: stateA,
});
const keyB = createStalkerEpisodePlaybackSessionKey({
...options,
episodeState: stateB,
});
if (!identityA) throw new Error('Expected a captured request identity');
expect(identityA.originalEpisodeIdentity).toBe(
'provider|episode:token-a'
);
expect(keyB).toBe(identityA.sessionKey);
expect(keyB).not.toContain('provider|episode:token-a');
expect(keyB).not.toContain('provider|episode:token-b');
expect(keyB).not.toContain('synthesized-32-bit-id');
expect(
resolveStalkerEpisodeStateByIdentity({
episodesBySeason: { [stateB.seasonKey]: [stateB.episode] },
identity: identityA,
})
).toBeNull();
});
it('separates every structural coordinate without delimiter collisions', () => {
const base = {
sourceId: 'playlist|a',
parentSeriesId: 'parent|a',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: episodeState({ originalCmd: 'cmd|a' }),
} as const;
const variants = [
{ ...base, sourceId: 'playlist|b' },
{ ...base, parentSeriesId: 'parent|b' },
{
...base,
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
},
{
...base,
episodeState: episodeState(
{ originalCmd: 'cmd|a' },
'season|2'
),
},
{
...base,
episodeState: {
...base.episodeState,
seasonNumber: 2,
},
},
{
...base,
episodeState: {
...base.episodeState,
episodeNumber: 3,
},
},
];
const baseKey = createStalkerEpisodePlaybackSessionKey(base);
const delimiterVariantKey = createStalkerEpisodePlaybackSessionKey({
...base,
sourceId: 'playlist',
parentSeriesId: 'a|parent|a',
});
expect(
variants.map(createStalkerEpisodePlaybackSessionKey)
).not.toContain(baseKey);
expect(delimiterVariantKey).not.toBe(baseKey);
});
it.each([
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
state: episodeState({}),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
state: episodeState({ originalCmd: '' }),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
state: episodeState({}),
},
{
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
state: episodeState({ originalId: '' }),
},
])(
'fails closed when original episode identity is absent',
({ seriesMode, state }) => {
expect(
createStalkerEpisodePlaybackSessionKey({
sourceId: 'playlist',
parentSeriesId: 'series',
seriesMode,
episodeState: state,
})
).toBe('');
}
);
});
@@ -0,0 +1,237 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import {
normalizeStalkerEntityId,
type StalkerMappedEpisode,
} from '@iptvnator/portal/stalker/data-access';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
import {
STALKER_SERIES_DOWNLOAD_MODES,
type StalkerSeriesDownloadMode,
} from './stalker-series-download.adapter';
export interface StalkerEpisodePlaybackSessionKeyOptions {
readonly sourceId: string | null | undefined;
readonly parentSeriesId: unknown;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null;
}
export interface StalkerEpisodePlaybackSessionIdentity {
readonly sourceId: string;
readonly parentSeriesId: string;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly originalEpisodeIdentity: string | number;
readonly seasonKey: string;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly sessionKey: string;
}
export interface StalkerEpisodePlaybackStructuralIdentity {
readonly sourceId: string;
readonly parentSeriesId: string;
readonly seriesMode: StalkerSeriesDownloadMode;
readonly seasonKey: string;
readonly seasonNumber: number;
readonly episodeNumber: number;
readonly sessionKey: string;
}
interface StalkerEpisodeStateLookupOptions {
readonly episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>;
}
function present(value: unknown): value is string | number {
return (
(typeof value === 'string' && value.trim().length > 0) ||
(typeof value === 'number' && Number.isFinite(value))
);
}
export function captureStalkerEpisodePlaybackSessionIdentity({
sourceId,
parentSeriesId,
seriesMode,
episodeState,
}: StalkerEpisodePlaybackSessionKeyOptions): StalkerEpisodePlaybackSessionIdentity | null {
const normalizedSourceId = sourceId?.trim() ?? '';
const seriesId = normalizeStalkerEntityId(parentSeriesId);
if (!normalizedSourceId || !seriesId || !episodeState) return null;
const { episodeNumber, seasonKey, seasonNumber } = episodeState;
const episode = episodeState.episode as StalkerMappedEpisode;
const originalIdentity =
seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
? episode.originalId
: episode.originalCmd;
if (
!present(originalIdentity) ||
!seasonKey.trim() ||
!Number.isSafeInteger(seasonNumber) ||
seasonNumber < 0 ||
!Number.isSafeInteger(episodeNumber) ||
episodeNumber < 1
) {
return null;
}
// Recovery ownership uses provider commands and IDs only for the transient
// request guard below. They may contain credentials and must never enter
// the session key or retained recovery state.
const structuralContentId = JSON.stringify([
seriesMode,
seriesId,
seasonKey,
seasonNumber,
episodeNumber,
]);
const sessionKey = createPlaybackSessionKey({
kind: 'episode',
sourceId: normalizedSourceId,
contentId: structuralContentId,
seriesId,
seasonNumber,
episodeNumber,
});
return Object.freeze({
sourceId: normalizedSourceId,
parentSeriesId: seriesId,
seriesMode,
originalEpisodeIdentity: originalIdentity,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
});
}
export function createStalkerEpisodePlaybackSessionKey(
options: StalkerEpisodePlaybackSessionKeyOptions
): string {
return (
captureStalkerEpisodePlaybackSessionIdentity(options)?.sessionKey ?? ''
);
}
export function toStalkerEpisodePlaybackStructuralIdentity(
identity: StalkerEpisodePlaybackSessionIdentity
): StalkerEpisodePlaybackStructuralIdentity {
const {
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
} = identity;
return Object.freeze({
sourceId,
parentSeriesId,
seriesMode,
seasonKey,
seasonNumber,
episodeNumber,
sessionKey,
});
}
export function resolveSelectedStalkerEpisodeState({
episodesBySeason,
episode,
}: StalkerEpisodeStateLookupOptions & {
readonly episode: XtreamSerieEpisode;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const exactState = findStalkerEpisodeState(
episodesBySeason,
(candidate) => candidate === episode
);
if (exactState) return exactState;
const selected = episode as StalkerMappedEpisode;
const originalIdentity = selected.originalId ?? selected.originalCmd;
const selectedSeasonNumber = Number(episode.season) || 0;
if (!present(originalIdentity)) return null;
return findStalkerEpisodeState(episodesBySeason, (candidate, state) => {
const mapped = candidate as StalkerMappedEpisode;
return (
(mapped.originalId ?? mapped.originalCmd) === originalIdentity &&
(!selectedSeasonNumber ||
state.seasonNumber === selectedSeasonNumber) &&
state.episodeNumber === Number(episode.episode_num)
);
});
}
export function resolveStalkerEpisodeStateByIdentity({
episodesBySeason,
identity,
}: StalkerEpisodeStateLookupOptions & {
readonly identity: StalkerEpisodePlaybackSessionIdentity;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
return findStalkerEpisodeState(
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
(candidate, state) => {
const mapped = candidate as StalkerMappedEpisode;
const originalIdentity =
identity.seriesMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod
? mapped.originalId
: mapped.originalCmd;
return (
originalIdentity === identity.originalEpisodeIdentity &&
state.seasonNumber === identity.seasonNumber &&
state.episodeNumber === identity.episodeNumber
);
}
);
}
export function resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason,
identity,
}: StalkerEpisodeStateLookupOptions & {
readonly identity: StalkerEpisodePlaybackStructuralIdentity;
}): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
return findStalkerEpisodeState(
{ [identity.seasonKey]: episodesBySeason[identity.seasonKey] ?? [] },
(_candidate, state) =>
state.seasonNumber === identity.seasonNumber &&
state.episodeNumber === identity.episodeNumber,
true
);
}
function findStalkerEpisodeState(
episodesBySeason: Record<string, readonly XtreamSerieEpisode[]>,
matches: (
episode: XtreamSerieEpisode,
state: SeriesPlaybackEpisodeState<XtreamSerieEpisode>
) => boolean,
requireUnique = false
): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
let match: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null = null;
for (const [seasonKey, episodes] of Object.entries(episodesBySeason)) {
for (
let episodeIndex = 0;
episodeIndex < episodes.length;
episodeIndex++
) {
const episode = episodes[episodeIndex];
const state = {
seasonKey,
seasonNumber: Number(episode.season) || Number(seasonKey) || 0,
episodeNumber: Number(episode.episode_num) || episodeIndex + 1,
episode,
previous: episodes[episodeIndex - 1] ?? null,
next: episodes[episodeIndex + 1] ?? null,
};
if (!matches(episode, state)) continue;
if (!requireUnique) return state;
if (match) return null;
match = state;
}
}
return match;
}
@@ -167,6 +167,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="serial.info.name"
@@ -53,6 +53,7 @@ class StubSeasonContainerComponent {
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
@@ -771,17 +772,17 @@ describe('StalkerSeriesViewComponent', () => {
}),
})
);
expect(inlinePlayer.episodeMetadata()).toEqual({
label: 'S01E01',
title: 'Pilot',
seasonNumber: 1,
episodeNumber: 1,
});
// Prettier expands these compact fixtures enough to breach the spec's
// hard 1,200-line lint limit.
// prettier-ignore
expect(inlinePlayer.episodeMetadata()).toEqual({ label: 'S01E01', title: 'Pilot', seasonNumber: 1, episodeNumber: 1 });
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
autoplayEnabled: true,
});
const firstEpisodeKey = inlinePlayer.playbackSessionKey();
expect(firstEpisodeKey).not.toBe('');
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
@@ -806,6 +807,7 @@ describe('StalkerSeriesViewComponent', () => {
canNext: false,
autoplayEnabled: true,
});
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
inlinePlayer.playbackEnded.emit();
await fixture.whenStable();
@@ -61,7 +61,6 @@ import {
getSeriesPlaybackNavigation,
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
resolveSeriesPlaybackEpisodeState,
type SeriesPlaybackEpisodeState,
type UpNextRailItem,
} from '@iptvnator/ui/playback';
@@ -85,6 +84,15 @@ import {
createStalkerSeriesDownloadAdapter,
STALKER_SERIES_DOWNLOAD_MODES,
} from './stalker-series-download.adapter';
import {
captureStalkerEpisodePlaybackSessionIdentity,
resolveSelectedStalkerEpisodeState,
resolveStalkerEpisodeStateByIdentity,
resolveStalkerEpisodeStateByStructuralIdentity,
toStalkerEpisodePlaybackStructuralIdentity,
type StalkerEpisodePlaybackSessionIdentity,
type StalkerEpisodePlaybackStructuralIdentity,
} from './stalker-episode-playback-session-key';
interface SeriesPositionContext {
readonly generation: number;
@@ -93,46 +101,10 @@ interface SeriesPositionContext {
readonly mutationKey: string;
}
function snapshotTextList(value: string | undefined): string[] | undefined {
const entries = value
?.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
return entries?.length ? entries : undefined;
}
function snapshotNumber(
value: string | number | undefined
): number | undefined {
if (value === undefined || String(value).trim() === '') {
return undefined;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
function snapshotYear(value: string | undefined): number | undefined {
const match = value?.match(/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/);
return match ? Number(match[1]) : undefined;
}
function snapshotPeople(
enriched: TmdbEnrichedCastMember[] | undefined,
fallback: string | undefined
) {
return enriched?.length
? enriched.map((person) => ({
name: person.name,
role: person.character,
profileUrl: person.profileUrl ?? undefined,
tmdbPersonId: person.tmdbPersonId,
}))
: snapshotTextList(fallback)?.map((name) => ({ name }));
}
function positiveCoordinate(value: number | string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 1;
interface StalkerSeriesPlaybackRequestContext {
readonly generation: number;
readonly usesEmbeddedPlayer: boolean;
readonly identity: StalkerEpisodePlaybackSessionIdentity | null;
}
/**
@@ -215,6 +187,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
>();
private readonly seriesPositionReloadKeys = new Set<string>();
private seriesPositionsLoadGeneration = 0;
private seriesPlaybackRequestGeneration = 0;
private currentSeriesPlaybackOwnerKey = '';
private readonly inlinePlaybackEpisodeIdentity =
signal<StalkerEpisodePlaybackStructuralIdentity | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
@@ -308,6 +284,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
constructor() {
effect(() => {
const ownerKey = this.seriesPlaybackOwnerKey();
untracked(() => this.syncSeriesPlaybackOwner(ownerKey));
});
// TMDB season fetch, keyed on (tmdb_id, selected season). With season
// tabs the first seasonSelected fires immediately when seasons load —
// usually BEFORE the async show-level TMDB enrichment has written
@@ -426,9 +407,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
) {
return;
}
this.episodePlaybackPositions.set(
reconciled.positionsByTrackingId
);
this.episodePlaybackPositions.set(reconciled.positionsByTrackingId);
this.legacyPositionByTrackingId.set(
reconciled.legacyPositionByTrackingId
);
@@ -488,15 +467,14 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// The facade/runtime already saved this row. Repeat the
// idempotent upsert because only this view owns the
// scoped-to-legacy cleanup mapping.
void this.persistSeriesPosition(
playlistId,
data
).catch((error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
});
void this.persistSeriesPosition(playlistId, data).catch(
(error: unknown) => {
this.logger.error(
'Failed to persist runtime series position',
error
);
}
);
}
) ?? null;
}
@@ -534,6 +512,22 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
readonly seriesMode = computed(() =>
this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
: this.vodWithSeries()
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries
);
private readonly seriesPlaybackOwnerKey = computed(() => {
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
return sourceId && parentSeriesId
? JSON.stringify([sourceId, parentSeriesId, this.seriesMode()])
: '';
});
readonly episodeDownloadAdapter = computed(() => {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
@@ -545,11 +539,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.translateService.defaultLang ||
'en',
seriesId: this.toSeriesId(item?.id ?? 0),
seriesMode: this.isVodSeries()
? STALKER_SERIES_DOWNLOAD_MODES.LazyVod
: this.vodWithSeries()
? STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod
: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
seriesMode: this.seriesMode(),
resolveUrl: (command, episodeNumber) =>
this.stalkerStore.fetchLinkToPlay(
playlist?.portalUrl ?? '',
@@ -591,10 +581,25 @@ export class StalkerSeriesViewComponent implements OnDestroy {
vodSeriesSeasons: this.vodSeriesSeasons(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(() =>
this.getInlineEpisodeState()
);
readonly inlineEpisodeState = computed(() => {
const identity = this.inlinePlaybackEpisodeIdentity();
const sourceId = this.stalkerStore.currentPlaylist()?._id?.trim() ?? '';
const parentSeriesId = normalizeStalkerEntityId(this.displayItem()?.id);
if (
!identity ||
this.playbackSessionKey() !== identity.sessionKey ||
sourceId !== identity.sourceId ||
parentSeriesId !== identity.parentSeriesId ||
this.seriesMode() !== identity.seriesMode
) {
return null;
}
return resolveStalkerEpisodeStateByStructuralIdentity({
episodesBySeason: this.mappedSeasons(),
identity,
});
});
readonly playbackSessionKey = signal('');
readonly inlineEpisodeMetadata = computed(() =>
getSeriesEpisodeMetadata(this.inlineEpisodeState())
);
@@ -768,27 +773,33 @@ export class StalkerSeriesViewComponent implements OnDestroy {
* Handles episode click from the container
*/
onEpisodeClicked(episode: XtreamSerieEpisode) {
const mappedEpisode = episode as StalkerMappedEpisode;
const item = this.displayItem();
const episodeState = resolveSelectedStalkerEpisodeState({
episodesBySeason: this.mappedSeasons(),
episode,
});
if (!item || !episodeState) return;
this.syncSeriesPlaybackOwner(this.seriesPlaybackOwnerKey());
if (mappedEpisode.custom_sid === 'vod-series') {
// It's a VOD series episode (is_series=1 mode)
// Use originalId for playback URL, but use generated id for tracking
this.playVodSeriesEpisode({
originalId: mappedEpisode.originalId, // For constructing playback URL
trackingId: episode.id, // The generated unique ID for position tracking
name: episode.title,
series_number: episode.episode_num,
});
} else {
// Regular series or vclub mode (VOD with embedded series array)
// Use originalCmd for playback, episode.id for tracking
const trackingId = Number(episode.id);
this.playEpisodeClicked(
episode.episode_num,
mappedEpisode.originalCmd,
trackingId
);
}
const mappedEpisode = episodeState.episode as StalkerMappedEpisode;
const isLazyVod = mappedEpisode.custom_sid === 'vod-series';
const command = isLazyVod
? `/media/file_${mappedEpisode.originalId ?? ''}.mpg`
: mappedEpisode.originalCmd;
const title = isLazyVod
? `${item.info.name} - ${mappedEpisode.title || `Episode ${episodeState.episodeNumber}`}`
: item.info.name;
const trackingId = Number(mappedEpisode.id);
const startTime =
this.episodePlaybackPositions().get(trackingId)?.positionSeconds;
void this.startPlayback(
command,
title,
item.info.movie_image,
episodeState,
startTime
);
}
async playQuickStartEpisode(): Promise<void> {
@@ -807,69 +818,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
/**
* Play episode - handles regular series and vclub mode
*/
playEpisodeClicked(episodeNum: number, cmd?: string, trackingId?: number) {
const item = this.displayItem();
if (!item) return;
this.logger.debug('playEpisodeClicked', {
episodeNum,
cmd,
trackingId,
seriesId: item.id,
});
const position = trackingId
? this.episodePlaybackPositions().get(trackingId)
: undefined;
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
item.info.name,
item.info.movie_image,
episodeNum,
trackingId,
startTime
);
}
/**
* Play VOD series episode
*/
playVodSeriesEpisode(episode: {
originalId?: string;
trackingId: string;
name: string;
series_number: number;
}) {
const item = this.displayItem();
if (!item) return;
// Use originalId for playback URL (this is what the Stalker API expects)
const cmd = `/media/file_${episode.originalId ?? ''}.mpg`;
const episodeName = episode.name || `Episode ${episode.series_number}`;
// Use trackingId (generated unique ID) for playback position tracking
const trackingId = Number(episode.trackingId);
this.logger.debug('playVodSeriesEpisode', {
originalId: episode.originalId,
trackingId,
seriesId: item.id,
episodeName,
});
const position = this.episodePlaybackPositions().get(trackingId);
const startTime = position?.positionSeconds;
void this.startPlayback(
cmd,
`${item.info.name} - ${episodeName}`,
item.info.movie_image,
episode.series_number,
trackingId,
startTime
);
}
openSimilarInPortals(item: CrossPortalSimilarItem): void {
void this.router.navigate(this.crossPortalSimilar.buildLink(item));
}
@@ -907,7 +855,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
closeInlinePlayer(): void {
this.seriesPlaybackRequestGeneration += 1;
this.inlinePlayback.set(null);
this.inlinePlaybackEpisodeIdentity.set(null);
this.playbackSessionKey.set('');
this.lastSaveTime = 0;
}
@@ -984,13 +935,27 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
private async startPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
cmd: string | undefined,
title: string | undefined,
thumbnail: string | undefined,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode>,
startTime?: number
): Promise<void> {
const generation = ++this.seriesPlaybackRequestGeneration;
const episodeNum = episodeState.episodeNumber;
const episodeId = Number(episodeState.episode.id);
const request: StalkerSeriesPlaybackRequestContext = {
generation,
usesEmbeddedPlayer: this.portalPlayer.isEmbeddedPlayer(),
identity: captureStalkerEpisodePlaybackSessionIdentity({
sourceId: this.stalkerStore.currentPlaylist()?._id,
parentSeriesId: this.displayItem()?.id,
seriesMode: this.seriesMode(),
episodeState,
}),
};
if (request.usesEmbeddedPlayer && !request.identity) return;
try {
const playback = await this.stalkerStore.resolveVodPlayback(
cmd,
@@ -1000,14 +965,8 @@ export class StalkerSeriesViewComponent implements OnDestroy {
episodeId,
startTime
);
const episodeState =
episodeId === undefined
? null
: resolveSeriesPlaybackEpisodeState({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId: episodeId,
fallbackEpisodeNumber: episodeNum,
});
if (!this.isPlaybackRequestCurrent(request)) return;
const resolvedPlayback =
episodeState && playback.contentInfo?.contentType === 'episode'
? {
@@ -1021,14 +980,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
: playback;
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(resolvedPlayback);
if (request.usesEmbeddedPlayer && request.identity) {
this.setInlinePlayback(resolvedPlayback, request.identity);
return;
}
this.closeInlinePlayer();
void this.portalPlayer.openResolvedPlayback(resolvedPlayback, true);
} catch (error) {
if (!this.isPlaybackRequestCurrent(request)) return;
this.logger.error('Failed to start inline series playback', error);
const errorMessage =
error instanceof Error && error.message === 'nothing_to_play'
@@ -1042,25 +1002,48 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
}
private getInlineEpisodeState(): SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null {
const playback = this.inlinePlayback();
const currentEpisodeId = playback?.contentInfo?.contentXtreamId;
if (
playback?.contentInfo?.contentType !== 'episode' ||
currentEpisodeId === undefined
) {
return null;
private isPlaybackRequestCurrent(
request: StalkerSeriesPlaybackRequestContext
): boolean {
if (request.generation !== this.seriesPlaybackRequestGeneration) {
return false;
}
if (!request.identity) return true;
return resolveSeriesPlaybackEpisodeState({
const identity = request.identity;
const episodeState = resolveStalkerEpisodeStateByIdentity({
episodesBySeason: this.mappedSeasons(),
currentEpisodeId,
identity,
});
const currentIdentity = captureStalkerEpisodePlaybackSessionIdentity({
sourceId: this.stalkerStore.currentPlaylist()?._id,
parentSeriesId: this.displayItem()?.id,
seriesMode: this.seriesMode(),
episodeState,
});
return currentIdentity?.sessionKey === identity.sessionKey;
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
this.closeInlinePlayer();
}
private setInlinePlayback(
playback: ResolvedPortalPlayback,
identity: StalkerEpisodePlaybackSessionIdentity
): void {
this.playbackSessionKey.set(identity.sessionKey);
this.inlinePlaybackEpisodeIdentity.set(
toStalkerEpisodePlaybackStructuralIdentity(identity)
);
this.inlinePlayback.set(playback);
}
private syncSeriesPlaybackOwner(ownerKey: string): void {
if (ownerKey === this.currentSeriesPlaybackOwnerKey) return;
this.currentSeriesPlaybackOwnerKey = ownerKey;
this.closeInlinePlayer();
}
async handlePlaybackToggleRequested(
@@ -1072,17 +1055,11 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
if (request.nextPosition) {
await this.persistSeriesPosition(
playlistId,
request.nextPosition
);
await this.persistSeriesPosition(playlistId, request.nextPosition);
return;
}
await this.clearSeriesPosition(
playlistId,
request.contentXtreamId
);
await this.clearSeriesPosition(playlistId, request.contentXtreamId);
}
handlePlaybackToggleRequestedFromUi(
@@ -1104,9 +1081,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const generation = ++this.seriesPositionsLoadGeneration;
this.trackPendingSeriesPositionLoad(context, generation);
try {
await this.waitForSeriesPositionMutations(
context.mutationKey
);
await this.waitForSeriesPositionMutations(context.mutationKey);
if (
generation !== this.seriesPositionsLoadGeneration ||
@@ -1155,16 +1130,13 @@ export class StalkerSeriesViewComponent implements OnDestroy {
return (
activeContext === context &&
activeContext.generation === context.generation &&
this.stalkerStore.currentPlaylist()?._id ===
context.playlistId &&
this.stalkerStore.currentPlaylist()?._id === context.playlistId &&
this.toSeriesId(this.displayItem()?.id ?? 0) ===
context.seriesXtreamId
);
}
private waitForSeriesPositionMutations(
mutationKey: string
): Promise<void> {
private waitForSeriesPositionMutations(mutationKey: string): Promise<void> {
return (
this.seriesPositionMutationQueues.get(mutationKey) ??
Promise.resolve()
@@ -1176,8 +1148,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context) ??
new Set<number>();
this.pendingSeriesPositionLoads.get(context) ?? new Set<number>();
generations.add(generation);
this.pendingSeriesPositionLoads.set(context, generations);
}
@@ -1186,8 +1157,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
context: SeriesPositionContext,
generation: number
): void {
const generations =
this.pendingSeriesPositionLoads.get(context);
const generations = this.pendingSeriesPositionLoads.get(context);
generations?.delete(generation);
if (generations?.size === 0) {
this.pendingSeriesPositionLoads.delete(context);
@@ -1220,30 +1190,20 @@ export class StalkerSeriesViewComponent implements OnDestroy {
() => undefined,
() => undefined
);
this.seriesPositionMutationQueues.set(
context.mutationKey,
barrier
);
this.seriesPositionMutationQueues.set(context.mutationKey, barrier);
void barrier.then(() => {
if (
this.seriesPositionMutationQueues.get(
context.mutationKey
) === barrier
this.seriesPositionMutationQueues.get(context.mutationKey) ===
barrier
) {
this.seriesPositionMutationQueues.delete(
context.mutationKey
);
this.reloadSeriesPositionsAfterMutations(
context.mutationKey
);
this.seriesPositionMutationQueues.delete(context.mutationKey);
this.reloadSeriesPositionsAfterMutations(context.mutationKey);
}
});
return result;
}
private reloadSeriesPositionsAfterMutations(
mutationKey: string
): void {
private reloadSeriesPositionsAfterMutations(mutationKey: string): void {
if (!this.seriesPositionReloadKeys.delete(mutationKey)) {
return;
}
@@ -1286,10 +1246,9 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (!context) {
return Promise.resolve();
}
const legacyPosition =
this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
const legacyPosition = this.legacyPositionByTrackingId().get(
position.contentXtreamId
);
return this.enqueueSeriesPositionMutation(context, async () => {
let clearedLegacy: boolean;
try {
@@ -1301,8 +1260,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
});
} catch (error) {
if (
error instanceof
StalkerSeriesPositionPartialSaveError &&
error instanceof StalkerSeriesPositionPartialSaveError &&
this.isSeriesPositionContextActive(context)
) {
this.publishSavedSeriesPosition(
@@ -1329,14 +1287,10 @@ export class StalkerSeriesViewComponent implements OnDestroy {
legacyPosition: PlaybackPositionData | undefined,
clearedLegacy: boolean
): void {
const removedTrackingIds = new Set([
position.contentXtreamId,
]);
const removedTrackingIds = new Set([position.contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(position.contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
@@ -1344,9 +1298,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.rawSeriesPositions.set([
...this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
!removedTrackingIds.has(candidate.contentXtreamId)
),
position,
]);
@@ -1361,14 +1313,15 @@ export class StalkerSeriesViewComponent implements OnDestroy {
if (!context) {
return Promise.resolve();
}
const position =
this.episodePlaybackPositions().get(contentXtreamId) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const position = this.episodePlaybackPositions().get(
contentXtreamId
) ?? {
contentXtreamId,
contentType: 'episode',
positionSeconds: 0,
playlistId,
seriesXtreamId: context.seriesXtreamId,
};
const legacyPosition =
this.legacyPositionByTrackingId().get(contentXtreamId);
return this.enqueueSeriesPositionMutation(context, async () => {
@@ -1397,9 +1350,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
const removedTrackingIds = new Set([contentXtreamId]);
if (clearedLegacy && legacyPosition) {
removedTrackingIds.add(legacyPosition.contentXtreamId);
const legacyPositions = new Map(
this.legacyPositionByTrackingId()
);
const legacyPositions = new Map(this.legacyPositionByTrackingId());
legacyPositions.delete(contentXtreamId);
this.legacyPositionByTrackingId.set(legacyPositions);
}
@@ -1407,9 +1358,7 @@ export class StalkerSeriesViewComponent implements OnDestroy {
this.rawSeriesPositions.set(
this.rawSeriesPositions().filter(
(candidate) =>
!removedTrackingIds.has(
candidate.contentXtreamId
)
!removedTrackingIds.has(candidate.contentXtreamId)
)
);
this.removeEpisodePlaybackPosition(contentXtreamId);
@@ -0,0 +1,753 @@
import { EMPTY, of } from 'rxjs';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import {
StalkerStore,
type StalkerMappedEpisode,
type StalkerVodSource,
} from '@iptvnator/portal/stalker/data-access';
import { TmdbEnrichmentService } from '@iptvnator/services';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
import { STALKER_SERIES_DOWNLOAD_MODES } from './stalker-series-download.adapter';
import { createStalkerEpisodePlaybackSessionKey } from './stalker-episode-playback-session-key';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
describe('StalkerSeriesViewComponent playback session key', () => {
let fixture: ComponentFixture<StalkerSeriesViewComponent>;
const selectedContentType = signal<'series' | 'vod'>('series');
const selectedItem = signal<StalkerVodSource | null>(null);
const serialSeasons = signal<unknown[]>([]);
const vodSeasons = signal<unknown[]>([]);
const resolveVodPlayback = jest.fn();
const tmdbGetSeason = jest.fn();
const currentPlaylist = signal({
_id: 'stalker|playlist',
title: 'Portal',
portalUrl: 'https://stalker.example',
macAddress: '00:1A:79:12:34:56',
});
beforeEach(async () => {
selectedContentType.set('series');
selectedItem.set({
id: 'series|parent',
info: { name: 'Series', movie_image: 'poster.jpg' },
});
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2],
},
]);
vodSeasons.set([]);
resolveVodPlayback
.mockReset()
.mockImplementation(
async (
_command: string,
title: string,
thumbnail: string,
_episodeNumber: number,
episodeId: number
) => ({
streamUrl: 'https://resolved.example/temporary.mpg',
title,
thumbnail,
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episodeId,
contentType: 'episode',
seriesXtreamId: 999,
},
})
);
tmdbGetSeason.mockReset().mockResolvedValue(null);
await TestBed.configureTestingModule({
imports: [StalkerSeriesViewComponent],
providers: [
{
provide: StalkerStore,
useValue: {
selectedItem,
selectedContentType,
currentPlaylist,
getSerialSeasonsResource: () => serialSeasons(),
getVodSeriesSeasonsResource: () => vodSeasons(),
isVodSeriesSeasonsLoading: signal(false),
isSerialSeasonsLoading: signal(false),
fetchVodSeriesEpisodes: jest.fn(),
resolveVodPlayback,
fetchLinkToPlay: jest.fn(),
clearSelectedItem: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions: jest
.fn()
.mockResolvedValue([]),
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openResolvedPlayback: jest.fn(),
},
},
{ provide: Router, useValue: { navigateByUrl: jest.fn() } },
{
provide: TmdbEnrichmentService,
useValue: {
isEnabled: () => false,
getSeason: tmdbGetSeason,
getSeasonEpisodes: jest.fn(),
},
},
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
currentLang: 'en',
defaultLang: 'en',
onLangChange: EMPTY,
onTranslationChange: EMPTY,
onDefaultLangChange: EMPTY,
},
},
],
})
.overrideComponent(StalkerSeriesViewComponent, {
set: { template: '' },
})
.compileComponents();
fixture = TestBed.createComponent(StalkerSeriesViewComponent);
});
afterEach(() => fixture.destroy());
async function playFirstEpisode(): Promise<StalkerMappedEpisode> {
fixture.detectChanges();
await fixture.whenStable();
const episode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(episode);
await fixture.whenStable();
return episode;
}
function setLazySeries(parentId: string, episodeId: string): void {
selectedContentType.set('vod');
selectedItem.set({
id: parentId,
is_series: true,
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
});
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'lazy-season',
video_id: parentId,
season_number: '2',
name: 'Season 2',
episodes: [
{
id: episodeId,
series_number: 3,
name: 'Replacement episode',
},
],
isLoading: false,
isExpanded: false,
},
]);
}
it.each([
STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod,
STALKER_SERIES_DOWNLOAD_MODES.LazyVod,
])('owns a collision-safe key for %s playback', async (expectedMode) => {
if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.EmbeddedVod) {
fixture.componentRef.setInput('vodWithSeries', {
id: 'series|parent',
cmd: 'ffrt4://embedded|full-command',
series: [1, 2],
info: { name: 'Embedded series', movie_image: 'poster.jpg' },
});
} else if (expectedMode === STALKER_SERIES_DOWNLOAD_MODES.LazyVod) {
selectedContentType.set('vod');
selectedItem.set({
id: 'series|parent',
is_series: true,
info: { name: 'Lazy series', movie_image: 'poster.jpg' },
});
vodSeasons.set([]);
fixture.detectChanges();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'season|provider',
video_id: 'series|parent',
season_number: '1',
name: 'Season | One',
episodes: [
{
id: 'episode|provider:full',
series_number: 1,
name: 'Pilot',
},
],
isLoading: false,
isExpanded: false,
},
]);
}
const episode = await playFirstEpisode();
const key = fixture.componentInstance.playbackSessionKey();
expect(fixture.componentInstance.seriesMode()).toBe(expectedMode);
expect(key).not.toBe('');
expect(key).not.toContain(String(episode.id));
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replacement.mkv',
title: 'Replacement payload',
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 987654321,
contentType: 'episode',
seriesXtreamId: 123,
seasonNumber: 9,
episodeNumber: 9,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
it('preserves the logical key when the provider command rotates', async () => {
await playFirstEpisode();
const firstKey = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'replacement-season',
name: 'Season 1',
cmd: 'ffrt4://different|full-command',
series: [1],
},
]);
await playFirstEpisode();
expect(fixture.componentInstance.playbackSessionKey()).toBe(firstKey);
expect(firstKey).not.toContain('ffrt4://regular|full-command');
expect(firstKey).not.toContain('ffrt4://different|full-command');
});
it('rejects a pending command refresh but lets the refreshed episode reuse its logical key', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValueOnce(pending.promise);
const [seasonKeyA, episodesA] = Object.entries(
fixture.componentInstance.mappedSeasons()
)[0];
const episodeA = episodesA[0] as StalkerMappedEpisode;
const expectedKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: seasonKeyA,
seasonNumber: Number(episodeA.season) || Number(seasonKeyA),
episodeNumber: Number(episodeA.episode_num),
episode: episodeA,
previous: null,
next: null,
},
});
fixture.componentInstance.onEpisodeClicked(episodeA);
serialSeasons.set([
{
id: 'replacement-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1, 2],
},
]);
fixture.detectChanges();
const [seasonKeyB, episodesB] = Object.entries(
fixture.componentInstance.mappedSeasons()
)[0];
const episodeB = episodesB[0] as StalkerMappedEpisode;
const refreshedKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: seasonKeyB,
seasonNumber: Number(episodeB.season) || Number(seasonKeyB),
episodeNumber: Number(episodeB.episode_num),
episode: episodeB,
previous: null,
next: episodesB[1] ?? null,
},
});
expect(episodeB.originalCmd).not.toBe(episodeA.originalCmd);
expect(episodeB.id).not.toBe(episodeA.id);
expect(refreshedKey).toBe(expectedKey);
pending.resolve({
streamUrl: 'https://stale.example/old-command.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episodeA.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
expect(TestBed.inject(MatSnackBar).open).not.toHaveBeenCalled();
fixture.componentInstance.onEpisodeClicked(episodeB);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'ffrt4://refreshed|full-command',
'Series',
'poster.jpg',
1,
Number(episodeB.id),
undefined
);
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
expectedKey
);
});
it('selects the exact clicked episode when distinct provider commands collide in the tracking hash', async () => {
serialSeasons.set([
{
id: 'collision-a',
name: 'Season A',
cmd: 'collision-cmd-1000',
series: [1],
},
{
id: 'collision-b',
name: 'Season B',
cmd: 'collision-cmd-425721',
series: [1],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const [episodeA, episodeB] = Object.values(
fixture.componentInstance.mappedSeasons()
).map((episodes) => episodes[0] as StalkerMappedEpisode);
expect(episodeA.id).toBe('1511026353');
expect(episodeB.id).toBe(episodeA.id);
fixture.componentInstance.onEpisodeClicked(episodeB);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'collision-cmd-425721',
'Series',
'poster.jpg',
1,
1511026353,
undefined
);
const expectedBKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: '2',
seasonNumber: 2,
episodeNumber: 1,
episode: episodeB,
previous: null,
next: null,
},
});
const wrongAKey = createStalkerEpisodePlaybackSessionKey({
sourceId: 'stalker|playlist',
parentSeriesId: 'series|parent',
seriesMode: STALKER_SERIES_DOWNLOAD_MODES.RegularSeries,
episodeState: {
seasonKey: '1',
seasonNumber: 1,
episodeNumber: 1,
episode: episodeA,
previous: null,
next: null,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(
expectedBKey
);
expect(expectedBKey).not.toBe(wrongAKey);
});
it.each(['parent', 'mode', 'playlist'] as const)(
'clears committed playback when the canonical %s owner changes',
async (transition) => {
await playFirstEpisode();
expect(fixture.componentInstance.inlinePlayback()).not.toBeNull();
if (transition === 'parent') {
selectedItem.set({
...selectedItem()!,
id: 'replacement-parent',
});
} else if (transition === 'mode') {
selectedContentType.set('vod');
selectedItem.set({ ...selectedItem()!, is_series: true });
} else {
currentPlaylist.set({
...currentPlaylist(),
_id: 'replacement-playlist',
});
}
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
}
);
it('preserves committed playback across a same-owner object refresh', async () => {
await playFirstEpisode();
const playback = fixture.componentInstance.inlinePlayback();
const key = fixture.componentInstance.playbackSessionKey();
selectedItem.set({ ...selectedItem()! });
currentPlaylist.set({ ...currentPlaylist() });
serialSeasons.set(
serialSeasons().map((season) => ({
...(season as Record<string, unknown>),
}))
);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
it('re-resolves mounted episode metadata and neighbors after a same-owner season refresh', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const initialEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(initialEpisodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const refreshedEpisodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
const state = fixture.componentInstance.inlineEpisodeState();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(state?.episode).toBe(refreshedEpisodes[1]);
expect(state?.previous).toBe(refreshedEpisodes[0]);
expect(state?.next).toBe(refreshedEpisodes[2]);
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'ffrt4://refreshed|full-command',
'Series',
'poster.jpg',
3,
Number(refreshedEpisodes[2].id),
undefined
);
});
it('re-resolves mounted episode metadata after same-owner TMDB enrichment', async () => {
await playFirstEpisode();
const key = fixture.componentInstance.playbackSessionKey();
tmdbGetSeason.mockResolvedValue({
overview: 'Season overview',
episodes: [{ episode_number: 1, name: 'TMDB Pilot' }],
});
const item = selectedItem();
if (!item) throw new Error('Expected the selected series');
selectedItem.set({
...item,
info: { ...item.info, tmdb_id: 314 },
});
fixture.componentInstance.onSeasonSelected('1');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const currentEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0];
expect(tmdbGetSeason).toHaveBeenCalledWith(314, 1);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()?.episode).toBe(
currentEpisode
);
expect(fixture.componentInstance.inlineEpisodeMetadata()?.title).toBe(
'TMDB Pilot'
);
});
it('uses the current mapped episode when a stale lazy-series event resolves', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: 'series|parent',
is_series: true,
info: {
name: 'Lazy series',
movie_image: 'poster.jpg',
tmdb_id: 2718,
},
});
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.vodSeriesSeasons.set([
{
id: 'lazy-season',
video_id: 'series|parent',
season_number: '2',
name: 'Season 2',
episodes: [
{
id: 'provider-episode',
series_number: 3,
name: 'Episode 3',
},
],
isLoading: false,
isExpanded: false,
},
]);
fixture.detectChanges();
await fixture.whenStable();
const staleEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
tmdbGetSeason.mockResolvedValue({
episodes: [{ episode_number: 3, name: 'Current TMDB title' }],
});
fixture.componentInstance.onSeasonSelected('2');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(
Object.values(fixture.componentInstance.mappedSeasons())[0][0].title
).toBe('Current TMDB title');
fixture.componentInstance.onEpisodeClicked(staleEpisode);
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenLastCalledWith(
'/media/file_provider-episode.mpg',
'Lazy series - Current TMDB title',
'poster.jpg',
3,
expect.any(Number),
undefined
);
});
it('fails mounted episode commands closed when a same-owner refresh removes the episode', async () => {
serialSeasons.set([
{
id: 'regular-season',
name: 'Season 1',
cmd: 'ffrt4://regular|full-command',
series: [1, 2, 3],
},
]);
fixture.detectChanges();
await fixture.whenStable();
const episodes = Object.values(
fixture.componentInstance.mappedSeasons()
)[0] as StalkerMappedEpisode[];
fixture.componentInstance.onEpisodeClicked(episodes[1]);
await fixture.whenStable();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'refreshed-season',
name: 'Season 1',
cmd: 'ffrt4://refreshed|full-command',
series: [1],
},
]);
fixture.detectChanges();
await fixture.whenStable();
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
expect(fixture.componentInstance.inlineEpisodeState()).toBeNull();
expect(fixture.componentInstance.inlineEpisodeMetadata()).toBeNull();
expect(fixture.componentInstance.inlineSeriesNavigation()).toBeNull();
fixture.componentInstance.playPreviousEpisode();
fixture.componentInstance.playNextEpisode();
fixture.componentInstance.handleInlinePlaybackEnded();
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(1);
});
it('rejects a completion after its playlist, parent, mode, and episode owner change', async () => {
fixture.detectChanges();
await fixture.whenStable();
const pending = deferred<ResolvedPortalPlayback>();
resolveVodPlayback.mockReturnValueOnce(pending.promise);
const episode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(episode);
currentPlaylist.set({
...currentPlaylist(),
_id: 'replacement-playlist',
});
setLazySeries('replacement-parent', 'replacement-provider-episode');
pending.resolve({
streamUrl: 'https://stale.example/old-episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: episode.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(fixture.componentInstance.inlinePlayback()).toBeNull();
expect(fixture.componentInstance.playbackSessionKey()).toBe('');
});
it('keeps the newest episode when owner-scoped requests resolve out of order', async () => {
fixture.detectChanges();
await fixture.whenStable();
const oldRequest = deferred<ResolvedPortalPlayback>();
const newRequest = deferred<ResolvedPortalPlayback>();
resolveVodPlayback
.mockReturnValueOnce(oldRequest.promise)
.mockReturnValueOnce(newRequest.promise);
const oldEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(oldEpisode);
currentPlaylist.set({ ...currentPlaylist(), _id: 'new-playlist' });
setLazySeries('new-parent', 'new-provider-episode');
const newEpisode = Object.values(
fixture.componentInstance.mappedSeasons()
)[0][0] as StalkerMappedEpisode;
fixture.componentInstance.onEpisodeClicked(newEpisode);
const newestPlayback = {
streamUrl: 'https://new.example/episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: newEpisode.id,
contentType: 'episode' as const,
},
};
newRequest.resolve(newestPlayback);
oldRequest.resolve({
streamUrl: 'https://old.example/episode.mpg',
contentInfo: {
playlistId: 'transport-playlist',
contentXtreamId: oldEpisode.id,
contentType: 'episode',
},
});
await fixture.whenStable();
expect(resolveVodPlayback).toHaveBeenCalledTimes(2);
expect(fixture.componentInstance.inlinePlayback()?.streamUrl).toBe(
newestPlayback.streamUrl
);
expect(fixture.componentInstance.playbackSessionKey()).not.toBe('');
});
it('preserves a valid mounted episode when original identity is missing', async () => {
await playFirstEpisode();
const playback = fixture.componentInstance.inlinePlayback();
const key = fixture.componentInstance.playbackSessionKey();
serialSeasons.set([
{
id: 'identity-less-season',
name: 'Season 1',
series: [1],
},
]);
await playFirstEpisode();
expect(fixture.componentInstance.inlinePlayback()).toBe(playback);
expect(fixture.componentInstance.playbackSessionKey()).toBe(key);
});
});
@@ -80,6 +80,7 @@ function createController() {
return {
controller,
inlinePlayback,
playbackPositions,
selectedVodPosition,
};
@@ -115,4 +116,31 @@ describe('StalkerVodPlaybackController', () => {
expect(selectedVodPosition()?.contentXtreamId).toBe(202);
expect(selectedVodPosition()?.positionSeconds).toBe(20);
});
it('does not mount playback that resolves after the detail closes', async () => {
const { controller, inlinePlayback } = createController();
const pending = createDeferred<{ streamUrl: string }>();
const playback = controller.startVodPlayback(() => pending.promise);
controller.closeInlinePlayer();
pending.resolve({ streamUrl: 'https://stale.example/movie.mpg' });
await playback;
expect(inlinePlayback()).toBeNull();
});
it('keeps the newest VOD request when resolutions finish out of order', async () => {
const { controller, inlinePlayback } = createController();
const older = createDeferred<{ streamUrl: string }>();
const newer = createDeferred<{ streamUrl: string }>();
const olderRequest = controller.startVodPlayback(() => older.promise);
const newerRequest = controller.startVodPlayback(() => newer.promise);
const newestPlayback = { streamUrl: 'https://new.example/movie.mpg' };
newer.resolve(newestPlayback);
older.resolve({ streamUrl: 'https://old.example/movie.mpg' });
await Promise.all([olderRequest, newerRequest]);
expect(inlinePlayback()).toBe(newestPlayback);
});
});
@@ -21,22 +21,30 @@ interface StalkerVodPlaybackControllerConfig {
translateService: TranslateService;
logger: Logger;
playbackErrorLogMessage: string;
playbackOwnerKey?: () => string;
}
export class StalkerVodPlaybackController {
private lastInlineSaveTime = 0;
private loadSelectedVodPositionRequestId = 0;
private playbackRequestId = 0;
constructor(private readonly config: StalkerVodPlaybackControllerConfig) {}
async startVodPlayback(
resolvePlayback: () => Promise<ResolvedPortalPlayback>
): Promise<void> {
const requestId = ++this.playbackRequestId;
const usesEmbeddedPlayer = this.config.portalPlayer.isEmbeddedPlayer();
const playbackOwnerKey = this.config.playbackOwnerKey?.();
try {
const playback = await resolvePlayback();
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
return;
}
this.lastInlineSaveTime = 0;
if (this.config.portalPlayer.isEmbeddedPlayer()) {
if (usesEmbeddedPlayer) {
this.config.inlinePlayback.set(playback);
return;
}
@@ -44,6 +52,9 @@ export class StalkerVodPlaybackController {
this.closeInlinePlayer();
void this.config.portalPlayer.openResolvedPlayback(playback, true);
} catch (error) {
if (!this.isPlaybackRequestCurrent(requestId, playbackOwnerKey)) {
return;
}
this.config.logger.error(
this.config.playbackErrorLogMessage,
error
@@ -115,10 +126,22 @@ export class StalkerVodPlaybackController {
}
closeInlinePlayer(): void {
this.playbackRequestId += 1;
this.config.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
}
private isPlaybackRequestCurrent(
requestId: number,
playbackOwnerKey: string | undefined
): boolean {
return (
requestId === this.playbackRequestId &&
(this.config.playbackOwnerKey === undefined ||
this.config.playbackOwnerKey() === playbackOwnerKey)
);
}
showCopyNotification(): void {
this.config.snackBar.open(
this.config.translateService.instant('PORTALS.STREAM_URL_COPIED'),
@@ -96,6 +96,7 @@
@if (isEmbeddedPlayer) {
<div class="video-player">
<app-web-player-view
[playbackSessionKey]="playbackSessionKey()"
[streamUrl]="streamUrl"
[title]="activePlayback()?.title ?? ''"
[playback]="activePlayback()"
@@ -29,13 +29,21 @@ import {
EpgTimelineComponent,
EpgTimelineSummary,
} from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { EpgItem, EpgProgram } from '@iptvnator/shared/interfaces';
import {
type PlaybackFallbackRequest,
WebPlayerViewComponent,
} from '@iptvnator/ui/playback';
import {
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import { GridListComponent } from '@iptvnator/portal/shared/ui';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { PageEvent } from '@angular/material/paginator';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -78,10 +86,11 @@ class StubGridListComponent {
template: '',
})
class StubWebPlayerViewComponent {
readonly playbackSessionKey = input.required<string>();
readonly streamUrl = input('');
readonly title = input('');
readonly playback = input<unknown>(null);
readonly externalFallbackRequested = output<unknown>();
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
}
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
@@ -195,6 +204,7 @@ describe('LiveStreamLayoutComponent', () => {
};
const portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(true),
openExternalPlayback: jest.fn(),
};
const settingsStore = {
openStreamOnDoubleClick: signal(false),
@@ -206,6 +216,7 @@ describe('LiveStreamLayoutComponent', () => {
const originalElectron = window.electron;
beforeEach(async () => {
currentPlaylist.set(playlist);
jest.useFakeTimers();
jest.setSystemTime(fixedNow);
settingsStore.resolvedEpgViewMode.set('timeline');
@@ -240,6 +251,7 @@ describe('LiveStreamLayoutComponent', () => {
xtreamUrlService.resolveCatchupUrl.mockClear();
portalPlayer.isEmbeddedPlayer.mockReset();
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
portalPlayer.openExternalPlayback.mockClear();
epgItems.set([]);
currentEpgItem.set(null);
@@ -590,6 +602,58 @@ describe('LiveStreamLayoutComponent', () => {
).not.toBeNull();
});
it('keeps the host-owned live key across URL replacement and changes it with the channel', () => {
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
currentPlaylist.set({ ...playlist, id: 'playlist|one' });
const first = { ...sampleChannel, xtream_id: 101 };
component.playLive(first);
fixture.detectChanges();
const webPlayer = fixture.debugElement.query(
By.directive(StubWebPlayerViewComponent)
).componentInstance as StubWebPlayerViewComponent;
const expected = createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist|one',
contentId: 101,
});
expect(webPlayer.playbackSessionKey()).toBe(expected);
xtreamStore.constructStreamUrl.mockReturnValueOnce(
'https://example.com/replaced-timeshift.ts'
);
component.playLive(first);
fixture.detectChanges();
expect(webPlayer.playbackSessionKey()).toBe(expected);
component.playLive({ ...first, xtream_id: 102 });
fixture.detectChanges();
expect(webPlayer.playbackSessionKey()).not.toBe(expected);
});
it('forwards the exact resolved live playback to external fallback', () => {
const playback: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/fallback.m3u8',
title: 'Channel 101',
isLive: true,
headers: { Authorization: 'Bearer token' },
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 101,
contentType: 'live',
},
};
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
} as PlaybackFallbackRequest);
const [forwardedPlayback, forwardedPlayer] =
portalPlayer.openExternalPlayback.mock.calls[0];
expect(forwardedPlayback).toBe(playback);
expect(forwardedPlayer).toBe('mpv');
});
it('updates live root pagination from the header paginator', () => {
selectedCategoryId.set(null);
selectedTypeContentLoading.set(false);
@@ -794,17 +858,44 @@ describe('LiveStreamLayoutComponent', () => {
);
});
it('shows the active archive program in the live EPG panel summary', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
it('does not attach a pending catchup result to a newer live owner', async () => {
let resolveCatchup!: (url: string) => void;
xtreamUrlService.resolveCatchupUrl.mockReturnValueOnce(
new Promise((resolve) => {
resolveCatchup = resolve;
})
);
component.playLive(sampleChannel);
const catchup = component.onProgramActivated({
type: 'timeshift',
program: buildArchivedProgram(),
});
const newerChannel = {
...sampleChannel,
xtream_id: 202,
name: 'Channel 202',
};
xtreamStore.constructStreamUrl.mockReturnValueOnce(
'https://example.com/channel-202.ts'
);
component.playLive(newerChannel);
resolveCatchup('https://stale.example/channel-101-timeshift.ts');
await catchup;
expect(component.activePlayback()?.streamUrl).toBe(
'https://example.com/channel-202.ts'
);
expect(component.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'live',
sourceId: 'playlist-1',
contentId: 202,
})
);
});
it('shows the active archive program in the live EPG panel summary', async () => {
const archivedProgram = buildArchivedProgram();
epgItems.set([
buildEpgItem(
'archived',
@@ -854,16 +945,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('returns archive playback to the selected live stream from the panel action', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
currentEpgItem.set(
buildEpgItem(
'current',
@@ -893,7 +975,9 @@ describe('LiveStreamLayoutComponent', () => {
isLive: true,
})
);
expect(timeline.componentInstance.summary()?.title).toBe('Current Show');
expect(timeline.componentInstance.summary()?.title).toBe(
'Current Show'
);
expect(timeline.componentInstance.summaryLabelKey()).toBe(
'EPG.CURRENT_PROGRAM'
);
@@ -901,16 +985,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('publishes the active archive program in remote-control status', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
currentEpgItem.set(
buildEpgItem(
'current',
@@ -942,16 +1017,7 @@ describe('LiveStreamLayoutComponent', () => {
});
it('passes the active catchup program to the EPG list until live playback resumes', async () => {
const archivedProgram: EpgProgram = {
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
startTimestamp: 1775296800,
stopTimestamp: 1775300400,
};
const archivedProgram = buildArchivedProgram();
epgItems.set([
buildEpgItem(
'1',
@@ -1066,7 +1132,9 @@ describe('LiveStreamLayoutComponent', () => {
const timeline = fixture.debugElement.query(
By.directive(StubEpgTimelineComponent)
);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(
true
);
});
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
@@ -1259,6 +1327,15 @@ describe('LiveStreamLayoutComponent', () => {
});
});
const buildArchivedProgram = (): EpgProgram => ({
start: '2026-04-04T10:00:00.000Z',
stop: '2026-04-04T11:00:00.000Z',
channel: 'channel-101',
title: 'Archived Show',
desc: null,
category: null,
});
function buildEpgItem(
id: string,
title: string,
@@ -70,6 +70,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@@ -269,11 +270,20 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private playbackRequestId = 0;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
private readonly activeLiveItemId = signal<number | null>(null);
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.activeLiveItemId();
return sourceId && contentId
? createPlaybackSessionKey({ kind: 'live', sourceId, contentId })
: '';
});
readonly activeStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
@@ -435,12 +445,14 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
item: XtreamLiveChannelItem,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
) {
this.playbackRequestId += 1;
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.activeCatchupProgram.set(null);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
this.activePlayback.set({
streamUrl,
title: item.title ?? item.name ?? '',
@@ -541,6 +553,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.playbackRequestId += 1;
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
}
@@ -643,6 +656,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
return;
}
const requestId = ++this.playbackRequestId;
this.activeLiveItemId.set(item.xtream_id);
const catchupUrl = await this.xtreamUrlService.resolveCatchupUrl(
playlist.id,
{
@@ -656,6 +671,13 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
stopTimestamp,
playlist.serverTimezone
);
if (
requestId !== this.playbackRequestId ||
this.xtreamStore.currentPlaylist()?.id !== playlist.id ||
this.activeLiveItemId() !== item.xtream_id
) {
return;
}
this.activeCatchupProgram.set(program);
this.activePlayback.set({
@@ -71,6 +71,8 @@ export class SerialDetailsPlaybackService {
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly inlinePlaybackSessionEpisodeState =
signal<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null>(null);
readonly episodePlaybackPositions = this.playbackPositionState.positions;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
@@ -221,7 +223,13 @@ export class SerialDetailsPlaybackService {
contentInfo,
};
this.startPlayback(playback);
const episodeState = resolveSeriesPlaybackEpisodeState({
episodesBySeason: selectedItem.episodes,
currentEpisodeId: episode.id,
fallbackSeasonNumber: Number(episode.season),
fallbackEpisodeNumber: Number(episode.episode_num),
});
this.startPlayback(playback, episodeState);
}
playQuickStartEpisode(): void {
@@ -259,6 +267,7 @@ export class SerialDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.inlinePlaybackSessionEpisodeState.set(null);
this.lastSaveTime = 0;
}
@@ -350,9 +359,13 @@ export class SerialDetailsPlaybackService {
});
}
private startPlayback(playback: ResolvedPortalPlayback): void {
private startPlayback(
playback: ResolvedPortalPlayback,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): void {
this.lastSaveTime = 0;
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlaybackSessionEpisodeState.set(episodeState);
this.inlinePlayback.set(playback);
return;
}
@@ -179,6 +179,7 @@
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
detail-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[episodeMetadata]="inlineEpisodeMetadata()"
[seriesTitle]="item.info.name"
@@ -22,10 +22,11 @@ import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { EMPTY, of } from 'rxjs';
import { BehaviorSubject, EMPTY, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
import { SerialDetailsPlaybackService } from './serial-details-playback.service';
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-season-container',
@@ -54,6 +55,7 @@ class StubSeasonContainerComponent {
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
readonly episodeMetadata = input<unknown>(null);
readonly seriesTitle = input<string | null>(null);
@@ -108,6 +110,10 @@ describe('SerialDetailsComponent', () => {
let seriesResumeTarget: ReturnType<
typeof signal<SeriesResumeTarget | null>
>;
let routeParams: BehaviorSubject<{
categoryId: string;
serialId: string;
}>;
beforeEach(async () => {
window.history.replaceState({}, '', window.location.href);
@@ -174,6 +180,10 @@ describe('SerialDetailsComponent', () => {
getSeriesPlaybackPositions.mockClear();
getSeriesPlaybackPositions.mockResolvedValue([]);
seriesResumeTarget = signal<SeriesResumeTarget | null>(null);
routeParams = new BehaviorSubject({
categoryId: '3',
serialId: '103',
});
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
@@ -181,10 +191,7 @@ describe('SerialDetailsComponent', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({
categoryId: '3',
serialId: '103',
}),
params: routeParams,
snapshot: {
params: {
categoryId: '3',
@@ -846,6 +853,15 @@ describe('SerialDetailsComponent', () => {
seasonNumber: 1,
episodeNumber: 1,
});
const firstEpisodeKey = createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
expect(inlinePlayer.playbackSessionKey()).toBe(firstEpisodeKey);
expect(inlinePlayer.seriesNavigation()).toEqual({
canPrevious: false,
canNext: true,
@@ -874,6 +890,7 @@ describe('SerialDetailsComponent', () => {
canNext: false,
autoplayEnabled: true,
});
expect(inlinePlayer.playbackSessionKey()).not.toBe(firstEpisodeKey);
inlinePlayer.playbackEnded.emit();
fixture.detectChanges();
@@ -887,4 +904,59 @@ describe('SerialDetailsComponent', () => {
expect.objectContaining({ id: '2001' })
);
});
it('owns the parent identity from the route and ignores replacement playback payloads', async () => {
isEmbeddedPlayer.mockReturnValue(true);
fixture.detectChanges();
await fixture.whenStable();
const item = fixture.componentInstance.selectedItem();
const episode = item?.episodes?.['1'][0];
if (!item || !episode) {
throw new Error('Expected the serial fixture and first episode');
}
fixture.componentInstance.playEpisode(episode);
fixture.detectChanges();
const expected = createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 103,
seasonNumber: 1,
episodeNumber: 1,
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://alternative.example/replaced.mkv',
title: 'Alternative payload',
headers: { Authorization: 'Bearer replacement' },
contentInfo: {
playlistId: 'alternative-playlist',
contentXtreamId: 999001,
contentType: 'episode',
seriesXtreamId: 999,
seasonNumber: 9,
episodeNumber: 9,
},
});
expect(fixture.componentInstance.playbackSessionKey()).toBe(expected);
selectedItem.set({ ...item, series_id: 999 });
routeParams.next({ categoryId: '3', serialId: '104' });
fixture.detectChanges();
await fixture.whenStable();
fixture.componentInstance.playEpisode(episode);
fixture.detectChanges();
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'episode',
sourceId: 'xtream-1',
contentId: 1001,
seriesId: 104,
seasonNumber: 1,
episodeNumber: 1,
})
);
});
});
@@ -53,6 +53,7 @@ import {
} from '../tmdb-similar.util';
import { createXtreamSeriesDownloadMetadataContext } from './serial-download-metadata';
import { createXtreamSeriesDownloadAdapter } from './xtream-series-download.adapter';
import { createSerialPlaybackSessionKey } from './serial-playback-session-key';
@Component({
selector: 'app-serial-details',
@@ -152,6 +153,13 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly quickStartAction = this.playback.quickStartAction;
readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata;
readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation;
readonly playbackSessionKey = computed(() =>
createSerialPlaybackSessionKey(
this.xtreamStore.currentPlaylist()?.id,
this.routeParams().serialId,
this.playback.inlinePlaybackSessionEpisodeState()
)
);
/** "Up Next" rail entries for the inline player (series only). */
readonly upNextEpisodes = computed<UpNextRailItem[]>(() =>
buildUpNextRailItems({
@@ -0,0 +1,28 @@
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import type { SeriesPlaybackEpisodeState } from '@iptvnator/ui/playback';
export function createSerialPlaybackSessionKey(
sourceId: string | undefined,
seriesId: number | string | undefined,
episodeState: SeriesPlaybackEpisodeState<XtreamSerieEpisode> | null
): string {
const normalizedSeriesId = Number(seriesId);
if (
!sourceId ||
!Number.isSafeInteger(normalizedSeriesId) ||
normalizedSeriesId <= 0 ||
!episodeState
) {
return '';
}
return createPlaybackSessionKey({
kind: 'episode',
sourceId,
contentId: episodeState.episode.id,
seriesId: normalizedSeriesId,
seasonNumber: episodeState.seasonNumber,
episodeNumber: episodeState.episodeNumber,
});
}
@@ -27,6 +27,7 @@ import {
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { BehaviorSubject, NEVER, of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
@Component({
selector: 'app-portal-inline-player',
@@ -34,6 +35,7 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
template: '<div data-testid="inline-vod-player"></div>',
})
class StubPortalInlinePlayerComponent {
readonly playbackSessionKey = input.required<string>();
readonly playback = input<unknown>(null);
// Multi-source wiring: the real player takes these, so the stub has to
// accept them or the template binding fails to compile.
@@ -369,6 +371,25 @@ describe('VodDetailsRouteComponent fallback actions', () => {
thumbnail: 'https://example.com/catalog-poster.jpg',
})
);
const expectedKey = createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist-1',
contentId: 650020,
});
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
fixture.componentInstance.inlinePlayback.set({
streamUrl: 'https://copy.example/movie/9001.mkv',
title: 'Catalog movie',
headers: { 'User-Agent': 'Copy Provider' },
contentInfo: {
playlistId: 'copy-playlist',
contentXtreamId: 9001,
contentType: 'vod',
},
});
fixture.detectChanges();
expect(inlinePlayer.playbackSessionKey()).toBe(expectedKey);
expect(
host.querySelector('app-portal-detail-shell')?.classList
).toContain('shell-host--watch');
@@ -581,6 +602,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
selectedItem.set(richItem());
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(true);
const firstSessionKey = fixture.componentInstance.playbackSessionKey();
window.history.replaceState({}, '', window.location.href);
selectedItem.set({
@@ -594,6 +616,16 @@ describe('VodDetailsRouteComponent fallback actions', () => {
fixture.detectChanges();
expect(fixture.componentInstance.providerOnly()).toBe(false);
expect(fixture.componentInstance.playbackSessionKey()).not.toBe(
firstSessionKey
);
expect(fixture.componentInstance.playbackSessionKey()).toBe(
createPlaybackSessionKey({
kind: 'vod',
sourceId: 'playlist-1',
contentId: 650021,
})
);
});
it('plays a rich downloaded movie locally without a usable provider source', async () => {
@@ -254,6 +254,7 @@
@if (playableVodItem()) {
@if (inlinePlayback(); as playback) {
<app-portal-inline-player
[playbackSessionKey]="playbackSessionKey()"
[playback]="playback"
[alternativeSources]="multiSource.alternatives()"
[allSources]="multiSource.sources()"
@@ -72,6 +72,7 @@ import { VodDetailsDownloadsService } from './vod-details-downloads.service';
import { VodDetailsSimilarService } from './vod-details-similar.service';
import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
type XtreamVodIdentityItem = XtreamVodDetails & {
readonly id?: number | string;
@@ -162,6 +163,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
readonly isFavorite = this.xtreamStore.isFavorite;
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
readonly playbackSessionKey = computed(() => {
const sourceId = this.xtreamStore.currentPlaylist()?.id;
const contentId = this.selectedVodId();
return sourceId && Number.isFinite(contentId) && contentId > 0
? createPlaybackSessionKey({ kind: 'vod', sourceId, contentId })
: '';
});
readonly providerOnly = computed(() => {
this.routeParams();
return isProviderOnlyDetailState(window.history.state);
+1 -1
View File
@@ -5,7 +5,7 @@ export * from './lib/embedded-mpv-player/embedded-mpv-player.component';
export * from './lib/embedded-mpv-player/embedded-mpv-session-controller';
export * from './lib/external-player-info-dialog/external-player-info-dialog.component';
export * from './lib/html-video-player/html-video-player.component';
export * from './lib/playback-diagnostics/playback-diagnostics.util';
export * from '@iptvnator/playback/util';
export * from './lib/player-controls';
export * from './lib/portal-inline-player/portal-inline-player.component';
export * from './lib/portal-inline-player/series-playback-navigation';
@@ -1,4 +1,4 @@
import { getPlaybackMediaExtensionFromUrl } from '../playback-diagnostics/playback-diagnostics.util';
import { getPlaybackMediaExtensionFromUrl } from '@iptvnator/playback/util';
/**
* ArtPlayer option overrides for the legacy and shared-control surfaces.
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
} from '../playback-diagnostics/playback-diagnostics.model';
} from '@iptvnator/playback/util';
import {
createFakeShakaEnvironment,
flushShakaMicrotasks,
@@ -1,6 +1,6 @@
import type Artplayer from 'artplayer';
import type { ChannelDrm } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import type { ArtPlayerSourceSession as ArtPlayerSourceSessionInstance } from './art-player-source-session';
@@ -1,4 +1,4 @@
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
MockHls,
MockMpegTsPlayer,
@@ -90,6 +90,84 @@ describe('ArtPlayerSourceSession', () => {
expect(attach).not.toHaveBeenCalled();
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
});
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const emitted: PlaybackDiagnostic[] = [];
const { session, player, video } = createSession({
sharedControls: true,
emitPlaybackIssue: (issue) => emitted.push(issue),
});
session.attach(player);
session.customType['m3u8']?.(
video,
'https://example.test/live.m3u8',
player
);
try {
expect(() =>
hlsInstances[0].emit(MockHls.Events.MANIFEST_PARSED, null, {
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
})
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(emitted).toEqual([]);
});
it('uses authoritative VOD metadata for shared MPEG-TS and catches autoplay rejection', async () => {
const { session, player, video, adapter } = createSession({
sharedControls: true,
@@ -299,3 +377,11 @@ describe('ArtPlayerSourceSession', () => {
expect(refresh).toHaveBeenCalled();
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -12,7 +12,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
import type { WebVideoControlsAdapter } from '../player-controls';
import type { ShakaModuleLoader } from '../shaka-engine/shaka-module.types';
import { ShakaVideoSession } from '../shaka-engine/shaka-video-session';
@@ -307,7 +308,10 @@ export class ArtPlayerSourceSession {
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
this.config.emitPlaybackIssue(issue);
}
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
export interface ArtPlayerVideoSessionConfig {
player: Artplayer;
@@ -93,7 +93,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'https://example.com/archive/movie.mkv',
externalFallbackRecommended: true,
}),
]);
});
@@ -124,7 +123,6 @@ describe('ArtPlayerComponent', () => {
code: 'unsupported-codec',
source: 'hls',
sourceUrl: 'https://example.com/live/playlist.m3u8',
externalFallbackRecommended: true,
}),
]);
});
@@ -256,7 +254,6 @@ describe('ArtPlayerComponent', () => {
engineDetails: 'CodecUnsupported',
failure: 'codec',
}),
externalFallbackRecommended: true,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -13,7 +13,7 @@ import {
} from '@angular/core';
import Artplayer from 'artplayer';
import { Channel, createDevLogger } from '@iptvnator/shared/interfaces';
import type { PlaybackDiagnostic } from '../playback-diagnostics/playback-diagnostics.util';
import type { PlaybackDiagnostic } from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -4,7 +4,7 @@ import {
type PlaybackDiagnostic,
classifyNativePlaybackIssue,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
const debugHtmlPlayer = createDevLogger('HtmlVideoPlayer');
@@ -9,7 +9,8 @@ import {
createHlsPlaybackEvidence,
createMpegTsPlaybackEvidence,
createPlaybackSourceMetadata,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import { isBrowserMediaTypeSupported } from '../web-video-support/browser-media-type-support';
/**
* HTML5-player diagnostics glue extracted from the component: builds source
@@ -46,7 +47,10 @@ export function emitUnsupportedHlsManifestCodecs(
.map((level) => level.videoCodec)
.filter((codec): codec is string => Boolean(codec))
);
const issue = classifyUnsupportedHlsManifestCodecs(metadata);
const issue = classifyUnsupportedHlsManifestCodecs(
metadata,
isBrowserMediaTypeSupported
);
if (issue) {
emitPlaybackIssue(issue);
}
@@ -154,7 +154,6 @@ describe('HtmlVideoPlayerComponent shared controls sources', () => {
failure: 'http',
httpStatus: 404,
}),
externalFallbackRecommended: false,
}),
]);
expect(JSON.stringify(issues)).not.toContain(secret);
@@ -4,7 +4,12 @@ import { By } from '@angular/platform-browser';
import { TranslateModule } from '@ngx-translate/core';
import { DataService } from '@iptvnator/services';
import { Channel } from '@iptvnator/shared/interfaces';
import { ErrorDetails, ErrorTypes, type ErrorData } from 'hls.js';
import {
ErrorDetails,
ErrorTypes,
type ErrorData,
type ManifestParsedData,
} from 'hls.js';
import {
PlayerControlsComponent,
WebVideoControlsAdapter,
@@ -198,11 +203,90 @@ describe('HtmlVideoPlayerComponent', () => {
code: 'unsupported-container',
source: 'native',
sourceUrl: 'http://test.ts',
externalFallbackRecommended: true,
}),
]);
});
it('uses browser media-type support for HLS manifest codec diagnostics', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: { isTypeSupported: jest.fn(() => false) },
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [{ audioCodec: 'ac-3', videoCodec: 'avc1.64001f' }],
} as ManifestParsedData
);
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([
expect.objectContaining({
code: 'unsupported-codec',
source: 'source',
}),
]);
});
it('keeps HLS manifest handling alive when the browser support probe throws', () => {
const mediaSourceDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'MediaSource'
);
Object.defineProperty(globalThis, 'MediaSource', {
configurable: true,
value: {
isTypeSupported: () => {
throw new Error('provider-controlled-codec');
},
},
});
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => issues.push(issue));
try {
expect(() =>
(
component as unknown as {
handleHlsManifestParsed: (
url: string,
data: ManifestParsedData
) => void;
}
).handleHlsManifestParsed(
'https://example.com/live/playlist.m3u8',
{
levels: [
{
audioCodec: 'provider-controlled-codec',
videoCodec: 'avc1.64001f',
},
],
} as ManifestParsedData
)
).not.toThrow();
} finally {
restoreMediaSource(mediaSourceDescriptor);
}
expect(issues).toEqual([]);
});
it('does not emit a playback issue when HLS.js reports a recoverable error', () => {
const issues: unknown[] = [];
component.playbackIssue.subscribe((issue) => {
@@ -355,3 +439,11 @@ describe('HtmlVideoPlayerComponent', () => {
expect(events).toEqual(['previous']);
});
});
function restoreMediaSource(descriptor?: PropertyDescriptor): void {
if (descriptor) {
Object.defineProperty(globalThis, 'MediaSource', descriptor);
return;
}
Reflect.deleteProperty(globalThis, 'MediaSource');
}
@@ -21,7 +21,7 @@ import {
InlinePlaybackPlayer,
PlaybackDiagnostic,
getPlaybackMediaExtensionFromUrl,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
import {
PlayerControlsComponent,
type PlayerMediaTitle,
@@ -0,0 +1,184 @@
@let issue = diagnostic();
<section
class="web-player-diagnostic"
data-test-id="playback-diagnostic-banner"
role="status"
>
<div class="web-player-diagnostic__content">
<div class="web-player-diagnostic__badge">
<mat-icon aria-hidden="true">warning</mat-icon>
<span>{{ getDiagnosticTitleKey(issue) | translate }}</span>
</div>
<h2 class="web-player-diagnostic__headline">
{{ diagnosticHeadlineKey() | translate }}
</h2>
<div class="web-player-diagnostic__description">
{{ getDiagnosticDescriptionKey(issue) | translate }}
</div>
@if (getDiagnosticMeta(issue); as meta) {
<div class="web-player-diagnostic__meta">
{{ meta }}
</div>
}
@if (getDiagnosticCodecHint(issue); as codecHint) {
<div
class="web-player-diagnostic__codec-hint"
data-test-id="playback-codec-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.CODEC_HINT'
| translate: { codecs: codecHint }
}}
</div>
}
<div
class="web-player-diagnostic__recommendations"
role="group"
[attr.aria-label]="
'PLAYBACK_DIAGNOSTICS.RECOMMENDATIONS_LABEL' | translate
"
>
@for (
recommendation of recommendations();
track getRecommendationKey(recommendation)
) {
@if (recommendation.action === 'alternative-source') {
<fieldset
class="web-player-diagnostic__alternatives dark-theme"
[class.web-player-diagnostic__alternatives--primary]="
recommendation.priority === 'primary'
"
[disabled]="pending()"
data-test-id="playback-alternative-sources"
>
<legend
class="web-player-diagnostic__alternatives-title"
>
{{
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
| translate
}}
</legend>
@for (
source of visibleAlternatives();
track source.id
) {
<app-vod-source-row
[source]="source"
[showPin]="false"
(playRequested)="
alternativeSourceRequested.emit($event)
"
(checkRequested)="
sourceCheckRequested.emit($event)
"
/>
}
@if (hiddenAlternativeCount() > 0) {
<p class="web-player-diagnostic__alternatives-more">
{{
'PORTALS.MULTI_SOURCE.MORE_SOURCES'
| translate
: {
count: hiddenAlternativeCount(),
}
}}
</p>
}
</fieldset>
} @else {
<button
type="button"
class="web-player-diagnostic__player-card"
[class.web-player-diagnostic__player-card--primary]="
recommendation.priority === 'primary'
"
[attr.data-test-id]="
getRecommendationTestId(recommendation)
"
[disabled]="pending()"
(click)="activate(recommendation)"
>
<mat-icon aria-hidden="true">{{
getRecommendationIcon(recommendation)
}}</mat-icon>
<span class="web-player-diagnostic__player-copy">
<span class="web-player-diagnostic__player-label">
{{
getRecommendationLabelKey(recommendation)
| translate
: getRecommendationParams(
recommendation
)
}}
</span>
<span class="web-player-diagnostic__player-hint">
{{
getRecommendationReasonKey(
recommendation.reason
) | translate
}}
</span>
@if (
isTemporaryBuiltInRecommendation(recommendation)
) {
<span
class="web-player-diagnostic__player-hint"
>
{{
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER_HINT'
| translate
}}
</span>
}
</span>
</button>
}
}
</div>
<div class="web-player-diagnostic__utilities">
<button
class="web-player-diagnostic__utility-button"
type="button"
[cdkCopyToClipboard]="playback().streamUrl"
[matTooltip]="playback().streamUrl"
data-test-id="playback-copy-url"
>
<mat-icon aria-hidden="true">content_copy</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL' | translate }}
</button>
<span
class="web-player-diagnostic__divider"
aria-hidden="true"
></span>
<details
class="web-player-diagnostic__details"
data-test-id="playback-diagnostic-details"
>
<summary>
<mat-icon aria-hidden="true">expand_more</mat-icon>
{{ 'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY' | translate }}
</summary>
<dl>
@for (
detail of getDiagnosticDetails(issue);
track detail.labelKey
) {
<div class="web-player-diagnostic__detail-row">
<dt>{{ detail.labelKey | translate }}</dt>
<dd>{{ detail.value }}</dd>
</div>
}
</dl>
</details>
</div>
</div>
</section>
@@ -0,0 +1,530 @@
:host {
display: contents;
}
.web-player-diagnostic {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
padding: clamp(24px, 9vh, 92px) clamp(20px, 18vw, 216px);
overflow: auto;
background:
radial-gradient(
circle at 18% 18%,
rgb(255 176 76 / 6%),
transparent 28%
),
rgb(0 0 0 / 96%);
color: #f6f6f6;
}
.web-player-diagnostic button,
.web-player-diagnostic summary {
app-region: no-drag;
}
.web-player-diagnostic__content {
width: min(100%, 720px);
min-width: 0;
}
.web-player-diagnostic__badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 8px;
padding: 5px 11px;
border: 1px solid rgb(255 174 66 / 42%);
border-radius: 6px;
background: rgb(255 148 35 / 15%);
color: #ffb24c;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.web-player-diagnostic__badge mat-icon {
width: 16px;
height: 16px;
flex: 0 0 16px;
font-size: 16px;
}
.web-player-diagnostic__badge span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__headline {
max-width: 660px;
margin: 20px 0 0;
color: #fff;
font-size: 1.65rem;
font-weight: 760;
letter-spacing: 0;
line-height: 1.05;
}
.web-player-diagnostic__description {
max-width: 760px;
margin-top: 12px;
color: rgb(255 255 255 / 73%);
font-size: 1rem;
line-height: 1.48;
}
.web-player-diagnostic__meta {
margin-top: 9px;
color: rgb(255 255 255 / 48%);
font-family: var(--app-monospace-font, monospace);
font-size: 0.82rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.web-player-diagnostic__codec-hint {
display: inline-block;
max-width: 100%;
margin-top: 12px;
padding: 4px 8px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
font-weight: 620;
line-height: 1.3;
overflow-wrap: anywhere;
}
.web-player-diagnostic__recommendations {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__recommendations:empty {
display: none;
}
.web-player-diagnostic__alternatives {
/* The diagnostic surface is always dark, independent of the application
* theme. Map every source-row surface and foreground locally so light-theme
* app tokens cannot leak dark text into this overlay. */
--playback-diagnostic-source-surface: #161b24;
--playback-diagnostic-source-foreground: #f3f6fb;
--playback-diagnostic-source-muted: #b9c2d3;
--playback-diagnostic-source-accent: #8bb7ff;
--playback-diagnostic-source-on-accent: #151515;
--playback-diagnostic-source-hover: #252d3b;
--playback-diagnostic-source-selected: #243b5c;
--playback-diagnostic-source-border: #657086;
--playback-diagnostic-source-ok-foreground: #4ade80;
--playback-diagnostic-source-ok-surface: #173928;
--playback-diagnostic-source-warn-foreground: #ffcf99;
--playback-diagnostic-source-warn-surface: #422e1d;
--playback-diagnostic-source-bad-foreground: #ff9aa6;
--playback-diagnostic-source-bad-surface: #451f27;
--app-on-surface: var(--playback-diagnostic-source-foreground);
--app-eyebrow-color: var(--playback-diagnostic-source-muted);
--app-card-hover-bg: var(--playback-diagnostic-source-hover);
--app-selection-color: var(--playback-diagnostic-source-accent);
--app-selection-surface: var(--playback-diagnostic-source-selected);
--app-widget-bg: var(--playback-diagnostic-source-surface);
--app-widget-border: var(--playback-diagnostic-source-border);
--mat-sys-on-primary: var(--playback-diagnostic-source-on-accent);
width: min(420px, 100%);
min-width: 0;
grid-column: 1 / -1;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: var(--playback-diagnostic-source-surface);
text-align: left;
max-height: min(46vh, 320px);
overflow-y: auto;
}
/* VodSourceRow defines status variables on its own host. This more-specific,
* panel-owned mapping keeps those tags on the same dark-overlay contract. */
.web-player-diagnostic__alternatives app-vod-source-row {
--vod-source-ok-fg: var(--playback-diagnostic-source-ok-foreground);
--vod-source-ok-bg: var(--playback-diagnostic-source-ok-surface);
--vod-source-warn-fg: var(--playback-diagnostic-source-warn-foreground);
--vod-source-warn-bg: var(--playback-diagnostic-source-warn-surface);
--vod-source-bad-fg: var(--playback-diagnostic-source-bad-foreground);
--vod-source-bad-bg: var(--playback-diagnostic-source-bad-surface);
}
.web-player-diagnostic__alternatives--primary {
outline: 1px solid rgb(255 255 255 / 34%);
outline-offset: 1px;
}
.web-player-diagnostic__alternatives:disabled {
opacity: 0.56;
}
.web-player-diagnostic__alternatives-more {
margin: 0;
padding: 6px 12px 8px;
color: rgb(255 255 255 / 45%);
font-size: 0.75rem;
}
.web-player-diagnostic__alternatives-title {
margin: 0;
padding: 8px 12px 4px;
color: rgb(255 255 255 / 55%);
font-size: 0.594rem;
font-weight: 600;
letter-spacing: 0.12em;
overflow-wrap: anywhere;
text-transform: uppercase;
}
.web-player-diagnostic__player-card {
display: grid;
min-width: 0;
min-height: 60px;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid rgb(255 255 255 / 13%);
border-radius: 8px;
background: rgb(0 0 0 / 22%);
color: #f5f5f5;
cursor: pointer;
text-align: left;
transition:
background-color 140ms ease,
border-color 140ms ease,
color 140ms ease,
transform 140ms ease;
}
.web-player-diagnostic__player-card:hover {
border-color: rgb(255 255 255 / 28%);
background: rgb(255 255 255 / 6%);
}
.web-player-diagnostic__player-card:active {
transform: translateY(1px);
}
.web-player-diagnostic__player-card:disabled {
cursor: wait;
opacity: 0.56;
transform: none;
}
.web-player-diagnostic__player-card:focus-visible,
.web-player-diagnostic__utility-button:focus-visible,
.web-player-diagnostic__details summary:focus-visible {
outline: 2px solid #ffb24c;
outline-offset: 3px;
}
.web-player-diagnostic__player-card--primary {
border-color: transparent;
background: #fff;
color: #151515;
}
.web-player-diagnostic__player-card--primary:hover {
border-color: transparent;
background: rgb(255 255 255 / 88%);
}
.web-player-diagnostic__player-card mat-icon {
width: 20px;
height: 20px;
font-size: 20px;
}
.web-player-diagnostic__player-copy {
display: grid;
min-width: 0;
gap: 2px;
}
.web-player-diagnostic__player-label {
overflow: hidden;
font-size: 0.95rem;
font-weight: 760;
line-height: 1.05;
text-overflow: ellipsis;
white-space: nowrap;
}
.web-player-diagnostic__player-hint {
min-width: 0;
overflow: visible;
overflow-wrap: anywhere;
color: currentColor;
font-size: 0.78rem;
line-height: 1.1;
opacity: 0.62;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px 12px;
margin-top: 22px;
color: rgb(255 255 255 / 45%);
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
display: inline-flex;
min-height: 24px;
align-items: center;
gap: 6px;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
font-size: 0.86rem;
font-weight: 640;
line-height: 1.2;
}
.web-player-diagnostic__utility-button:hover,
.web-player-diagnostic__details summary:hover {
color: rgb(255 255 255 / 76%);
}
.web-player-diagnostic__utility-button mat-icon,
.web-player-diagnostic__details summary mat-icon {
width: 16px;
height: 16px;
font-size: 16px;
}
.web-player-diagnostic__divider {
width: 1px;
height: 18px;
margin-top: 3px;
background: rgb(255 255 255 / 16%);
}
.web-player-diagnostic__details {
min-width: 0;
max-width: 100%;
}
.web-player-diagnostic__details summary {
list-style: none;
}
.web-player-diagnostic__details summary::-webkit-details-marker {
display: none;
}
.web-player-diagnostic__details[open] {
flex-basis: 100%;
}
.web-player-diagnostic__details dl {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 7px 14px;
max-width: min(100%, 640px);
margin: 12px 0 0;
padding: 12px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 8px;
background: rgb(255 255 255 / 5%);
color: rgb(255 255 255 / 68%);
font-size: 0.78rem;
line-height: 1.3;
}
.web-player-diagnostic__detail-row {
display: contents;
}
.web-player-diagnostic__detail-row dt {
color: rgb(255 255 255 / 48%);
font-weight: 700;
white-space: nowrap;
}
.web-player-diagnostic__detail-row dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
@container (max-width: 760px) {
.web-player-diagnostic {
align-items: flex-start;
padding: 22px 16px;
}
.web-player-diagnostic__badge,
.web-player-diagnostic__badge span {
white-space: normal;
}
.web-player-diagnostic__headline {
margin-top: 16px;
font-size: 1.48rem;
}
.web-player-diagnostic__utilities {
gap: 10px 12px;
}
.web-player-diagnostic__details dl {
grid-template-columns: minmax(0, 1fr);
max-width: 100%;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 520px) {
.web-player-diagnostic {
align-items: flex-start;
padding: clamp(14px, 5cqi, 22px);
}
.web-player-diagnostic__content {
width: 100%;
}
.web-player-diagnostic__badge {
gap: 6px;
padding: 4px 8px;
font-size: 0.7rem;
white-space: normal;
}
.web-player-diagnostic__badge span {
white-space: nowrap;
}
.web-player-diagnostic__headline {
margin-top: 14px;
font-size: 1.35rem;
line-height: 1.08;
}
.web-player-diagnostic__description {
margin-top: 10px;
font-size: 0.9rem;
line-height: 1.38;
}
.web-player-diagnostic__meta {
margin-top: 8px;
font-size: 0.74rem;
}
.web-player-diagnostic__codec-hint {
margin-top: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__recommendations {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
.web-player-diagnostic__alternatives {
width: 100%;
}
.web-player-diagnostic__player-card {
min-height: 52px;
padding: 10px 12px;
}
.web-player-diagnostic__player-label {
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.web-player-diagnostic__utilities {
display: grid;
gap: 7px;
margin-top: 16px;
}
.web-player-diagnostic__divider {
display: none;
}
.web-player-diagnostic__utility-button,
.web-player-diagnostic__details summary {
width: fit-content;
min-height: 22px;
font-size: 0.82rem;
}
.web-player-diagnostic__details[open] {
flex-basis: auto;
}
.web-player-diagnostic__details dl {
max-height: 260px;
overflow: auto;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
margin-top: 8px;
padding: 10px;
font-size: 0.72rem;
}
.web-player-diagnostic__detail-row {
display: grid;
gap: 2px;
}
.web-player-diagnostic__detail-row dt {
white-space: normal;
}
}
@container (max-width: 340px) {
.web-player-diagnostic {
padding: 12px;
}
.web-player-diagnostic__headline {
font-size: 1.22rem;
}
.web-player-diagnostic__description {
font-size: 0.84rem;
}
.web-player-diagnostic__player-label {
font-size: 0.88rem;
}
}
@@ -0,0 +1,619 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
PlaybackRecommendationReason,
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { PlaybackDiagnosticPanelComponent } from './playback-diagnostic-panel.component';
const DIAGNOSTIC: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedContainer,
source: PlaybackDiagnosticSource.Source,
sourceUrl: 'https://example.com/archive/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: [],
videoCodecs: [],
};
const PLAYBACK: ResolvedPortalPlayback = {
streamUrl: 'https://example.com/archive/movie.mkv',
title: 'Example Movie',
};
const PANEL_STYLE_SOURCE = readFileSync(
resolve(
process.cwd(),
'libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss'
),
'utf8'
);
function contrastRatio(foreground: string, background: string): number {
const luminance = (hex: string): number => {
const channels = [1, 3, 5].map((offset) =>
Number.parseInt(hex.slice(offset, offset + 2), 16)
);
const [red, green, blue] = channels.map((channel) => {
const normalized = channel / 255;
return normalized <= 0.04045
? normalized / 12.92
: ((normalized + 0.055) / 1.055) ** 2.4;
});
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
};
const values = [luminance(foreground), luminance(background)].sort(
(left, right) => right - left
);
return (values[0] + 0.05) / (values[1] + 0.05);
}
function recommendation(
action: PlaybackRecommendation['action'],
overrides: Partial<PlaybackRecommendation> = {}
): PlaybackRecommendation {
if (action === 'retry') {
return {
action,
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
if (action === 'alternative-source') {
return {
action,
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
return {
action,
target: 'html5',
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority: 'secondary',
...overrides,
} as PlaybackRecommendation;
}
function source(index: number): VodSourceDescriptor {
return {
id: `playlist-${index}:xtream:${index}`,
playlistId: `playlist-${index}`,
playlistName: `Portal ${index}`,
portalType: 'xtream',
contentId: index,
rawTitle: `Example Movie ${index}`,
matchConfidence: 'exact',
isActive: false,
isPinned: false,
isTried: false,
probe: { status: 'idle' },
};
}
describe('PlaybackDiagnosticPanelComponent', () => {
let fixture: ComponentFixture<PlaybackDiagnosticPanelComponent>;
let component: PlaybackDiagnosticPanelComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
PlaybackDiagnosticPanelComponent,
TranslateModule.forRoot(),
],
})
.overrideComponent(PlaybackDiagnosticPanelComponent, {
add: { styles: [PANEL_STYLE_SOURCE] },
})
.compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
PLAYBACK_DIAGNOSTICS: {
ACTION_TRY_PLAYER: 'Try {{player}}',
ACTION_TRY_PLAYER_HINT:
'This temporary player change applies only to the current playback session and leaves your saved preference unchanged.',
REASON_DIFFERENT_ENGINE_FAMILY:
'This player uses a different playback engine that may support the stream format more reliably.',
RECOMMENDATIONS_LABEL: 'Recommended recovery actions',
},
PORTALS: {
MULTI_SOURCE: {
MORE_SOURCES: '{{count}} more sources',
TRY_ANOTHER_SOURCE: 'Try another source',
},
},
});
translate.use('en');
fixture = TestBed.createComponent(PlaybackDiagnosticPanelComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('diagnostic', DIAGNOSTIC);
fixture.componentRef.setInput('recommendations', []);
fixture.componentRef.setInput('playback', PLAYBACK);
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('playbackExternallyTransferable', true);
});
it('applies the component host style from the actual panel stylesheet', () => {
fixture.detectChanges();
const host = fixture.nativeElement as HTMLElement;
expect(getComputedStyle(host).display).toBe('contents');
});
it('renders supplied policy-ranked action buttons in order with priority styling', () => {
const rankedRecommendations = [
recommendation('player', {
target: 'html5',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('retry'),
];
fixture.componentRef.setInput('recommendations', rankedRecommendations);
fixture.detectChanges();
const cards = fixture.debugElement.queryAll(
By.css('.web-player-diagnostic__player-card')
);
expect(cards.map((card) => card.attributes['data-test-id'])).toEqual([
'playback-recommendation-html5',
'playback-fallback-mpv',
'playback-retry',
]);
expect(
cards.map(
(card) =>
card.classes[
'web-player-diagnostic__player-card--primary'
] ?? false
)
).toEqual([true, false, false]);
for (const card of cards) {
const button = card.nativeElement as HTMLButtonElement;
expect(button.tagName).toBe('BUTTON');
expect(button.getAttribute('type')).toBe('button');
expect(button.type).toBe('button');
}
});
it('exposes the recommendation set and alternatives with native grouping semantics', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const group = fixture.nativeElement.querySelector(
'.web-player-diagnostic__recommendations'
) as HTMLElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const legend = alternatives.firstElementChild as HTMLElement;
expect(group.getAttribute('role')).toBe('group');
expect(group.getAttribute('aria-label')).toBe(
'Recommended recovery actions'
);
expect(legend.tagName).toBe('LEGEND');
expect(legend.classList).toContain(
'web-player-diagnostic__alternatives-title'
);
expect(legend.textContent).toContain('Try another source');
});
it('provides a high-contrast dark token contract to alternative source rows', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const styles = getComputedStyle(alternatives);
const token = (name: string): string =>
styles.getPropertyValue(name).trim();
const expectedTokens: Readonly<Record<string, string>> = {
'--playback-diagnostic-source-surface': '#161b24',
'--playback-diagnostic-source-foreground': '#f3f6fb',
'--playback-diagnostic-source-muted': '#b9c2d3',
'--playback-diagnostic-source-accent': '#8bb7ff',
'--playback-diagnostic-source-on-accent': '#151515',
'--playback-diagnostic-source-hover': '#252d3b',
'--playback-diagnostic-source-selected': '#243b5c',
'--playback-diagnostic-source-border': '#657086',
'--playback-diagnostic-source-ok-foreground': '#4ade80',
'--playback-diagnostic-source-ok-surface': '#173928',
'--playback-diagnostic-source-warn-foreground': '#ffcf99',
'--playback-diagnostic-source-warn-surface': '#422e1d',
'--playback-diagnostic-source-bad-foreground': '#ff9aa6',
'--playback-diagnostic-source-bad-surface': '#451f27',
};
expect(alternatives.classList).toContain('dark-theme');
for (const [name, value] of Object.entries(expectedTokens)) {
expect(token(name)).toBe(value);
}
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-on-surface:\s*var\(--playback-diagnostic-source-foreground\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-eyebrow-color:\s*var\(--playback-diagnostic-source-muted\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-card-hover-bg:\s*var\(--playback-diagnostic-source-hover\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-surface:\s*var\(--playback-diagnostic-source-selected\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-selection-color:\s*var\(--playback-diagnostic-source-accent\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-border:\s*var\(--playback-diagnostic-source-border\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--app-widget-bg:\s*var\(--playback-diagnostic-source-surface\)/
);
expect(PANEL_STYLE_SOURCE).toMatch(
/--mat-sys-on-primary:\s*var\(--playback-diagnostic-source-on-accent\)/
);
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-ok-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-warn-fg:');
expect(PANEL_STYLE_SOURCE).toContain('--vod-source-bad-fg:');
expect(PANEL_STYLE_SOURCE).toMatch(
/web-player-diagnostic__alternatives app-vod-source-row[\s\S]*--vod-source-ok-fg:/
);
const color = (name: string): string => expectedTokens[name];
const surface = color('--playback-diagnostic-source-surface');
for (const foreground of [
'--playback-diagnostic-source-foreground',
'--playback-diagnostic-source-muted',
'--playback-diagnostic-source-accent',
]) {
expect(
contrastRatio(color(foreground), surface)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(color('--playback-diagnostic-source-border'), surface)
).toBeGreaterThanOrEqual(3);
for (const tone of ['ok', 'warn', 'bad']) {
expect(
contrastRatio(
color(`--playback-diagnostic-source-${tone}-foreground`),
color(`--playback-diagnostic-source-${tone}-surface`)
)
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(
color('--playback-diagnostic-source-on-accent'),
color('--playback-diagnostic-source-accent')
)
).toBeGreaterThanOrEqual(4.5);
});
it('disables recommendation buttons and source controls while pending', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.componentRef.setInput('pending', true);
fixture.detectChanges();
const retry = fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement;
const alternatives = fixture.nativeElement.querySelector(
'[data-test-id="playback-alternative-sources"]'
) as HTMLFieldSetElement;
const sourcePlay = fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement;
expect(retry.disabled).toBe(true);
expect(alternatives.disabled).toBe(true);
expect(sourcePlay.matches(':disabled')).toBe(true);
});
it('bounds alternative sources to five and reports the hidden count', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput(
'alternativeSources',
Array.from({ length: 7 }, (_, index) => source(index + 1))
);
fixture.detectChanges();
expect(
fixture.debugElement.queryAll(By.css('app-vod-source-row'))
).toHaveLength(5);
expect(
fixture.nativeElement.querySelector(
'.web-player-diagnostic__alternatives-more'
).textContent
).toContain('2');
});
it('emits exact retry, player, source play, and source check requests', () => {
const retries: void[] = [];
const players: PlaybackRecommendationTarget[] = [];
const playedSources: string[] = [];
const checkedSources: string[] = [];
component.retryRequested.subscribe(() => retries.push(undefined));
component.playerRequested.subscribe((target) => players.push(target));
component.alternativeSourceRequested.subscribe((id) =>
playedSources.push(id)
);
component.sourceCheckRequested.subscribe((id) =>
checkedSources.push(id)
);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'artplayer',
priority: 'primary',
}),
recommendation('retry'),
recommendation('alternative-source'),
]);
fixture.componentRef.setInput('alternativeSources', [source(8)]);
fixture.detectChanges();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-artplayer"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-row__action--play'
) as HTMLButtonElement
).click();
(
fixture.nativeElement.querySelector(
'.source-tag--action'
) as HTMLButtonElement
).click();
expect(players).toEqual(['artplayer']);
expect(retries).toEqual([undefined]);
expect(playedSources).toEqual(['playlist-8:xtream:8']);
expect(checkedSources).toEqual(['playlist-8:xtream:8']);
});
it('always renders Copy URL and Technical details utilities', () => {
fixture.detectChanges();
const copy = fixture.nativeElement.querySelector(
'[data-test-id="playback-copy-url"]'
) as HTMLButtonElement;
const details = fixture.nativeElement.querySelector(
'[data-test-id="playback-diagnostic-details"]'
) as HTMLDetailsElement;
expect(copy).not.toBeNull();
expect(copy.type).toBe('button');
expect(copy.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.ACTION_COPY_URL'
);
expect(details).not.toBeNull();
expect(details.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.DETAILS_SUMMARY'
);
});
it('uses runtime capability for browser-access guidance without implying an external action', () => {
const browserIssue: PlaybackDiagnostic = {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
};
fixture.componentRef.setInput('diagnostic', browserIssue);
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('recommendations', []);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"], [data-test-id="playback-fallback-vlc"]'
)
).toBeNull();
});
it('uses PWA browser-access guidance when managed external players are unavailable', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', false);
fixture.componentRef.setInput('recommendations', []);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION'
);
});
it('uses a native headline only when an external player is ranked', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.BrowserAccessError,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'vlc',
priority: 'primary',
reason: PlaybackRecommendationReason.ExternalBrowserAccess,
}),
]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION'
);
});
it('uses a neutral headline for untransferable DRM without a ranked external target', () => {
fixture.componentRef.setInput('diagnostic', {
...DIAGNOSTIC,
code: PlaybackDiagnosticCode.DrmOrEncryption,
});
fixture.componentRef.setInput('supportsManagedExternalPlayers', true);
fixture.componentRef.setInput(
'playbackExternallyTransferable',
false
);
fixture.componentRef.setInput('recommendations', [
recommendation('alternative-source', { priority: 'primary' }),
]);
fixture.componentRef.setInput('alternativeSources', [source(1)]);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain(
'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE'
);
expect(fixture.nativeElement.textContent).not.toContain(
'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE'
);
});
it('shows the temporary hint only for built-in player targets', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
recommendation('player', { target: 'mpv' }),
recommendation('player', { target: 'vlc' }),
]);
fixture.detectChanges();
const builtIn = fixture.nativeElement.querySelector(
'[data-test-id="playback-recommendation-videojs"]'
) as HTMLButtonElement;
const mpv = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-mpv"]'
) as HTMLButtonElement;
const vlc = fixture.nativeElement.querySelector(
'[data-test-id="playback-fallback-vlc"]'
) as HTMLButtonElement;
expect(builtIn.textContent).toContain(
'leaves your saved preference unchanged'
);
expect(mpv.textContent).not.toContain(
'leaves your saved preference unchanged'
);
expect(vlc.textContent).not.toContain(
'leaves your saved preference unchanged'
);
});
it('wraps long translated reasons and temporary hints without clipping', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('player', {
target: 'videojs',
priority: 'primary',
}),
]);
fixture.detectChanges();
const hints = Array.from(
fixture.nativeElement.querySelectorAll(
'.web-player-diagnostic__player-hint'
) as NodeListOf<HTMLElement>
);
expect(hints).toHaveLength(2);
expect(hints[0].textContent).toContain(
'different playback engine that may support the stream format'
);
expect(hints[1].textContent).toContain(
'leaves your saved preference unchanged'
);
for (const hint of hints) {
const styles = getComputedStyle(hint);
expect(styles.whiteSpace).toBe('normal');
expect(styles.overflowWrap).toBe('anywhere');
expect(styles.textOverflow).toBe('clip');
expect(styles.overflow).not.toBe('hidden');
}
});
it('uses native focus order without autofocus or a focus trap', () => {
fixture.componentRef.setInput('recommendations', [
recommendation('retry', { priority: 'primary' }),
]);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('[autofocus], [cdkTrapFocus]')
).toBeNull();
expect(document.activeElement).not.toBe(
fixture.nativeElement.querySelector(
'[data-test-id="playback-retry"]'
)
);
});
it('declares focus, container-query, and drag-region CSS contracts for browser integration', () => {
const styles = PANEL_STYLE_SOURCE;
expect(styles).toContain(
'.web-player-diagnostic__player-card:focus-visible'
);
expect(styles).toContain('outline: 2px solid #ffb24c');
expect(styles).toContain('@container (max-width: 520px)');
expect(styles).toMatch(
/web-player-diagnostic__recommendations[\s\S]*grid-template-columns:\s*minmax\(0, 1fr\)/
);
expect(styles).toContain('flex-wrap: wrap');
expect(styles).toContain('min-width: 0');
expect(styles).toContain('overflow-wrap: anywhere');
expect(styles).toContain('app-region: no-drag');
});
});
@@ -0,0 +1,126 @@
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslateModule } from '@ngx-translate/core';
import {
type PlaybackDiagnostic,
type PlaybackRecommendation,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import type {
ResolvedPortalPlayback,
VodSourceDescriptor,
} from '@iptvnator/shared/interfaces';
import { VodSourceRowComponent } from '@iptvnator/ui/components';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isExternalPlayerRecommendation,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
/** How many recovery options the error screen shows before it stops helping. */
const ERROR_SCREEN_ALTERNATIVES = 5;
@Component({
selector: 'app-playback-diagnostic-panel',
templateUrl: './playback-diagnostic-panel.component.html',
styleUrl: './playback-diagnostic-panel.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ClipboardModule,
MatIconModule,
MatTooltipModule,
TranslateModule,
VodSourceRowComponent,
],
host: { class: 'playback-diagnostic-panel' },
})
export class PlaybackDiagnosticPanelComponent {
readonly diagnostic = input.required<PlaybackDiagnostic>();
readonly recommendations =
input.required<readonly PlaybackRecommendation[]>();
readonly playback = input.required<ResolvedPortalPlayback>();
readonly supportsManagedExternalPlayers = input.required<boolean>();
readonly playbackExternallyTransferable = input.required<boolean>();
readonly alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly pending = input(false);
readonly retryRequested = output<void>();
readonly playerRequested = output<PlaybackRecommendationTarget>();
readonly alternativeSourceRequested = output<string>();
readonly sourceCheckRequested = output<string>();
readonly visibleAlternatives = computed(() =>
this.alternativeSources().slice(0, ERROR_SCREEN_ALTERNATIVES)
);
readonly hiddenAlternativeCount = computed(() =>
Math.max(
0,
this.alternativeSources().length - ERROR_SCREEN_ALTERNATIVES
)
);
readonly hasExternalPlayerRecommendation = computed(() =>
this.recommendations().some(isExternalPlayerRecommendation)
);
readonly diagnosticHeadlineKey = computed(() => {
if (this.hasExternalPlayerRecommendation()) {
return 'PLAYBACK_DIAGNOSTICS.NATIVE_FALLBACK_TITLE';
}
return this.playbackExternallyTransferable()
? 'PLAYBACK_DIAGNOSTICS.INLINE_FAILURE_TITLE'
: 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_FAILURE_TITLE';
});
readonly getDiagnosticTitleKey = getDiagnosticTitleKey;
readonly getDiagnosticMeta = getDiagnosticMeta;
readonly getDiagnosticCodecHint = getDiagnosticCodecHint;
readonly getDiagnosticDetails = getDiagnosticDetails;
readonly getRecommendationKey = getRecommendationKey;
readonly getRecommendationTestId = getRecommendationTestId;
readonly getRecommendationIcon = getRecommendationIcon;
readonly getRecommendationLabelKey = getRecommendationLabelKey;
readonly getRecommendationParams = getRecommendationParams;
readonly getRecommendationReasonKey = getRecommendationReasonKey;
readonly isTemporaryBuiltInRecommendation =
isTemporaryBuiltInRecommendation;
getDiagnosticDescriptionKey(issue: PlaybackDiagnostic): string {
return getDiagnosticDescriptionKey(
issue,
this.supportsManagedExternalPlayers(),
this.playbackExternallyTransferable()
);
}
activate(recommendation: PlaybackRecommendation): void {
switch (recommendation.action) {
case 'retry':
this.retryRequested.emit();
return;
case 'player':
this.playerRequested.emit(recommendation.target);
return;
case 'alternative-source':
return;
}
}
}
@@ -0,0 +1,139 @@
import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
} from '@iptvnator/playback/util';
import {
getDiagnosticCodecHint,
getDiagnosticDescriptionKey,
getDiagnosticDetails,
getDiagnosticMeta,
getDiagnosticTitleKey,
} from './playback-diagnostic-view.util';
describe('playback diagnostic view formatters', () => {
const issue: PlaybackDiagnostic = {
code: PlaybackDiagnosticCode.UnsupportedCodec,
source: PlaybackDiagnosticSource.Native,
sourceUrl: 'https://example.com/movie.mkv',
container: 'matroska',
mimeType: 'video/matroska',
player: 'videojs',
audioCodecs: ['ac-3'],
videoCodecs: ['hvc1.1.6.L93.B0'],
};
it('preserves existing title, metadata, codec and detail formatting', () => {
expect(getDiagnosticTitleKey(issue)).toBe(
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.TITLE'
);
expect(getDiagnosticMeta(issue)).toBe('hvc1.1.6.L93.B0, ac-3');
expect(getDiagnosticCodecHint(issue)).toBe('HEVC, AC-3');
expect(getDiagnosticDetails(issue)).toEqual(
expect.arrayContaining([
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_PLAYER',
value: 'Video.js',
},
{
labelKey: 'PLAYBACK_DIAGNOSTICS.DETAIL_SOURCE',
value: 'Native media element',
},
])
);
});
describe.each([
{
diagnostic: 'browser access',
code: PlaybackDiagnosticCode.BrowserAccessError,
desktopKey:
'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.BROWSER_ACCESS_ERROR.PWA_DESCRIPTION',
},
{
diagnostic: 'unsupported codec',
code: PlaybackDiagnosticCode.UnsupportedCodec,
desktopKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CODEC.DESCRIPTION',
},
{
diagnostic: 'unsupported container',
code: PlaybackDiagnosticCode.UnsupportedContainer,
desktopKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
pwaKey:
'PLAYBACK_DIAGNOSTICS.UNSUPPORTED_CONTAINER.DESCRIPTION',
},
{
diagnostic: 'media decode',
code: PlaybackDiagnosticCode.MediaDecodeError,
desktopKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION',
pwaKey: 'PLAYBACK_DIAGNOSTICS.MEDIA_DECODE_ERROR.DESCRIPTION',
},
])('$diagnostic description', ({ code, desktopKey, pwaKey }) => {
const diagnosticIssue: PlaybackDiagnostic = { ...issue, code };
it.each([
{
runtime: 'desktop',
supportsManagedExternalPlayers: true,
expected: desktopKey,
},
{
runtime: 'PWA',
supportsManagedExternalPlayers: false,
expected: pwaKey,
},
])('preserves transferable $runtime copy', ({
supportsManagedExternalPlayers,
expected,
}) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
true
)
).toBe(expected);
});
it.each([
['desktop', true],
['PWA', false],
])(
'uses neutral protected copy in the %s runtime',
(_runtime, supportsManagedExternalPlayers) => {
expect(
getDiagnosticDescriptionKey(
diagnosticIssue,
supportsManagedExternalPlayers,
false
)
).toBe('PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION');
}
);
});
it.each([
[
'network',
PlaybackDiagnosticCode.NetworkError,
'PLAYBACK_DIAGNOSTICS.NETWORK_ERROR.DESCRIPTION',
],
[
'unknown',
PlaybackDiagnosticCode.UnknownPlaybackError,
'PLAYBACK_DIAGNOSTICS.UNKNOWN_PLAYBACK_ERROR.DESCRIPTION',
],
[
'DRM or encryption',
PlaybackDiagnosticCode.DrmOrEncryption,
'PLAYBACK_DIAGNOSTICS.DRM_OR_ENCRYPTION.DESCRIPTION',
],
])('preserves neutral %s copy for protected playback', (_label, code, expected) => {
expect(
getDiagnosticDescriptionKey({ ...issue, code }, true, false)
).toBe(expected);
});
});
@@ -2,7 +2,16 @@ import {
type PlaybackDiagnostic,
PlaybackDiagnosticCode,
getLikelyBrowserUnsupportedCodecLabels,
} from '../playback-diagnostics/playback-diagnostics.util';
} from '@iptvnator/playback/util';
const UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES: ReadonlySet<
PlaybackDiagnostic['code']
> = new Set([
PlaybackDiagnosticCode.BrowserAccessError,
PlaybackDiagnosticCode.UnsupportedCodec,
PlaybackDiagnosticCode.UnsupportedContainer,
PlaybackDiagnosticCode.MediaDecodeError,
]);
export type PlaybackDiagnosticDetail = {
readonly labelKey: string;
@@ -15,8 +24,16 @@ export function getDiagnosticTitleKey(issue: PlaybackDiagnostic): string {
export function getDiagnosticDescriptionKey(
issue: PlaybackDiagnostic,
supportsManagedExternalPlayers: boolean
supportsManagedExternalPlayers: boolean,
playbackExternallyTransferable: boolean
): string {
if (
!playbackExternallyTransferable &&
UNTRANSFERABLE_EXTERNAL_RECOVERY_COPY_CODES.has(issue.code)
) {
return 'PLAYBACK_DIAGNOSTICS.UNTRANSFERABLE_DESCRIPTION';
}
if (
issue.code === PlaybackDiagnosticCode.BrowserAccessError &&
!supportsManagedExternalPlayers
@@ -0,0 +1,162 @@
import {
PlaybackRecommendationReason,
type PlaybackRecommendation,
type PlaybackRecommendationPriority,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
import {
getRecommendationIcon,
getRecommendationKey,
getRecommendationLabelKey,
getRecommendationParams,
getRecommendationReasonKey,
getRecommendationTestId,
isTemporaryBuiltInRecommendation,
} from './playback-recommendation-view.util';
function player(
target: PlaybackRecommendationTarget,
priority: PlaybackRecommendationPriority = 'secondary'
): PlaybackRecommendation {
return {
action: 'player',
target,
reason: PlaybackRecommendationReason.DifferentEngineFamily,
priority,
};
}
function retry(): PlaybackRecommendation {
return {
action: 'retry',
reason: PlaybackRecommendationReason.RetryTransientFailure,
priority: 'primary',
};
}
function alternative(): PlaybackRecommendation {
return {
action: 'alternative-source',
reason: PlaybackRecommendationReason.AlternativeSourceAvailable,
priority: 'secondary',
};
}
describe('playback recommendation view mappings', () => {
it.each([
['videojs', 'playback-recommendation-videojs'],
['html5', 'playback-recommendation-html5'],
['artplayer', 'playback-recommendation-artplayer'],
['mpv', 'playback-fallback-mpv'],
['vlc', 'playback-fallback-vlc'],
] as const)('maps %s to its stable test id', (target, testId) => {
expect(getRecommendationTestId(player(target))).toBe(testId);
});
it('keeps stable retry and alternative-source test ids', () => {
expect(getRecommendationTestId(retry())).toBe('playback-retry');
expect(getRecommendationTestId(alternative())).toBe(
'playback-alternative-sources'
);
});
it.each([
['videojs', 'Video.js'],
['html5', 'HTML5'],
['artplayer', 'ArtPlayer'],
] as const)(
'maps built-in target %s to translated player copy',
(target, name) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER'
);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('play_circle');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(true);
}
);
it.each([
['mpv', 'MPV', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV'],
['vlc', 'VLC', 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC'],
] as const)(
'maps external target %s without a temporary-player hint',
(target, name, labelKey) => {
const recommendation = player(target);
expect(getRecommendationLabelKey(recommendation)).toBe(labelKey);
expect(getRecommendationParams(recommendation)).toEqual({
player: name,
});
expect(getRecommendationIcon(recommendation)).toBe('open_in_new');
expect(isTemporaryBuiltInRecommendation(recommendation)).toBe(
false
);
}
);
it('maps non-player actions exhaustively', () => {
expect(getRecommendationLabelKey(retry())).toBe(
'PLAYBACK_DIAGNOSTICS.ACTION_RETRY'
);
expect(getRecommendationParams(retry())).toEqual({});
expect(getRecommendationIcon(retry())).toBe('refresh');
expect(getRecommendationKey(retry())).toBe('retry');
expect(isTemporaryBuiltInRecommendation(retry())).toBe(false);
expect(getRecommendationLabelKey(alternative())).toBe(
'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE'
);
expect(getRecommendationParams(alternative())).toEqual({});
expect(getRecommendationIcon(alternative())).toBe('swap_horiz');
expect(getRecommendationKey(alternative())).toBe('alternative-source');
expect(isTemporaryBuiltInRecommendation(alternative())).toBe(false);
});
it.each([
[
PlaybackRecommendationReason.RetryTransientFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE',
],
[
PlaybackRecommendationReason.RetryUnknownFailure,
'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE',
],
[
PlaybackRecommendationReason.AlternativeSourceAvailable,
'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE',
],
[
PlaybackRecommendationReason.DifferentEngineFamily,
'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY',
],
[
PlaybackRecommendationReason.ExternalCodecOrContainerSupport,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT',
],
[
PlaybackRecommendationReason.ExternalBrowserAccess,
'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS',
],
[
PlaybackRecommendationReason.CompatibleDrmPath,
'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH',
],
] as const)('maps reason %s to its translation key', (reason, key) => {
expect(getRecommendationReasonKey(reason)).toBe(key);
});
it('keeps player recommendation keys stable and target-specific', () => {
expect(getRecommendationKey(player('videojs'))).toBe('player-videojs');
expect(getRecommendationKey(player('html5'))).toBe('player-html5');
expect(getRecommendationKey(player('artplayer'))).toBe(
'player-artplayer'
);
expect(getRecommendationKey(player('mpv'))).toBe('player-mpv');
expect(getRecommendationKey(player('vlc'))).toBe('player-vlc');
});
});
@@ -0,0 +1,148 @@
import {
type PlaybackRecommendation,
type PlaybackRecommendationReason,
type PlaybackRecommendationTarget,
} from '@iptvnator/playback/util';
export function getRecommendationKey(
recommendation: PlaybackRecommendation
): string {
return recommendation.action === 'player'
? `player-${recommendation.target}`
: recommendation.action;
}
export function getRecommendationTestId(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'playback-retry';
case 'alternative-source':
return 'playback-alternative-sources';
case 'player':
return getPlayerTestId(recommendation.target);
}
}
export function getRecommendationLabelKey(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'PLAYBACK_DIAGNOSTICS.ACTION_RETRY';
case 'alternative-source':
return 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE';
case 'player':
return getPlayerLabelKey(recommendation.target);
}
}
export function getRecommendationParams(
recommendation: PlaybackRecommendation
): Readonly<Record<string, string>> {
return recommendation.action === 'player'
? { player: getPlayerName(recommendation.target) }
: {};
}
export function getRecommendationIcon(
recommendation: PlaybackRecommendation
): string {
switch (recommendation.action) {
case 'retry':
return 'refresh';
case 'alternative-source':
return 'swap_horiz';
case 'player':
return isExternalTarget(recommendation.target)
? 'open_in_new'
: 'play_circle';
}
}
export function getRecommendationReasonKey(
reason: PlaybackRecommendationReason
): string {
switch (reason) {
case 'retry-transient-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_TRANSIENT_FAILURE';
case 'retry-unknown-failure':
return 'PLAYBACK_DIAGNOSTICS.REASON_RETRY_UNKNOWN_FAILURE';
case 'alternative-source-available':
return 'PLAYBACK_DIAGNOSTICS.REASON_ALTERNATIVE_SOURCE_AVAILABLE';
case 'different-engine-family':
return 'PLAYBACK_DIAGNOSTICS.REASON_DIFFERENT_ENGINE_FAMILY';
case 'external-codec-or-container-support':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT';
case 'external-browser-access':
return 'PLAYBACK_DIAGNOSTICS.REASON_EXTERNAL_BROWSER_ACCESS';
case 'compatible-drm-path':
return 'PLAYBACK_DIAGNOSTICS.REASON_COMPATIBLE_DRM_PATH';
}
}
export function isTemporaryBuiltInRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
!isExternalTarget(recommendation.target)
);
}
export function isExternalPlayerRecommendation(
recommendation: PlaybackRecommendation
): boolean {
return (
recommendation.action === 'player' &&
isExternalTarget(recommendation.target)
);
}
function getPlayerTestId(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'playback-recommendation-videojs';
case 'html5':
return 'playback-recommendation-html5';
case 'artplayer':
return 'playback-recommendation-artplayer';
case 'mpv':
return 'playback-fallback-mpv';
case 'vlc':
return 'playback-fallback-vlc';
}
}
function getPlayerLabelKey(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
case 'html5':
case 'artplayer':
return 'PLAYBACK_DIAGNOSTICS.ACTION_TRY_PLAYER';
case 'mpv':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_MPV';
case 'vlc':
return 'PLAYBACK_DIAGNOSTICS.ACTION_OPEN_VLC';
}
}
function getPlayerName(target: PlaybackRecommendationTarget): string {
switch (target) {
case 'videojs':
return 'Video.js';
case 'html5':
return 'HTML5';
case 'artplayer':
return 'ArtPlayer';
case 'mpv':
return 'MPV';
case 'vlc':
return 'VLC';
}
}
function isExternalTarget(target: PlaybackRecommendationTarget): boolean {
return target === 'mpv' || target === 'vlc';
}
Loaded 100 of 176 files, more files were not shown because too many files have changed in this diff. Show more