mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
5e4f2ca3dd
commit
fd96b85c19
176 files changed
+15228
-1908
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+1
-1
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
+43
-85
@@ -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();
|
||||
File renamed without changes.
+8
-1
@@ -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 {
|
||||
+33
-22
@@ -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', () => {
|
||||
+13
-26
@@ -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;
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+2
-5
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
-7
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
File renamed without changes.
+2
-2
@@ -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
|
||||
File renamed without changes.
@@ -0,0 +1,6 @@
|
||||
export interface ShakaErrorLike {
|
||||
readonly severity: number;
|
||||
readonly category: number;
|
||||
readonly code: number;
|
||||
readonly data?: readonly unknown[];
|
||||
}
|
||||
File renamed without changes.
+2
-2
@@ -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,
|
||||
File renamed without changes.
File renamed without changes.
@@ -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}`;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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()"
|
||||
|
||||
+48
-15
@@ -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
|
||||
);
|
||||
|
||||
+4
@@ -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',
|
||||
|
||||
+47
@@ -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,
|
||||
};
|
||||
}
|
||||
+23
@@ -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();
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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()"
|
||||
|
||||
+23
-23
@@ -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'
|
||||
);
|
||||
|
||||
+6
-8
@@ -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 {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
+1
@@ -8,6 +8,7 @@
|
||||
<app-stalker-series-view [providerOnly]="providerOnly()" />
|
||||
} @else if (vodDetailsItem()) {
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="vodDetailsItem()!"
|
||||
[isFavorite]="isSelectedVodFavorite()"
|
||||
[playbackPosition]="selectedVodPlaybackPosition()"
|
||||
|
||||
+144
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
-3
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
+1
@@ -15,6 +15,7 @@
|
||||
} @else if (vodDetailsItem(); as item) {
|
||||
<!-- Regular VOD item -->
|
||||
<app-vod-details
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[item]="item"
|
||||
[isFavorite]="isFavorite()"
|
||||
[playbackPosition]="playbackPosition()"
|
||||
|
||||
+6
@@ -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([
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
+10
-3
@@ -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()"
|
||||
|
||||
+68
-22
@@ -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',
|
||||
|
||||
+81
-25
@@ -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)) {
|
||||
|
||||
+294
@@ -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(),
|
||||
|
||||
+278
@@ -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('');
|
||||
}
|
||||
);
|
||||
});
|
||||
+237
@@ -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;
|
||||
}
|
||||
+1
@@ -167,6 +167,7 @@
|
||||
@if (inlinePlayback(); as playback) {
|
||||
<app-portal-inline-player
|
||||
detail-player
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[playback]="playback"
|
||||
[episodeMetadata]="inlineEpisodeMetadata()"
|
||||
[seriesTitle]="serial.info.name"
|
||||
|
||||
+8
-6
@@ -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();
|
||||
|
||||
+185
-236
@@ -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);
|
||||
|
||||
+753
@@ -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'),
|
||||
|
||||
+1
@@ -96,6 +96,7 @@
|
||||
@if (isEmbeddedPlayer) {
|
||||
<div class="video-player">
|
||||
<app-web-player-view
|
||||
[playbackSessionKey]="playbackSessionKey()"
|
||||
[streamUrl]="streamUrl"
|
||||
[title]="activePlayback()?.title ?? ''"
|
||||
[playback]="activePlayback()"
|
||||
|
||||
+122
-45
@@ -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({
|
||||
|
||||
+15
-2
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
-1
@@ -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,
|
||||
|
||||
+184
@@ -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>
|
||||
+530
@@ -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;
|
||||
}
|
||||
}
|
||||
+619
@@ -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');
|
||||
});
|
||||
});
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+139
@@ -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);
|
||||
});
|
||||
});
|
||||
+19
-2
@@ -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
|
||||
+162
@@ -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');
|
||||
});
|
||||
});
|
||||
+148
@@ -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
Reference in new issue
Block a user