Files
iptvnator/docs/superpowers/plans/2026-08-03-playback-recommendations.md
T
4gray fd96b85c19 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
2026-08-08 01:04:39 +02:00

2080 lines
78 KiB
Markdown

# 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.json` and `tsconfig.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:
```bash
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:
```bash
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:
```bash
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`:
```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`:
```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`:
```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`:
```json
"@iptvnator/playback/util": ["libs/playback/util/src/index.ts"]
```
Run:
```bash
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:
```bash
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`:
```typescript
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:
```typescript
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:
```typescript
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`:
```typescript
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:
```typescript
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:
```bash
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:
```bash
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**
```bash
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:
```typescript
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:
```bash
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:
```typescript
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:
```typescript
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:
```typescript
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`:
```typescript
/**
* 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:
```bash
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:
```bash
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:
```typescript
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 `unknown` source 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:
```typescript
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:
```typescript
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**
```bash
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**
```bash
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:
```typescript
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:
```typescript
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:
- `syncSession` is 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 `Signal` values backed by private
writable signals. `beginPlayback` always 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.
`clearPlaybackBinding` delegates 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.
- `recordFailure` and `settle` first call `accepts`; stale generations do
nothing. Both clear `switchPending` for an accepted replacement binding; a
replacement target that fails before emitting a success/clear event must not
leave every recovery action disabled.
- `beginPlayerSwitch` returns 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 calls `beginPlayback` are therefore rejected.
- `beginRetry` returns 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.
- `recordTimeUpdate` stores only finite non-negative VOD positions; live,
`NaN`, `Infinity`, and negative positions are ignored.
- `resumeStartTime` always returns 0 for live playback and otherwise prefers
the stored finite position over the host input.
- `accepts` compares both the generation and exact target against
`activeBinding`; a binding for the old target is stale even if a caller
accidentally reuses its generation.
- [ ] **Step 3: Verify and commit the session helper**
```bash
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 `contentInfo` but 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:
```bash
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:
```typescript
readonly playbackSessionKey = input.required<string>();
```
Update every stub for these three components with the same required input:
```typescript
readonly playbackSessionKey = input.required<string>();
```
- [ ] **Step 3: Bind direct live hosts**
Use `createPlaybackSessionKey` in each component. The M3U shape is:
```typescript
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:
```html
<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:
```typescript
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:
```bash
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:
```bash
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**
```bash
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:
```typescript
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:
```bash
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:
```typescript
@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:
```html
<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:
```scss
: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:
```json
{
"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:
```bash
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.
```bash
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/util` and
`libs/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:
```typescript
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:
```bash
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:
1. a fatal Video.js HLS media diagnostic ranks HTML5, MPV, then VLC;
2. clicking HTML5 mounts the HTML5 stub while the settings storage still says
Video.js;
3. a second failure excludes Video.js and HTML5 from subsequent player actions;
4. Retry preserves attempts and reloads the active target;
5. a same-key playback URL change preserves attempts;
6. a new key clears attempts and the temporary override;
7. VOD switching passes the latest finite time as start time while live passes
zero;
8. a stale `{ generation, target }` issue cannot replace the current diagnostic;
9. playback with `drm` never ranks MPV/VLC;
10. PWA capabilities never render managed external-player actions;
11. a pending switch disables another action.
Run:
```bash
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:
```typescript
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:
```typescript
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:
```html
@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`:
```typescript
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:
```typescript
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:
```html
@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:
```bash
rg -n "externalFallbackRecommended|canShowExternalFallbackActions" libs apps --glob '*.ts' --glob '*.html'
```
Expected: no matches.
- [ ] **Step 8: Verify integration, file limits, and commit**
```bash
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.
```bash
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`:
```text
#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:
```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:
1. block service workers and run Chromium with autoplay allowed;
2. route `https://playback-fixture.local/**` to the two fixture files with
`application/vnd.apple.mpegurl` and `video/mp2t` content types;
3. open Settings, select HTML5, and save;
4. import a one-channel raw M3U pointing to
`https://playback-fixture.local/fatal-media.m3u8`;
5. select the channel and wait for the structured fatal HLS media diagnostic;
6. assert `playback-recommendation-videojs` is primary;
7. click it and assert `app-vjs-player` mounts;
8. return to Settings and assert the persisted selector still reads HTML5.
Use these stable locators:
```typescript
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:
```bash
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.
```bash
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:
```typescript
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:
```text
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:
```typescript
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**
```bash
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.
```bash
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:
```text
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:
```markdown
---
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**
```bash
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**
```bash
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**
```bash
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**
```bash
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:
```bash
/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**
```bash
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.