* 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
78 KiB
Playback Recovery Recommendations Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Rank evidence-based playback recovery actions and let users try a recommended built-in web player for the current content without changing their saved setting.
Architecture: Extract the existing pure diagnostic contracts into a new
@iptvnator/playback/util Nx project, then add a total ranked-policy function
over structured evidence and explicit target capabilities. Keep playback-session
state in WebPlayerViewComponent through a focused helper, render recommendations
through a presentational diagnostic panel, and make every host provide stable
content identity so Retry/source changes preserve attempts while new content
resets them.
Tech Stack: Angular 21 signals and standalone components, TypeScript 5.9, Nx 22, Jest, Playwright for web and Electron, hls.js, Video.js/VHS, Shaka Player, mpegts.js, ngx-translate, Angular Material/CDK.
File Map
Create the pure project and keep each file focused:
libs/playback/util/project.json— Nx ownership, tags, test, and lint targets.libs/playback/util/tsconfig.jsonandtsconfig.lib.json— strict library compilation.libs/playback/util/src/index.ts— the only public playback-util barrel.libs/playback/util/src/lib/diagnostics/— the moved native/HLS/VHS/ mpegts.js/Shaka evidence and classification boundary.libs/playback/util/src/lib/playback-recommendation.model.ts— target, capability, source-context, reason, priority, and recommendation contracts.libs/playback/util/src/lib/playback-target-capabilities.ts— deterministic source-kind and engine-family mapping.libs/playback/util/src/lib/playback-recommendation-policy.ts— the total, ordered, maximum-three recommendation function.libs/playback/util/src/lib/playback-session-key.ts— stable content identity encoding shared by M3U and portal hosts.
Keep UI ownership under ui-playback:
libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts— memory-only attempts, temporary override, resume point, generation, and single-flight switch state.libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts— pure construction of resolved playback, channel, and Video.js options extracted from the near-limit host component.libs/ui/playback/src/lib/playback-diagnostic-panel/— presentational ranked recommendation overlay and its formatting helpers, template, styles, and specs.libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts— orchestration only: engine host, session state, policy input, and outputs.
Host changes stay with their existing owners. E2E uses repository-owned media
under apps/web-e2e/src/fixtures/playback/. No production diagnostic-injection
hook is added.
Task 0: Refresh The Isolated Baseline
Files:
-
Verify:
package.json -
Verify:
pnpm-lock.yaml -
Verify:
docs/superpowers/specs/2026-08-03-playback-recommendations-design.md -
Verify:
docs/superpowers/plans/2026-08-03-playback-recommendations.md -
Step 1: Rebase the local design/plan commits onto the latest master
Run:
git status --short
git fetch origin master --prune
git rebase origin/master
test "$(git merge-base HEAD origin/master)" = "$(git rev-parse origin/master)"
Expected: the worktree is clean, the rebase succeeds, and the final command exits 0. Do not start implementation from the older merged diagnostics branch.
- Step 2: Verify dependency and Nx discovery
Run:
test -d node_modules || pnpm install --frozen-lockfile
pnpm nx show projects
pnpm nx show projects --withTarget test
pnpm nx show projects --withTarget e2e
Expected: exit 0 with ui-playback, web-e2e, and
electron-backend-e2e present.
- Step 3: Re-run the affected baseline
Run:
pnpm nx test ui-playback
pnpm nx lint ui-playback
Expected: the pre-change baseline remains green (currently 91 suites and 915 tests), and lint succeeds. A Jest worker-teardown warning is non-fatal only when Nx still reports success.
Task 1: Extract The Pure Playback Diagnostic Boundary
Files:
-
Create:
libs/playback/util/project.json -
Create:
libs/playback/util/tsconfig.json -
Create:
libs/playback/util/tsconfig.lib.json -
Create:
libs/playback/util/src/index.ts -
Create:
libs/playback/util/src/lib/playback-util-boundary.spec.ts -
Move:
libs/ui/playback/src/lib/playback-diagnostics/* -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts -
Move:
libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts -
Create:
libs/playback/util/src/lib/diagnostics/shaka-error.types.ts -
Create:
libs/ui/playback/src/lib/web-video-support/browser-media-type-support.ts -
Modify:
libs/ui/playback/src/lib/shaka-engine/shaka-module.types.ts -
Modify: HTML5 and ArtPlayer HLS manifest-codec probe call sites
-
Modify:
tsconfig.base.json -
Modify:
libs/ui/playback/src/index.ts -
Modify: imports under
libs/ui/playback/src/lib/**/*.ts -
Step 1: Scaffold the pure Nx project
Create project.json:
{
"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"
}
}
}
Create tsconfig.json:
{
"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" }]
}
Create tsconfig.lib.json:
{
"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"]
}
Add the scoped alias to tsconfig.base.json:
"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"]
Run:
pnpm nx show project playback-util
Expected: Nx reports scope:shared, domain:playback, type:util, plus test
and lint targets.
- Step 2: Move the pure diagnostic files without changing behavior
Run these repository moves:
mkdir -p libs/playback/util/src/lib/diagnostics
git mv libs/ui/playback/src/lib/playback-diagnostics/* libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-classifier.spec.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-contract.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-lifecycle.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-error-mapping.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.ts libs/playback/util/src/lib/diagnostics/
git mv libs/ui/playback/src/lib/shaka-engine/shaka-playback-evidence.util.spec.ts libs/playback/util/src/lib/diagnostics/
Because all moved Shaka files now share the same directory as the diagnostic
model, replace their old ../playback-diagnostics/... imports with local
./playback-diagnostics... imports.
Do not move shaka-module.types.ts: it owns the lazy vendor loader and DOM
player surface. Instead, create the DOM-free structural seam in
shaka-error.types.ts:
export interface ShakaErrorLike {
readonly severity: number;
readonly category: number;
readonly code: number;
readonly data?: readonly unknown[];
}
Import that type locally from the moved Shaka classifier/evidence/lifecycle
files. Remove its old declaration from shaka-module.types.ts, import it from
@iptvnator/playback/util, and re-export the type there so the UI session keeps
its current local module surface. Keep shaka-video-session, the lazy module
loader, shaka-player-test-double, and text-track suppression in
ui-playback.
Remove the two remaining production DOM references from the moved diagnostic
boundary. Narrow classifyNativePlaybackIssue to the already-public
NativePlaybackErrorInput | null | undefined structural input; real
MediaError values remain assignable. Change the HLS codec preflight to accept
an explicit probe:
export type MediaTypeSupportProbe = (mimeType: string) => boolean | undefined;
export function classifyUnsupportedHlsManifestCodecs(
metadata: PlaybackSourceMetadata,
isTypeSupported: MediaTypeSupportProbe
): PlaybackDiagnostic | null;
The classifier returns null when there are no codecs or the probe returns
true/undefined, and returns unsupported-codec only for exact false.
Create the UI-owned adapter:
export function isBrowserMediaTypeSupported(
mimeType: string
): boolean | undefined {
return typeof MediaSource === 'undefined'
? undefined
: MediaSource.isTypeSupported(mimeType);
}
Pass this adapter from the existing HTML5 and ArtPlayer manifest-codec call sites. Update the closest specs first to prove supported, unsupported, and unavailable-probe behavior is unchanged.
Add playback-util-boundary.spec.ts as a source-boundary regression test. It
must recursively parse every non-spec .ts file under the project with the
TypeScript compiler API and fail when an import starts with @angular/,
@iptvnator/ui/, electron, or @ngx-pwa/, or when a non-property identifier
references window, document, localStorage, sessionStorage,
MediaSource, MediaError, HTMLMediaElement, or HTMLElement. Ignoring
property names is required because app-owned evidence values such as
MpegTsPlaybackFailure.MediaSource are not DOM access. Assert the current file
set produces an empty violation array.
- Step 3: Publish the new barrel and compatibility re-export
Create libs/playback/util/src/index.ts:
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';
In libs/ui/playback/src/index.ts, replace the deleted relative diagnostic
export with the compatibility export:
export * from '@iptvnator/playback/util';
Change every internal source and spec import that names
playback-diagnostics/ or a moved shaka-error-* module to the new scoped
alias. Do not add a legacy bare alias or a deep import.
Run:
rg -n "playback-diagnostics/|shaka-engine/shaka-error-(classifier|contract|lifecycle|mapping)" libs apps --glob '*.ts'
rg -n "typeof (window|document|MediaSource)|\b(window|document|MediaSource)\.|\b(MediaError|HTMLMediaElement|HTMLElement)\s*[|&>,)]" libs/playback/util/src --glob '*.ts' --glob '!*.spec.ts'
Expected: no import references a deleted path, and the production
playback-util scan prints no Angular, UI, Electron, storage, or DOM
dependency. Documentation may still mention the old path until Task 10.
- Step 4: Verify the behavior-preserving extraction
Run:
pnpm nx test playback-util
pnpm nx lint playback-util
pnpm nx test ui-playback
pnpm nx lint ui-playback
Expected: all migrated diagnostic contract/redaction/version-lock specs and all remaining UI playback suites pass.
- Step 5: Commit the extraction
git add tsconfig.base.json libs/playback/util libs/ui/playback
git commit -m "refactor(playback): extract diagnostic utilities"
Task 2: Define Targets, Capabilities, Engine Families, And Session Keys
Files:
-
Create:
libs/playback/util/src/lib/playback-recommendation.model.ts -
Create:
libs/playback/util/src/lib/playback-target-capabilities.ts -
Create:
libs/playback/util/src/lib/playback-target-capabilities.spec.ts -
Create:
libs/playback/util/src/lib/playback-session-key.ts -
Create:
libs/playback/util/src/lib/playback-session-key.spec.ts -
Modify:
libs/playback/util/src/index.ts -
Step 1: Write failing capability and engine-family tests
Cover the approved matrix with this table:
it.each([
['hls', 'videojs', 'vhs'],
['hls', 'html5', 'hls.js'],
['hls', 'artplayer', 'hls.js'],
['mpegts', 'videojs', 'mpegts.js'],
['mpegts', 'html5', 'mpegts.js'],
['mpegts', 'artplayer', 'mpegts.js'],
['dash', 'videojs', null],
['dash', 'html5', 'shaka'],
['dash', 'artplayer', 'shaka'],
['native', 'videojs', 'native-media'],
['native', 'html5', 'native-media'],
['native', 'artplayer', 'native-media'],
] as const)('%s maps %s to %s', (sourceKind, target, expectedFamily) => {
expect(getInlinePlaybackEngineFamily(sourceKind, target)).toBe(
expectedFamily
);
});
Also assert that engine-specific hls, mpegts, shaka, and native
diagnostic sources are authoritative even when generic metadata disagrees.
For generic source and multi-format vhs, accept only normalized exact base
MIME/container evidence: m3u/m3u8 and the established HLS MIME aliases,
or mpd and application/dash+xml. Cover parameters/casing, MIME-only DASH
and HLS, symmetric container/MIME contradictions, malformed MIME substrings,
and insufficient evidence. Contradictory or insufficient evidence stays
unknown.
Run:
pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-target-capabilities.spec.ts --runInBand
Expected: FAIL because the contracts and mapper do not exist.
- Step 2: Add the recommendation contracts
Create playback-recommendation.model.ts with these exact public shapes:
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;
};
- Step 3: Implement the exact capability mapper
In playback-target-capabilities.ts, export:
export function resolvePlaybackSourceKind(
diagnostic: PlaybackDiagnostic
): PlaybackSourceKind;
export function getInlinePlaybackEngineFamily(
sourceKind: PlaybackSourceKind,
target: InlinePlaybackPlayer
): PlaybackEngineFamily | null;
export function createPlaybackTargetCapabilities(options: {
readonly sourceKind: PlaybackSourceKind;
readonly managedExternalPlayersAvailable: boolean;
}): readonly PlaybackTargetCapability[];
Use this fail-closed mapping. Engine-specific sources identify the diagnostic boundary that emitted the failure and therefore outrank generic metadata:
switch (diagnostic.source) {
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;
}
resolveSourceOrVhsKind normalizes MIME to its trimmed, lowercased base
value with parameters stripped. It recognizes exact
application/vnd.apple.mpegurl, application/x-mpegurl, and the repository's
established audio/x-mpegurl alias as HLS, and exact
application/dash+xml as DASH. Gather container and MIME evidence separately;
if both are recognized and disagree, return Unknown. Otherwise return the
single recognized kind, the shared kind when both agree, or Unknown. Never
use substring MIME matching.
Return inline capabilities in canonical order videojs, html5, artplayer
and external capabilities in mpv, vlc order. Video.js is unavailable for
DASH recommendations; the other matrix rows use the engine families asserted
in Step 1.
- Step 4: Drive and implement collision-safe session keys
Write tests proving callers can reuse one host-owned canonical logical identity
across different provider copies and source URLs, while changing channel,
movie, or episode identity changes the key. Include : and | in identifiers
to prove parts cannot collide, and assert the module does not expose an adapter
that guesses identity from provider-scoped playback metadata.
Create playback-session-key.ts:
/**
* 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('|');
}
Do not add a PlayerContentInfo adapter: multi-source resolution replaces its
playlist/content IDs with the selected provider copy, so it cannot represent a
stable recovery session.
Run:
pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-target-capabilities.spec.ts libs/playback/util/src/lib/playback-session-key.spec.ts --runInBand
Expected: PASS.
- Step 5: Export and commit the contracts
Add the three new modules to libs/playback/util/src/index.ts, then run:
pnpm nx test playback-util
pnpm nx lint playback-util
git add libs/playback/util
git commit -m "feat(playback): define recovery recommendation contracts"
Task 3: Implement The Ranked Recovery Policy With TDD
Files:
-
Create:
libs/playback/util/src/lib/playback-recommendation-policy.ts -
Create:
libs/playback/util/src/lib/playback-recommendation-policy.spec.ts -
Modify:
libs/playback/util/src/index.ts -
Step 1: Write the failing table for the complete policy matrix
Use a diagnostic/capability factory and assert exact ordered outputs for:
const cases = [
['network-error', ['retry', 'alternative-source']],
['unknown-playback-error', ['retry', 'alternative-source']],
['browser-access-error', ['mpv', 'vlc', 'alternative-source']],
['unsupported-codec', ['mpv', 'vlc', 'alternative-source']],
['unsupported-container', ['mpv', 'vlc', 'alternative-source']],
['media-decode-error:hls-vhs', ['html5', 'mpv', 'vlc']],
['media-decode-error:hls-hlsjs', ['videojs', 'mpv', 'vlc']],
['media-decode-error:mpegts', ['mpv', 'vlc', 'alternative-source']],
['media-decode-error:dash', ['mpv', 'vlc', 'alternative-source']],
['media-decode-error:native', ['mpv', 'vlc', 'alternative-source']],
['drm-or-encryption:dash-clearkey', ['alternative-source']],
] as const;
Add separate tests proving:
- the current and attempted targets are excluded;
- filtering promotes the first survivor to
primary; - output is capped at three and later entries are
secondary; - HLS returns one representative per distinct engine family;
- ClearKey/KODIPROP context excludes both external targets;
- unavailable managed players are excluded in the PWA;
- missing/contradictory active capability returns only Retry plus alternative source;
- the function does not mutate the input Set or capability array and never
throws for
unknownsource kind.
Run the focused spec. Expected: FAIL because
recommendPlaybackRecovery does not exist.
- Step 2: Implement ordered candidates and filtering
Create playback-recommendation-policy.ts around this complete flow:
type PlaybackRecommendationCandidate =
| Omit<
Extract<PlaybackRecommendation, { readonly action: 'retry' }>,
'priority'
>
| Omit<
Extract<
PlaybackRecommendation,
{ readonly action: 'alternative-source' }
>,
'priority'
>
| Omit<
Extract<PlaybackRecommendation, { readonly action: 'player' }>,
'priority'
>;
export function recommendPlaybackRecovery(
context: PlaybackRecommendationContext
): readonly PlaybackRecommendation[] {
const capabilityIndex = isPlayerOrientedDiagnostic(context.diagnostic.code)
? createCapabilityIndex(context)
: null;
const candidates = buildCandidates(context, capabilityIndex).filter(
(candidate): candidate is PlaybackRecommendationCandidate =>
candidate !== null
);
const seenTargets = new Set<PlaybackRecommendationTarget>();
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)
) {
return false;
}
if (capabilityIndex?.get(candidate.target)?.available !== true) {
return false;
}
if (
isExternalTarget(candidate.target) &&
(context.source.drm === 'untransferable' ||
!context.source.externalTransferable)
) {
return false;
}
seenTargets.add(candidate.target);
return true;
});
return filtered
.slice(0, 3)
.map<PlaybackRecommendation>((candidate, index) => ({
...candidate,
priority: index === 0 ? 'primary' : 'secondary',
}));
}
buildCandidates must use these exact sequences before filtering:
switch (context.diagnostic.code) {
case PlaybackDiagnosticCode.NetworkError:
return [retryTransient(), alternative(context)];
case PlaybackDiagnosticCode.UnknownPlaybackError:
return [retryUnknown(), alternative(context)];
case PlaybackDiagnosticCode.BrowserAccessError:
return [
external('mpv', PlaybackRecommendationReason.ExternalBrowserAccess),
external('vlc', PlaybackRecommendationReason.ExternalBrowserAccess),
alternative(context),
];
case PlaybackDiagnosticCode.UnsupportedCodec:
case PlaybackDiagnosticCode.UnsupportedContainer:
return [
external(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
external(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(context),
];
case PlaybackDiagnosticCode.MediaDecodeError:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.DifferentEngineFamily
),
external(
'mpv',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
external(
'vlc',
PlaybackRecommendationReason.ExternalCodecOrContainerSupport
),
alternative(context),
];
case PlaybackDiagnosticCode.DrmOrEncryption:
return [
distinctInline(
context,
capabilityIndex,
PlaybackRecommendationReason.CompatibleDrmPath
),
alternative(context),
external('mpv', PlaybackRecommendationReason.CompatibleDrmPath),
external('vlc', PlaybackRecommendationReason.CompatibleDrmPath),
];
default:
return [retryUnknown(), alternative(context)];
}
buildCandidates returns
readonly (PlaybackRecommendationCandidate | null)[].
alternative(context) returns null unless alternativeSourceCount is a
positive safe integer. Before building candidates for a player-oriented
diagnostic, validate one complete, unique capability record for each canonical
target, including exact target/kind pairing and source-kind engine-family
mapping. Any malformed matrix returns Retry plus a valid alternative instead
of trusting player candidates. distinctInline then selects the fixed HLS
representative from that validated index independently of capability-array
order: HTML5 represents hls.js after a Video.js/VHS failure, while Video.js
represents VHS after an HTML5 or ArtPlayer hls.js failure. If HTML5 is current,
unavailable, or already attempted, filtering proceeds to external and
alternative candidates without substituting ArtPlayer. For MPEG-TS, DASH,
native, and unknown matrices this naturally returns null.
If a player-oriented diagnostic has no coherent active inline capability,
return retryUnknown() plus alternative(context) instead of trusting any
player candidate.
- Step 3: Run the full policy and project suites
pnpm nx test playback-util -- --runTestsByPath libs/playback/util/src/lib/playback-recommendation-policy.spec.ts --runInBand
pnpm nx test playback-util
pnpm nx lint playback-util
Expected: every ordered case passes, with no mutation or thrown-error failures.
- Step 4: Export and commit the policy
git add libs/playback/util
git commit -m "feat(playback): rank recovery recommendations"
Task 4: Add A Focused In-Memory Recovery Session
Files:
-
Create:
libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts -
Create:
libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts -
Step 1: Write failing lifecycle and race tests
Cover:
session.syncSession('movie-a');
const first = session.beginPlayback('videojs');
expect(session.recordFailure(first)).toBe(true);
expect(session.attemptedTargets()).toEqual(new Set(['videojs']));
session.recordTimeUpdate({ currentTime: 42, duration: 120 }, false);
expect(session.beginPlayerSwitch('html5', false)).toBe(true);
const switched = session.beginPlayback('html5');
expect(switched.target).toBe('html5');
expect(session.temporaryPlayerOverride()).toBe('html5');
expect(session.resumeStartTime(0, false)).toBe(42);
session.syncSession('movie-b');
expect(session.attemptedTargets().size).toBe(0);
expect(session.temporaryPlayerOverride()).toBeNull();
expect(session.resumeStartTime(0, false)).toBe(0);
expect(session.recordFailure(first)).toBe(false);
Also test Retry preserves attempts, source changes with the same key preserve
attempts, live sessions never retain a resume point, external attempts are
recorded, an accepted failure or settle clears pending state, and concurrent
switch/retry attempts accept only the first operation. Explicitly cover the
pre-effect interval after an accepted switch and retry: old binding callbacks
must be rejected until beginPlayback installs the replacement binding.
Run the focused spec. Expected: FAIL because the class does not exist.
- Step 2: Implement the signal-backed UI session
Create the class with this public contract:
export interface PlaybackBinding {
readonly generation: number;
readonly target: InlinePlaybackPlayer;
}
export class PlaybackRecoverySession {
private readonly attemptedTargetsState = signal<
ReadonlySet<PlaybackRecommendationTarget>
>(new Set());
private readonly temporaryPlayerOverrideState =
signal<InlinePlaybackPlayer | null>(null);
private readonly switchPendingState = signal(false);
private readonly activeBindingState = signal<PlaybackBinding | null>(null);
private readonly sessionKey = signal<string | null>(null);
private readonly generation = signal(0);
private readonly resumePosition = signal<number | null>(null);
readonly attemptedTargets: Signal<
ReadonlySet<PlaybackRecommendationTarget>
> = this.attemptedTargetsState.asReadonly();
readonly temporaryPlayerOverride: Signal<InlinePlaybackPlayer | null> =
this.temporaryPlayerOverrideState.asReadonly();
readonly switchPending: Signal<boolean> =
this.switchPendingState.asReadonly();
readonly activeBinding: Signal<PlaybackBinding | null> =
this.activeBindingState.asReadonly();
syncSession(key: string): boolean;
beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding;
clearPlaybackBinding(): void;
recordFailure(binding: PlaybackBinding): boolean;
recordInlineAttempt(target: InlinePlaybackPlayer): void;
recordExternalAttempt(target: ExternalPlayerName): void;
recordTimeUpdate(
event: { readonly currentTime: number; readonly duration: number },
isLive: boolean
): void;
beginPlayerSwitch(target: InlinePlaybackPlayer, isLive: boolean): boolean;
beginRetry(): boolean;
settle(binding: PlaybackBinding): void;
resumeStartTime(inputStartTime: number, isLive: boolean): number;
accepts(binding: PlaybackBinding): boolean;
}
Implementation rules:
-
syncSessionis a no-op for the same key; a new key clears attempts, override, pending state, resume position, and active binding, then increments generation. -
Public state is exposed as read-only
Signalvalues backed by private writable signals.beginPlaybackalways advances the generation and installs and returns the same frozen active-target snapshot. Call it for every applied source, including a same-content alternative URL, so delayed events from the replaced source become stale without clearing attempts.clearPlaybackBindingdelegates to the same private invalidation helper used by recovery operations; the helper advances the generation and stores null for Embedded MPV/non-diagnostic playback. -
recordInlineAttempt,recordExternalAttempt, and every failure update copy the Set before adding. -
recordFailureandsettlefirst callaccepts; stale generations do nothing. Both clearswitchPendingfor an accepted replacement binding; a replacement target that fails before emitting a success/clear event must not leave every recovery action disabled. -
beginPlayerSwitchreturns false without invalidating the binding while pending. Otherwise it records the target, clears live resume, sets the override and pending state, synchronously invalidates the current binding, and returns true. Old callbacks in the interval before the component's playback effect callsbeginPlaybackare therefore rejected. -
beginRetryreturns false without invalidating the binding while pending or without an active binding. Otherwise it keeps attempts/override, marks the reload pending, synchronously invalidates the current binding, and returns true. Incrementing the component reload token reruns the playback effect and installs the next binding. -
recordTimeUpdatestores only finite non-negative VOD positions; live,NaN,Infinity, and negative positions are ignored. -
resumeStartTimealways returns 0 for live playback and otherwise prefers the stored finite position over the host input. -
acceptscompares both the generation and exact target againstactiveBinding; a binding for the old target is stale even if a caller accidentally reuses its generation. -
Step 3: Verify and commit the session helper
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts --runInBand
pnpm nx lint ui-playback
git add libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts
git commit -m "feat(playback): track session recovery attempts"
Task 5: Bind Stable Content Session Keys In Every Host
Files:
-
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts -
Modify:
libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts -
Modify:
libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html -
Modify:
libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts -
Modify:
libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts -
Modify:
libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html -
Modify:
libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html -
Modify:
libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts -
Modify:
libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts -
Modify:
libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html -
Modify:
libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts -
Modify:
libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts -
Modify:
libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html -
Modify:
libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts -
Modify:
libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts -
Modify:
libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts -
Modify:
libs/ui/playback/src/lib/vod-details/vod-details.component.ts -
Modify:
libs/ui/playback/src/lib/vod-details/vod-details.component.html -
Modify:
libs/ui/playback/src/lib/vod-details/vod-details.component.spec.ts -
Modify:
libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts -
Modify:
libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html -
Modify:
libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts -
Modify:
libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts -
Modify:
libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts -
Modify:
libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html -
Modify:
libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.html -
Modify:
libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.html -
Modify:
libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts -
Modify:
libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html -
Modify:
libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts -
Step 1: Add failing host identity tests
Prove these invariants in the closest existing specs:
- M3U: playlist ID +
Channel.id; catch-up URL changes keep the key, changing channel changes it. - Xtream live: current playlist ID + selected
xtream_id. - Stalker live: current playlist
_id+ normalized selected channel ID. - Unified live: active item playlist ID +
uid; timeshift URL changes keep the key. - Portal inline VOD/episode: the Xtream/Stalker route or series host derives a
canonical key from its original route/catalog identity and passes it through
unchanged. Replacing playback with an alternative provider copy changes its
URL and provider-scoped
contentInfobut keeps the key; selecting a different original movie or episode changes it. - Transfer contract: M3U preserves the resolved request URL plus active-channel
User-Agent/Referer/Origin; Xtream, Stalker, unified live, and portal inline
pass the exact
ResolvedPortalPlayback(including headers and content info) to their existing external-launch owner. These assertions justify marking current non-DRM Electron playback externally transferable in Task 7.
Run:
pnpm nx test playlist-m3u-feature-player -- --runTestsByPath libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts --runInBand
pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts --runInBand
pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.spec.ts --runInBand
pnpm nx test portal-shared-ui -- --runTestsByPath libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.spec.ts --runInBand
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-sources.spec.ts libs/ui/playback/src/lib/portal-inline-player/portal-inline-player-up-next.spec.ts --runInBand
pnpm nx test portal-xtream-feature -- --runTestsByPath libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts --runInBand
pnpm nx test portal-stalker-feature -- --runTestsByPath libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-inline-detail/stalker-inline-detail.component.spec.ts libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts --runInBand
Expected: FAIL because neither the required input nor the host bindings exist.
- Step 2: Make content identity required through the player chain
Add this input to WebPlayerViewComponent; Task 7 will consume it, while this
task first makes every current caller compile with a stable key. Add the same
required input to PortalInlinePlayerComponent and VodDetailsComponent,
which only thread the host-owned value to their nested player:
readonly playbackSessionKey = input.required<string>();
Update every stub for these three components with the same required input:
readonly playbackSessionKey = input.required<string>();
- Step 3: Bind direct live hosts
Use createPlaybackSessionKey in each component. The M3U shape is:
readonly playbackSessionKey = computed(() => {
const playlistId = this.activePlaylistId();
const channel = this.activeChannel();
return playlistId && channel
? createPlaybackSessionKey({
kind: 'live',
sourceId: playlistId,
contentId: channel.id,
})
: '';
});
Xtream and Stalker use their current playlist and selected item IDs in the same
shape. Unified live uses activeItem().playlistId and activeItem().uid.
Bind every direct view:
<app-web-player-view [playbackSessionKey]="playbackSessionKey()" />
Add that binding to each existing component tag without deleting or changing any of its other input/output bindings. Do not include the current stream/catch-up URL in these keys.
- Step 4: Derive VOD and episode keys in their owning hosts
Xtream VOD derives its key from the current route playlist and original
selectedVodId, not inlinePlayback().contentInfo. Xtream series derives an
episode key from the route playlist/series plus the host's active original
episode, season, and episode coordinates. The shapes are:
readonly vodPlaybackSessionKey = computed(() =>
createPlaybackSessionKey({
kind: 'vod',
sourceId: this.xtreamStore.currentPlaylist()?.id ?? '',
contentId: this.selectedVodId(),
})
);
readonly episodePlaybackSessionKey = computed(() => {
const originalEpisode = this.playback.inlineEpisodeState()?.episode;
return createPlaybackSessionKey({
kind: 'episode',
sourceId: this.xtreamStore.currentPlaylist()?.id ?? '',
contentId: originalEpisode?.id ?? '',
seriesId: this.routeParams().serialId ?? '',
seasonNumber: originalEpisode?.season,
episodeNumber: originalEpisode?.episode_num,
});
});
Use the equivalent original catalog/route item and active episode identity in
Stalker VOD and series hosts. Thread the required key through any
StalkerInlineDetailComponent/VodDetailsComponent intermediary, bind it to
PortalInlinePlayerComponent, and have that component pass the exact input to
its nested WebPlayerViewComponent.
Do not derive or fall back from playback.contentInfo, streamUrl, or title.
Alternative-source resolution intentionally rewrites contentInfo.playlistId
and contentXtreamId for the selected provider copy; those fields remain
playback/resume metadata and are not recovery-session identity. Host specs must
replace the full playback payload with such an alternative copy and prove the
required key is unchanged.
- Step 5: Verify every required binding and host project
Run:
rg -l "<app-web-player-view|<app-portal-inline-player|<app-vod-details" libs apps --glob '*.html'
Inspect every returned template and confirm each required link in the player
chain binds [playbackSessionKey]. Then run:
pnpm nx test playlist-m3u-feature-player
pnpm nx test portal-xtream-feature
pnpm nx test portal-stalker-feature
pnpm nx test portal-shared-ui
pnpm nx test ui-playback
pnpm nx lint playlist-m3u-feature-player
pnpm nx lint portal-xtream-feature
pnpm nx lint portal-stalker-feature
pnpm nx lint portal-shared-ui
pnpm nx lint ui-playback
Expected: all host identity and existing playback tests pass.
- Step 6: Commit stable key wiring
git add libs/playlist/m3u/feature-player libs/portal/xtream/feature libs/portal/stalker/feature libs/portal/shared/ui libs/ui/playback
git commit -m "feat(playback): identify content recovery sessions"
Task 6: Build The Presentational Ranked Diagnostic Panel
Files:
-
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.ts -
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.html -
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.scss -
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts -
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.ts -
Create:
libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts -
Modify:
apps/web/src/assets/i18n/*.json -
Step 1: Write failing view-model and component tests
Assert the exact mappings:
expect(getRecommendationTestId(player('videojs'))).toBe(
'playback-recommendation-videojs'
);
expect(getRecommendationTestId(player('html5'))).toBe(
'playback-recommendation-html5'
);
expect(getRecommendationTestId(player('artplayer'))).toBe(
'playback-recommendation-artplayer'
);
expect(getRecommendationTestId(player('mpv'))).toBe('playback-fallback-mpv');
expect(getRecommendationTestId(player('vlc'))).toBe('playback-fallback-vlc');
expect(getRecommendationTestId(retry())).toBe('playback-retry');
The component spec must prove one primary and at most two secondary actions,
native button semantics, disabled buttons while pending, the bounded
VodSourceRow block for an alternative-source recommendation, Copy URL and
Technical details always present, and output events for Retry/player/source
selection. Assert the template has no autofocus/focus-trap behavior, and read
the component stylesheet in the spec to preserve the existing :focus-visible
outline plus the new narrow container query, one-column grid, wrapping, and
min-width: 0 guards.
Run:
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --runInBand
Expected: FAIL because the files do not exist.
- Step 2: Add the standalone component contract
Create the component with these inputs and outputs:
@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 alternativeSources = input<readonly VodSourceDescriptor[]>([]);
readonly pending = input(false);
readonly retryRequested = output<void>();
readonly playerRequested = output<PlaybackRecommendationTarget>();
readonly alternativeSourceRequested = output<string>();
readonly sourceCheckRequested = output<string>();
}
Keep the existing ERROR_SCREEN_ALTERNATIVES = 5, visibleAlternatives, and the hidden
count inside this panel. Move the existing diagnostic title/meta/codec/detail
formatters into this folder unchanged; add small exhaustive functions mapping
recommendation reasons to translation keys, player names, icons, and the stable
test IDs asserted above. Derive hasExternalPlayerRecommendation from actual
ranked mpv/vlc entries. Use it for the existing native-vs-inline headline
choice and as the boolean passed to getDiagnosticDescriptionKey; this keeps
the browser-access PWA description accurate when policy or DRM filtering
removes managed external actions.
- Step 3: Render the ranked action list without redesigning the overlay
Copy the current badge, headline, description, metadata, codec hint, source-row, Copy URL, and details markup verbatim. Replace the fixed action areas with this ordered loop:
<div
class="web-player-diagnostic__recommendations"
[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"
[class.web-player-diagnostic__alternatives--primary]="recommendation.priority === 'primary'"
[disabled]="pending()"
data-test-id="playback-alternative-sources"
>
<h3 class="web-player-diagnostic__alternatives-title">
{{ 'PORTALS.MULTI_SOURCE.TRY_ANOTHER_SOURCE' | translate }}
</h3>
@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>
activate emits Retry for retry, emits the target for player, and leaves
individual VodSourceRow buttons responsible for source selection.
isTemporaryBuiltInRecommendation returns true only when action === 'player'
and the target is one of videojs, html5, or artplayer; external targets
must not show the saved-player hint.
Do not add autofocus or a focus trap.
- Step 4: Copy and adapt the existing diagnostic styles
Copy .web-player-diagnostic* rules from
web-player-view.component.scss into the panel stylesheet. Add:
:host {
display: contents;
}
.web-player-diagnostic__recommendations {
display: grid;
grid-template-columns: repeat(2, minmax(168px, 192px));
gap: 12px;
margin-top: 28px;
}
.web-player-diagnostic__player-card {
min-width: 0;
}
.web-player-diagnostic__alternatives {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
@container (max-width: 520px) {
.web-player-diagnostic__recommendations {
grid-template-columns: minmax(0, 1fr);
gap: 8px;
margin-top: 16px;
}
}
Keep the current focus-visible outline and mobile detail layout. Do not change the overlay colors or typography.
- Step 5: Add translation-key parity
Add these keys under PLAYBACK_DIAGNOSTICS in every locale file:
{
"RECOMMENDATIONS_LABEL": "Recommended recovery actions",
"ACTION_TRY_PLAYER": "Try {{player}}",
"ACTION_TRY_PLAYER_HINT": "Temporary for this item; your saved player stays unchanged",
"REASON_RETRY_TRANSIENT_FAILURE": "Retry the same player after a temporary loading failure",
"REASON_RETRY_UNKNOWN_FAILURE": "Retry because the failure did not identify another compatible player",
"REASON_ALTERNATIVE_SOURCE_AVAILABLE": "Try another available source for the same content",
"REASON_DIFFERENT_ENGINE_FAMILY": "This player uses a different browser playback engine",
"REASON_EXTERNAL_CODEC_OR_CONTAINER_SUPPORT": "Native players support more codecs and containers",
"REASON_EXTERNAL_BROWSER_ACCESS": "A native player may avoid browser access restrictions",
"REASON_COMPATIBLE_DRM_PATH": "This target is compatible with the available encryption data"
}
The block above is the canonical English copy. Add meaning-equivalent localized values to all 18 other locale files; do not leave the new values in English in non-English files. Reuse existing localized MPV, VLC, Retry, Copy URL, and Technical details strings rather than adding duplicates.
Run:
pnpm run i18n:validate
Expected: every locale has identical keys and placeholder variables.
- Step 6: Verify and commit the unused presentational unit
The new component is intentionally not mounted until Task 7, so existing UI behavior remains unchanged in this commit.
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/playback-diagnostic-panel/playback-recommendation-view.util.spec.ts libs/ui/playback/src/lib/playback-diagnostic-panel/playback-diagnostic-panel.component.spec.ts --runInBand
pnpm nx lint ui-playback
git add libs/ui/playback/src/lib/playback-diagnostic-panel apps/web/src/assets/i18n
git commit -m "feat(ui): add ranked playback diagnostic panel"
Task 7: Integrate Policy And Temporary Player Switching
Files:
-
Create:
libs/ui/playback/src/lib/web-player-view/web-player-playback-state.ts -
Create:
libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts -
Create:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.html -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.component.scss -
Modify:
libs/ui/playback/src/lib/web-player-view/web-player-view.spec-stubs.ts -
Modify: diagnostic factories/specs under
libs/playback/utilandlibs/ui/playback -
Step 1: Extract existing playback construction before adding state
Drive web-player-playback-state.spec.ts from the current component assertions,
then extract these pure functions:
export function resolveWebPlayerPlayback(options: {
readonly playback: ResolvedPortalPlayback | null;
readonly streamUrl: string;
readonly title: string;
readonly startTime: number;
}): ResolvedPortalPlayback;
export function createWebPlayerChannel(
playback: ResolvedPortalPlayback
): Channel;
export function createVideoJsOptions(options: {
readonly streamUrl: string;
readonly isLive: boolean;
readonly reloadToken: number;
}): {
readonly isLive: boolean;
readonly reloadToken: number;
readonly sources: readonly {
readonly src: string;
readonly type: string;
}[];
};
Move the existing MIME selection and case-insensitive header lookup into these
functions without changing output. Replace setChannel, setVjsOptions, and
the resolved-playback construction in the component with these helpers.
Run:
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-playback-state.spec.ts libs/ui/playback/src/lib/web-player-view/web-player-view.component.spec.ts --runInBand
Expected: PASS before recommendation behavior is added and production
web-player-view.component.ts drops below 300 counted lines after the full
Task 7 edit.
- Step 2: Write failing WebPlayerView recovery integration tests
In the new recovery spec, use the existing player stubs and prove:
- a fatal Video.js HLS media diagnostic ranks HTML5, MPV, then VLC;
- clicking HTML5 mounts the HTML5 stub while the settings storage still says Video.js;
- a second failure excludes Video.js and HTML5 from subsequent player actions;
- Retry preserves attempts and reloads the active target;
- a same-key playback URL change preserves attempts;
- a new key clears attempts and the temporary override;
- VOD switching passes the latest finite time as start time while live passes zero;
- a stale
{ generation, target }issue cannot replace the current diagnostic; - playback with
drmnever ranks MPV/VLC; - PWA capabilities never render managed external-player actions;
- a pending switch disables another action.
Run:
pnpm nx test ui-playback -- --runTestsByPath libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts --runInBand
Expected: FAIL because policy wiring and temporary switching are absent.
- Step 3: Add session and recommendation signals to WebPlayerView
Add the required input and focused computed state:
readonly playbackSessionKey = input.required<string>();
private readonly recoverySession = new PlaybackRecoverySession();
readonly recoveryPending = this.recoverySession.switchPending;
readonly activeBinding = this.recoverySession.activeBinding;
readonly selectedPlayer = computed<VideoPlayer>(() => {
const temporary = this.recoverySession.temporaryPlayerOverride();
return temporary
? toVideoPlayer(temporary)
: (this.playerOverride() ??
this.settings()?.player ??
VideoPlayer.VideoJs);
});
readonly effectiveStartTime = computed(() =>
this.recoverySession.resumeStartTime(
this.startTime(),
this.resolvedIsLive()
)
);
Add an exhaustive toVideoPlayer helper mapping videojs to
VideoPlayer.VideoJs, html5 to VideoPlayer.Html5Player, and artplayer to
VideoPlayer.ArtPlayer; do not cast between the const-derived diagnostic type
and the settings enum.
Use one playback effect that reads playbackSessionKey first, calls
syncSession, reads the resolved playback/selected player/reload token, then
calls beginPlayback(inlineTarget) immediately before applyPlayback. For
Embedded MPV call clearPlaybackBinding. Clear the visible diagnostic for
each newly applied source but do not clear attempts when syncSession reports
the same content key. Pass the newly returned binding into applyPlayback and
require both the header service's stillCurrent result and
recoverySession.accepts(binding) before its asynchronous callback hands a
source to a web player. This ordering makes key, source, Retry, and target
changes invalidate all older callbacks.
At the start of handlePlaybackIssue, call a small syncRecoverySession()
helper that synchronizes the latest required input and clears a diagnostic if
the key changed. This closes the narrow interval between an Angular input
update and its effect flush, so an old child output cannot land in the new
content session.
Build recommendations from the current diagnostic with:
readonly recommendations = computed(() => {
const issue = this.visiblePlaybackDiagnostic();
const binding = this.activeBinding();
if (!issue || !binding) {
return [];
}
const sourceKind = resolvePlaybackSourceKind(issue);
return recommendPlaybackRecovery({
diagnostic: issue,
activeTarget: binding.target,
attemptedTargets: this.recoverySession.attemptedTargets(),
targetCapabilities: createPlaybackTargetCapabilities({
sourceKind,
managedExternalPlayersAvailable:
this.runtime.supportsManagedExternalPlayers,
}),
source: {
kind: sourceKind,
isLive: this.resolvedIsLive(),
drm: this.resolvedPlayback().drm
? 'untransferable'
: 'none',
externalTransferable: !this.resolvedPlayback().drm,
},
alternativeSourceCount: this.alternativeSources().length,
});
});
The non-DRM transferability fact is allowed only because Task 5 proves every current WebPlayerView host forwards the full required payload through its existing external-player path. If any host cannot satisfy that assertion, add a required host capability input and pass false there instead of weakening the policy or inferring safety from the URL.
- Step 4: Make engine events generation-safe
Change player outputs to pass the binding captured for that rendered branch:
@let binding = activeBinding(); @if (binding && selectedPlayer() === 'videojs')
{
<app-vjs-player
[startTime]="effectiveStartTime()"
(timeUpdate)="handleTimeUpdate($event)"
(playbackIssue)="handlePlaybackIssue($event, binding)"
/>
}
Apply the same pattern to HTML5 and ArtPlayer. In handlePlaybackIssue:
handlePlaybackIssue(
issue: PlaybackDiagnostic | null,
binding: PlaybackBinding
): void {
if (!this.recoverySession.accepts(binding)) {
return;
}
if (!issue) {
this.recoverySession.settle(binding);
this.playbackDiagnostic.set(null);
return;
}
if (!this.recoverySession.recordFailure(binding)) {
return;
}
this.playbackDiagnostic.set(issue);
this.playbackFailed.emit(issue.code);
}
Capture the same generation before the asynchronous Electron header operation;
its then callback must call recoverySession.accepts(binding) before handing
the source to a player.
handleTimeUpdate records the latest position, then forwards the unchanged
event to the host output.
- Step 5: Implement recommendation actions
Use one dispatcher:
requestRecommendedPlayer(target: PlaybackRecommendationTarget): void {
const diagnostic = this.visiblePlaybackDiagnostic();
if (!diagnostic) {
return;
}
if (target === 'mpv' || target === 'vlc') {
this.recoverySession.recordExternalAttempt(target);
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
});
return;
}
const available = this.recommendations().some(
(item) => item.action === 'player' && item.target === target
);
if (!available) {
this.recoverySession.recordInlineAttempt(target);
return;
}
if (
this.recoverySession.beginPlayerSwitch(
target,
this.resolvedIsLive()
)
) {
this.playbackDiagnostic.set(null);
}
}
The unavailable inline branch records the target through the
recordInlineAttempt(target) method defined in Task 4, leaving the diagnostic
visible so the computed list reranks immediately. Retry calls beginRetry,
and only when it returns true clears the diagnostic and increments
reloadToken; the playback effect rebuilds the same source with a new binding
without clearing attempts. Alternative source output does not reset session
state.
- Step 6: Mount the panel and remove the old overlay
Replace the entire inline diagnostic <section> in
web-player-view.component.html with:
@if (visiblePlaybackDiagnostic(); as issue) {
<app-playback-diagnostic-panel
[diagnostic]="issue"
[recommendations]="recommendations()"
[playback]="resolvedPlayback()"
[alternativeSources]="alternativeSources()"
[pending]="recoveryPending()"
(retryRequested)="retryPlayback()"
(playerRequested)="requestRecommendedPlayer($event)"
(alternativeSourceRequested)="alternativeSourceRequested.emit($event)"
(sourceCheckRequested)="sourceCheckRequested.emit($event)"
/>
}
Delete .web-player-diagnostic* rules from the WebPlayerView stylesheet now
that the panel owns them. Preserve the host/container and defer-placeholder
rules.
- Step 7: Remove diagnostic-owned fallback policy
Delete externalFallbackRecommended from PlaybackDiagnostic, the factory
option/default, classifier overrides, and every fixture assertion. Replace the
old boolean expectations with policy assertions in
playback-recommendation-policy.spec.ts.
Run:
rg -n "externalFallbackRecommended|canShowExternalFallbackActions" libs apps --glob '*.ts' --glob '*.html'
Expected: no matches.
- Step 8: Verify integration, file limits, and commit
pnpm nx test playback-util
pnpm nx test ui-playback
pnpm nx lint playback-util
pnpm nx lint ui-playback
node tools/eslint/generate-max-lines-baseline.mjs
git diff -- tools/eslint/max-lines-baseline.mjs
Expected: tests/lint pass; no new baseline entry appears; if the generator
removes a now-split UI file, retain that shrink. Every new production
TypeScript file and the edited web-player-view.component.ts remain below 300
counted lines.
git add libs/playback/util libs/ui/playback tools/eslint/max-lines-baseline.mjs
git commit -m "feat(playback): switch temporarily to recommended players"
Task 8: Cover Temporary Built-In Switching In Web E2E
Files:
-
Create:
apps/web-e2e/src/fixtures/playback/fatal-media.m3u8 -
Create:
apps/web-e2e/src/fixtures/playback/corrupt.ts -
Create:
apps/web-e2e/src/playback-recommendations.e2e.ts -
Step 1: Add the bounded offline HLS fixture
Create fatal-media.m3u8:
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:1
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:1,
corrupt.ts
#EXT-X-ENDLIST
Create corrupt.ts as the literal text:
This is intentionally not an MPEG transport stream segment.
The fixture contains no external URL or account data.
- Step 2: Write the Playwright flow
In the new E2E file:
- block service workers and run Chromium with autoplay allowed;
- route
https://playback-fixture.local/**to the two fixture files withapplication/vnd.apple.mpegurlandvideo/mp2tcontent types; - open Settings, select HTML5, and save;
- import a one-channel raw M3U pointing to
https://playback-fixture.local/fatal-media.m3u8; - select the channel and wait for the structured fatal HLS media diagnostic;
- assert
playback-recommendation-videojsis primary; - click it and assert
app-vjs-playermounts; - return to Settings and assert the persisted selector still reads HTML5.
Use these stable locators:
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
const videoJsRecommendation = page.locator(
'[data-test-id="playback-recommendation-videojs"]'
);
await expect(banner).toBeVisible({ timeout: 15_000 });
await expect(videoJsRecommendation).toBeVisible();
await videoJsRecommendation.click();
await expect(page.locator('app-vjs-player')).toBeVisible();
Do not add a test-only production API. If Chromium reports a different public terminal hls.js media detail for the bounded corrupt segment, update only the fixture to another deterministic public fatal media event; retain the component-level exact policy test.
- Step 3: Run the atomized web E2E target and commit
After Nx discovers the new file, run:
pnpm nx run web-e2e:e2e-ci--src/playback-recommendations.e2e.ts -- --project=chromium
Expected: the recommendation mounts Video.js and the Settings selector remains HTML5.
git add apps/web-e2e/src/fixtures/playback apps/web-e2e/src/playback-recommendations.e2e.ts
git commit -m "test(playback): cover temporary player recommendation"
Task 9: Cover DRM Exclusion And External Fallback In Electron E2E
Files:
-
Create:
apps/web-e2e/src/fixtures/playback/unsupported.mkv -
Modify:
apps/electron-backend-e2e/src/dash-clearkey.e2e.ts -
Modify:
apps/web-e2e/src/dash-clearkey.e2e.ts -
Step 1: Extend ClearKey assertions before adding the eligible case
For the unsupported Widevine channel in both existing E2E files, assert:
await expect(
banner.locator('[data-test-id="playback-fallback-mpv"]')
).toHaveCount(0);
await expect(
banner.locator('[data-test-id="playback-fallback-vlc"]')
).toHaveCount(0);
In web E2E also assert no built-in recommendation test ID is rendered. The web runtime already lacks managed external launch, while the Electron assertion is the required proof that DRM transferability—not runtime capability—filters the targets.
- Step 2: Add a deterministic unsupported-container fixture
Create unsupported.mkv with a small non-media payload:
This fixture intentionally declares Matroska without playable media bytes.
Extend the Electron fixture server to serve .mkv as video/matroska, and add
an Unsupported MKV channel to the imported M3U. Video.js/native media must
surface unsupported-container from the .mkv metadata rather than a network
diagnostic.
- Step 3: Capture and assert the existing MPV launch request
Install local IPC capture in the Electron test before selecting the MKV channel:
await app.electronApp.evaluate(({ ipcMain }) => {
const launches: Array<{ player: string; url: string; title: string }> = [];
(
globalThis as typeof globalThis & {
__playbackRecommendationLaunches?: typeof launches;
}
).__playbackRecommendationLaunches = launches;
ipcMain.removeHandler('OPEN_MPV_PLAYER');
ipcMain.handle('OPEN_MPV_PLAYER', async (_event, url, title) => {
launches.push({ player: 'mpv', url, title });
const now = new Date().toISOString();
return {
canClose: false,
id: 'e2e-recommended-mpv',
player: 'mpv',
startedAt: now,
status: 'opened',
streamUrl: url,
thumbnail: null,
title,
updatedAt: now,
};
});
});
Select Unsupported MKV, assert both existing MPV/VLC test IDs are visible,
click MPV, and poll the main-process capture for exactly one launch with the
fixture URL and channel title. This verifies the existing
PlaybackFallbackRequest path remains intact.
- Step 4: Run both atomized DASH/Electron targets and commit
pnpm nx run web-e2e:e2e-ci--src/dash-clearkey.e2e.ts -- --project=chromium
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts
Expected: ClearKey still plays, unsupported DRM shows no external actions, and eligible Matroska failure launches the captured MPV request.
git add apps/web-e2e/src/fixtures/playback/unsupported.mkv apps/web-e2e/src/dash-clearkey.e2e.ts apps/electron-backend-e2e/src/dash-clearkey.e2e.ts
git commit -m "test(playback): verify recommendation capability guards"
Task 10: Update Canonical Documentation And Release Note
Files:
-
Modify:
docs/architecture/embedded-inline-playback.md -
Modify:
docs/architecture/nx-workspace-boundaries.md -
Modify:
docs/architecture/player-controls-contract.md -
Modify:
AGENTS.md -
Modify:
CLAUDE.md -
Create:
.changes/playback-recovery-recommendations.md -
Step 1: Update the canonical architecture flow
In embedded-inline-playback.md, document this exact sequence:
engine public error
-> sanitized PlaybackDiagnostic (@iptvnator/playback/util)
-> recommendPlaybackRecovery(context)
-> ranked maximum-three action model
-> WebPlayerView session-local user action
Include the HLS/VHS, shared mpegts.js, Shaka/DASH, and native-media engine-family matrix; the network/unknown fail-closed rule; ClearKey/KODIPROP external suppression; temporary override lifecycle; and the no-history/no-auto-switch boundary.
- Step 2: Update workspace and controls ownership docs
Add playback-util and @iptvnator/playback/util to
nx-workspace-boundaries.md with tags
scope:shared/domain:playback/type:util. State in
player-controls-contract.md that PlayerController remains a sibling and
does not own diagnostics or recovery recommendations.
Update the Shared Player Controls/playback diagnostic sections in both
AGENTS.md and CLAUDE.md with the new path, policy ownership, content-session
key, and temporary-switch semantics. Keep overlapping process guidance in the
two root files synchronized.
- Step 3: Add the user-facing release note
Create:
---
type: fix
area: playback
issues: [1159]
---
When playback fails, IPTVnator now ranks useful next steps from the reported
error, including another compatible built-in player, MPV/VLC, Retry, or another
source. Trying a built-in recommendation affects only the current item and
does not change the saved player setting.
The body is under 400 characters and describes the user outcome.
- Step 4: Validate docs and commit
pnpm exec prettier --check docs/architecture/embedded-inline-playback.md docs/architecture/nx-workspace-boundaries.md docs/architecture/player-controls-contract.md AGENTS.md CLAUDE.md .changes/playback-recovery-recommendations.md
pnpm run release:notes:validate
git diff --check
git add docs/architecture AGENTS.md CLAUDE.md .changes/playback-recovery-recommendations.md
git commit -m "docs(playback): document recovery recommendations"
Task 11: Complete The Test-Impact Pass And Local Codex Review
Files:
-
Verify: all changed files against
origin/master -
Modify: only files required to fix review findings
-
Step 1: Verify Nx discovery, boundaries, and focused projects
pnpm nx show project playback-util
pnpm nx show projects
pnpm nx sync:check
pnpm nx test playback-util
pnpm nx lint playback-util
pnpm nx test ui-playback
pnpm nx lint ui-playback
pnpm nx test playlist-m3u-feature-player
pnpm nx test portal-xtream-feature
pnpm nx test portal-stalker-feature
pnpm nx test portal-shared-ui
Expected: all commands pass and Nx reports the new project with all three tags.
- Step 2: Verify application builds and repository gates
pnpm nx build web
pnpm nx run electron-backend:build-e2e
pnpm run i18n:validate
pnpm run release:notes:validate
pnpm run skills:validate
git diff --check origin/master...HEAD
skills:validate is required because AGENTS.md/CLAUDE.md document literal
repository paths used by skills and agents. Expected: all commands pass.
- Step 3: Re-run the atomized playback E2E coverage
pnpm nx run web-e2e:e2e-ci--src/playback-recommendations.e2e.ts -- --project=chromium
pnpm nx run web-e2e:e2e-ci--src/dash-clearkey.e2e.ts -- --project=chromium
pnpm nx run electron-backend-e2e:e2e-ci--src/dash-clearkey.e2e.ts
Expected: all three targets pass without real provider traffic.
- Step 4: Run the requested local Codex P1/P2 review
The shell-installed npm wrapper is currently missing its native binary; use the bundled desktop binary directly:
/Applications/Codex.app/Contents/Resources/codex review \
--base origin/master \
"Review this playback recovery PR. Report only actionable P1/P2 correctness, privacy, race, lifecycle, DRM-transfer, Nx-boundary, and regression issues. Verify temporary switching never mutates persisted settings and stale engine events cannot affect a new content session."
Expected: no actionable P1/P2 findings. For every finding, reproduce it with a
focused failing test, implement the smallest correction, rerun the affected
project/E2E target, and commit with a scoped fix(playback): ... message. Run
the same review once more after fixes.
- Step 5: Inspect the final branch without publishing it
git status --short --branch
git log --oneline origin/master..HEAD
git diff --stat origin/master...HEAD
git diff --check origin/master...HEAD
Expected: clean worktree, only intentional commits/files, and no whitespace errors. Do not push or create the PR until the user explicitly authorizes those GitHub mutations.