From faad8fd8fd7f4bfb34eaab679318ced6f0055f5b Mon Sep 17 00:00:00 2001
From: 4gray <4gray@users.noreply.github.com>
Date: Mon, 21 Sep 2026 18:07:14 +0200
Subject: [PATCH] docs(agents): compact root guidance and preserve
task-specific knowledge (#1645)
* docs(agents): compact root guidance and preserve task-specific knowledge
* fix(agents): parse guidance navigation with Markdown tokens
* fix(agents): validate generic literal repository paths
* fix(agents): distinguish code symbols and shortcut images
* fix(agents): recognize SCSS filename literals
* fix(agents): handle fenced imports and encoded paths
* fix(agents): parse prose and rendered HTML anchors
* fix(agents): validate rendered HTML navigation
* fix(agents): use GitHub-compatible heading slugs
* fix(agents): require standalone top-level Claude import
* fix(agents): exclude HTML-contained guidance imports
* fix(agents): handle image fragments and quoted imports
* fix(agents): validate visible HTML and image source sets
* fix(agents): recognize package scopes and route source work
* fix(agents): parse JSONC and constrain package exemptions
* fix(agents): decode link entities and allow package subpaths
* fix(agents): route source work and check extensionless files
* fix(agents): support package versions and source fragments
* fix(agents): accept qualified package prose
* fix(agents): retain rendered context for Markdown references
* fix(agents): validate visible headings and spaced paths
* fix(agents): validate media and hyphenated literal paths
* fix(agents): decode full HTML entities and media assets
* fix(agents): recognize possessive package mentions
* fix(agents): validate extensionless imports and version comparators
* fix(agents): retain visible backticks and explicit path punctuation
* fix(agents): validate image-map navigation targets
* fix(agents): count all Markdown line endings in budgets
* fix(agents): delimit package prose at Unicode punctuation
* fix(agents): normalize punctuation for extensionless imports
* fix(agents): preserve filenames across prose punctuation
* fix(agents): validate iframe document references
* fix(agents): inspect document suffix before URL fragments
* fix(agents): unify Markdown suffix and encoded import guards
* fix(agents): handle wildcard versions and alternate documents
* fix(agents): validate document formats and trim HTML URLs
* fix(agents): cover document families and guidance basenames
* fix(agents): require files for media references
* fix(agents): preserve block boundaries and validate embeds
* fix(agents): normalize internal HTML URL whitespace
* fix(agents): reject empty media and ignore URL at-signs
* fix(agents): validate srcdoc references and empty srcset
* fix(agents): honor HTML bases and preserve adjacent imports
* fix(agents): convert base file URLs to native paths
* fix(agents): preserve imports after bare URL punctuation
* fix(agents): exclude opaque URI prose from import scans
* fix(agents): keep import tokens outside URI scheme matches
* fix(agents): restrict opaque URI exemptions to parsed links
* fix(agents): handle opening prose delimiters
* fix(agents): scan nested imports and share document suffixes
* fix(agents): reject pathless media and direct file URLs
* fix(agents): reject file bases and preserve quoted URL boundaries
* fix(agents): distinguish URL quotes and cover guidance variants
* fix(agents): validate SVG images and conventional guides
* fix(agents): handle declared package names handles and SVG use
* fix(agents): normalize closing punctuation on federated handles
* fix(agents): normalize Unicode punctuation on handles
* fix(agents): normalize possessive federated handles
* fix(agents): separate parenthetical prose from handles
* fix(agents): exclude www autolinks from import scanning
* ci: allow manual CodeQL validation of PR branches
* fix(agents): reject nonportable Windows drive links
---
.github/workflows/ci.yml | 3 +
.github/workflows/codeql-analysis.yml | 1 +
...026-09-20-agent-guidance-reorganization.md | 67 +
AGENTS.md | 1334 +----------
CLAUDE.md | 2040 +----------------
README.md | 8 +
docs/architecture/m3u-playlist-module.md | 41 +
docs/architecture/nx-workspace-boundaries.md | 16 +
docs/architecture/player-controls-contract.md | 36 +
docs/architecture/pwa-self-hosted.md | 22 +
docs/architecture/validation-map.md | 38 +
docs/architecture/workspace-dashboard.md | 10 +
.../xtream-portal-compatibility.md | 28 +
docs/development/agent-workflow.md | 223 ++
docs/development/electron-debugging.md | 79 +
docs/maintenance/agent-context-map.md | 59 +
docs/maintenance/agent-guidance-migration.md | 802 +++++++
package.json | 4 +
pnpm-lock.yaml | 14 +
tools/embedded-mpv/README.md | 2 +
tools/skills/agent-guidance-markdown.mjs | 392 ++++
tools/skills/project.json | 26 +-
tools/skills/validate-agent-guidance.mjs | 321 +++
tools/skills/validate-agent-guidance.test.mjs | 1868 +++++++++++++++
24 files changed, 4178 insertions(+), 3256 deletions(-)
create mode 100644 .plans/2026-09-20-agent-guidance-reorganization.md
create mode 100644 docs/development/agent-workflow.md
create mode 100644 docs/development/electron-debugging.md
create mode 100644 docs/maintenance/agent-context-map.md
create mode 100644 docs/maintenance/agent-guidance-migration.md
create mode 100644 tools/skills/agent-guidance-markdown.mjs
create mode 100644 tools/skills/validate-agent-guidance.mjs
create mode 100644 tools/skills/validate-agent-guidance.test.mjs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db7634ba9..f0b1910c6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -147,6 +147,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ - name: Validate agent guidance
+ run: pnpm run agents:validate
+
- name: Validate Nx dependency version policy
run: pnpm run deps:nx:validate
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 5f87782aa..93b8e120f 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -6,6 +6,7 @@
name: "CodeQL"
on:
+ workflow_dispatch:
push:
branches: [master]
pull_request:
diff --git a/.plans/2026-09-20-agent-guidance-reorganization.md b/.plans/2026-09-20-agent-guidance-reorganization.md
new file mode 100644
index 000000000..4a7677f69
--- /dev/null
+++ b/.plans/2026-09-20-agent-guidance-reorganization.md
@@ -0,0 +1,67 @@
+# Agent guidance reorganization — issue #1643
+
+Approved implementation plan, 2026-09-20.
+
+## Outcome
+
+One source of common instructions: AGENTS.md (at most 200 lines / 16 KiB).
+CLAUDE.md imports @AGENTS.md and contains only Claude-specific guidance
+(at most 30 lines / 2 KiB). Do not increase Codex loading limits. No runtime
+or public API changes.
+
+## Knowledge preservation
+
+Inventory both original files at the starting commit in
+`docs/maintenance/agent-guidance-migration.md`. Record source section and line
+ranges, destination document and heading, and whether each contract was moved,
+merged with an existing equivalent, or corrected with evidence. Split long
+player sections into individual contracts. Preserve exceptions, commands,
+rationale and platform constraints. Do not create a required monolithic archive.
+
+## Destinations
+
+Use existing authoritative docs first: Nx boundaries for structure/dependencies;
+validation-map for tests/lint; release-pipeline and release skills for releases;
+sqlite-db-worker and the database README for IPC/migrations; m3u-playlist-module
+for M3U/XMLTV/startup/source health; Xtream/Stalker compatibility docs for portals;
+player-controls-contract for web controls/radio/sleep; embedded-mpv-native for
+native runtime/packaging; UI guidelines, detail navigation and remote control for
+navigation; PWA/host connectivity/security docs for networking; existing download,
+TMDB, multi-source, workspace and backup docs for their domains; website README
+for website policy.
+
+Create docs/development/agent-workflow.md for documentation/skill maintenance and
+Angular conventions, and docs/development/electron-debugging.md for CDP/tracing.
+Add a developer navigation link in README.md.
+
+## Root guidance and navigation
+
+Retain project purpose, essential commands, .nvmrc/frozen install/Nx bootstrap,
+scoped imports and boundaries, migration safety, credential redaction, regression
+coverage, release-note/doc requirements, protected Markdown formatting and plan
+storage. Preserve the Nx-managed block/markers, conditional on available tools.
+Replace mandatory root-file updates with updates to each subsystem's canonical
+doc. Root instructions hold only universal rules and a compact topic routing table.
+Create docs/maintenance/agent-context-map.md with topics, code paths, docs and
+skills. Read affected contracts only; cross-domain work reads each relevant one.
+Update existing skills rather than proliferating copies; preserve byte-identical
+release mirrors. No mass nested instructions in this change.
+
+## Tooling
+
+Extend repository-skills (no new Nx project) with agents:validate and node:test
+coverage. Check UTF-8 bytes/line budgets, one standalone @AGENTS.md import in
+CLAUDE.md and no other root imports, local navigation/map/migration links and
+anchors, and literal repository paths without treating globs/commands as paths.
+Add an unconditional CI validation step and correct Nx test inputs/lint commands.
+
+## Acceptance
+
+Tests cover exact/over budgets, UTF-8, LF/CRLF, missing/duplicate/extra imports,
+missing local files and anchors. Run frozen install, Nx discovery, repository-skills
+test/lint, agents:validate, skills:validate, release:notes:validate, git diff --check
+and workflow validation. Audit every source block to a destination, with no
+unresolved or lost unique contract. Walk navigation for XMLTV, Xtream, MPV,
+migrations and releases. App unit/E2E is unnecessary (no runtime changes); no
+release note for docs/tooling validation. Do not run whole-file Prettier on docs,
+AGENTS.md or CLAUDE.md.
diff --git a/AGENTS.md b/AGENTS.md
index 57b493557..3119f7d6a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,1232 +1,128 @@
-# AGENTS.md
-
-This file provides guidance to coding agents working in this repository.
-
-## Plan Mode
-
-- When an agent is in Plan Mode and produces a final ``, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.
-- Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`.
-- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.
-- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.
-
-## Agent Bootstrap
-
-- In a fresh worktree, run `pnpm install --frozen-lockfile` before relying on Nx project discovery, lint, test, or build commands. Without `node_modules`, `pnpm nx show projects` will fail because the local Nx modules are unavailable.
-- Re-run the install whenever the checkout moves — `git pull`, `git reset --hard`, a rebase, or a worktree branch being re-pointed. Git rewrites `pnpm-lock.yaml` but never re-links `node_modules`, so a tree installed at an older commit keeps serving the old dependency versions and tests fail locally while CI stays green. Check with `cmp pnpm-lock.yaml node_modules/.pnpm/lock.yaml`; any difference means the tree is stale, and a plain `pnpm install --frozen-lockfile` in that directory repairs it. Each worktree needs its own install — with no local `node_modules`, Nx aborts with `Could not find ".modules.yaml"`.
-- Never run `prettier --write` on `CLAUDE.md`, `AGENTS.md` or `docs/**`. These files are not Prettier-clean upstream, so a whole-file write reflows passages the change never touched — a nested list item loses its indentation, a `+ player` continuation line turns into a `- player` bullet — and the review bots flag the diff as corrupted guidance (PR #1628). Format only the lines you wrote. If a write already happened, restore the file from the branch's merge base (`git show $(git merge-base HEAD origin/master):CLAUDE.md > CLAUDE.md`) and re-apply the intended edit by hand.
-- After dependencies are installed, verify workspace discovery with `pnpm nx show projects`.
-- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
-- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.
-- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Keep `nx` and every official `@nx/*` package on the same exact version; run
- `pnpm run deps:nx:validate` after dependency updates.
-- Use the Node version in `.nvmrc` for development and CI. Angular 22 requires
- Node `^22.22.3 || ^24.15.0` and TypeScript `>=6.0 <6.1` in this workspace.
-- Vite `8.1.5`, resolved through Angular's build tooling, retains upstream
- precise matchers and adds bounded raw-code prefilters through
- `patches/vite@8.1.5.patch`. Keep the patch until upstream also preserves
- comment-bearing asset/worker expressions; run `pnpm run deps:vite:test`
- after related dependency updates.
-- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in
- `patches/app-builder-lib@26.15.7.patch` with the upstream backport
- electron-userland/electron-builder#10172: `security set-key-partition-list -k`
- must receive the temporary keychain's own password, not the `.p12` import
- password. macOS runner images since `macos-26-arm64` 20260831 verify that
- password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
- name or passphrase you entered is not correct`. Keep the patch until
- electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
- and run `pnpm run deps:electron-builder:test` after related dependency
- updates — the test fails when the patched version no longer matches the
- installed one.
-- `node-gyp` is a declared root devDependency because
- `apps/electron-backend/build-embedded-mpv.js` resolves it with
- `require.resolve`. Do not drop it as "unused": without the declaration it is
- reachable only through pnpm's hidden hoist (`node_modules/.pnpm/node_modules`),
- which pnpm's `.bin` shims put on `NODE_PATH` — so `pnpm nx …` and CI keep
- working while a plain `node apps/electron-backend/build-embedded-mpv.js`
- fails on a clean install with "Unable to resolve node-gyp".
-- `nx-electron@22.0.0` uses a local Nx 23 export-path patch and an explicit
- `webpack-node-externals` package extension. Scoped peer allowances for it
- and `ngx-indexed-db@22.0.0` live in `pnpm-workspace.yaml`; they are project
- compatibility bridges, not upstream support declarations. See
- `docs/architecture/nx-workspace-boundaries.md` before removing them.
-- A directory holding files consumed by other projects must be an Nx project.
- Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`
- across project roots creates no edge and the imported file lands in no task
- hash — edits then return a cache hit instead of rebuilding. Shared partials
- live in `libs/ui/styles` (project `ui-styles`), and each consumer declares
- `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate`
- after adding a cross-project stylesheet import.
-- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the
- lockfile, run generated migrations when present, and validate before opening
- a PR. Major updates are always manual. Replace incomplete Dependabot security
- PRs with a coordinated update instead of editing the bot branch.
-- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused.
-- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`.
-- Repository-specific skills live under `.codex/skills/`.
-- Frontmatter descriptions are trigger-only and begin with `Use when`; keep
- each skill at or below 500 words.
-- Run `pnpm run skills:validate` after editing a committed skill or a literal
- path it documents.
-- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`
- byte-identical.
-
-## Documentation After Changes
-
-- After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete.
-- Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries.
-- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.
-- Prefer updating an existing authoritative doc before creating a new one:
- 1. `README.md` for top-level developer or user workflows
- 2. `docs/architecture/` for architecture, ownership, and behavior contracts
- 3. the nearest module `README.md` for local usage or behavior
-- Keep the root `CLAUDE.md` and this file up to date. They are living documents: whenever a change touches something they describe — monorepo structure (new/moved/renamed apps or libs), routes, database schema/tables, stores and their features, key components, commands, environment behavior, or coding conventions — update the affected sections as part of the same task, and keep the process sections mirrored between `AGENTS.md` and `CLAUDE.md` in sync.
-- When adding a new feature area, check whether the Architecture or Key Features sections of `CLAUDE.md` describe the surrounding area; if they do, reflect the addition there instead of leaving the description stale.
-- Do not let `CLAUDE.md` or `AGENTS.md` drift: a stale path or route in these files poisons the context of every future agent session. If you notice an outdated claim while working, fix it (or flag it in the final summary) even if it is unrelated to the current task.
-- Repo docs are canonical even when they were originally drafted by an LLM.
-- Final task summaries should state whether docs were updated and which doc changed.
-
-## Release Notes For User-Visible Changes
-
-- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
-- Name it ` -.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
-- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
-- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
-- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
-- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
-- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
-- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
-- Validate before finishing: `pnpm run release:notes:validate`.
-- Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
-- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
-- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
-- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
-- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image.
-- Final task summaries should state whether a release note was added or why it was skipped.
-
-## AppImage Manager Metadata
-
-AppManager full-download discovery uses `appImage.desktop.entry` URL fields.
-Electron Builder generates the version; `extraMetadata.desktopName=iptvnator`
-preserves Linux window identity without a shared `linux.desktop.entry` object
-(builder's nested merge would leak AppImage fields into Snap). This does not
-enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md`
-(AppImage external-manager metadata).
-
-## Upgrade And Migration Compatibility
-
-- Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version.
-- Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception.
-- Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns.
-- For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores.
-- See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance.
-
-## Regression Prevention And Test Updates
-
-- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
-- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
-- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
-- Default validation ladder:
- 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
- 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
- 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
- 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
-- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
-- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
-
-## Legacy Desktop Profile Migration
-
-`electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend`
-profile before eager main-process imports only when current Chromium storage
-is unused. Existing profiles retain their settings and offer explicit recovery
-of missing sources from a disposable legacy snapshot. Playlist rows and a
-completion receipt commit atomically in the DB worker; original IndexedDB is
-retained, current payload rows are preserved, and completed imports never
-replay deleted sources. Contract and recovery limits:
-`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
-
-Startup shows `AppStartupStatusComponent` until the initial route and source
-inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
-failed reads show an explicit Retry action instead of an empty library. Successful
-inventory reads first await settings loading, then pending XMLTV reconciliation,
-and retry failed cleanup
-with its last committed URLs before exposing the workspace. See the
-same contract for startup readiness and error handling.
-
-## Electron Debugging (CDP)
-
-- Start the Electron development app with: `nx serve electron-backend`
-- Package-script equivalent: `pnpm run serve:backend`
-- Electron is configured to start with: `--remote-debugging-port=9222`
-- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`
-- For Electron automation/debugging tasks, use the `electron` skill
-- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
-- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black.
-- If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field.
-- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue.
-
-### Trace / Debug Startup
-
-- Full startup tracing:
-
-```bash
-IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
-```
-
-- Narrower trace flags:
- - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
- - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
- - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
- - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
- - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
- - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
- - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
- - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
-
-- Settings, portal request/response, and trace payloads must use
- `@iptvnator/shared/logging` or the redacting portal logger before reaching
- `console.*`; never log raw credentials while debugging.
-
-- If local Nx state gets weird before a rerun:
-
-```bash
-pnpm nx reset
-```
-
-### agent-browser (global install)
-
-```bash
-agent-browser --cdp 9222 tab list
-agent-browser --cdp 9222 tab 1
-agent-browser --cdp 9222 snapshot -i -c -d 4
-agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png
-```
-
-### Fallback
-
-```bash
-npx --yes agent-browser --cdp 9222 tab list
-```
-
-### DevTools Workaround
-
-```bash
-ELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend
-curl http://127.0.0.1:9222/json/list
-agent-browser connect ws://127.0.0.1:9222/devtools/page/
-agent-browser screenshot /tmp/iptvnator-cdp.png
-```
-
-## Xtream Category Management
-
-The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
-to search results while a filter is active and to the whole type otherwise.
-Button states use the matching group; "Total selected" counts the whole catalog.
-Save persists the complete draft, Close discards it, and refresh restores hidden
-categories by provider ID and type. See `docs/architecture/category-management.md`.
-
-## XMLTV Response Compression
-
-Electron decodes HTTP compression before the gzip file layer. For `.gz`/gzip
-metadata plus HTTP gzip, a streaming signature check unwraps one remaining
-file layer while preserving single-layer providers. Errors and cancellation
-close the decoding chain. Contract: `docs/architecture/m3u-playlist-module.md`
-("XMLTV response compression").
-
-## XMLTV Source Removal
-
-Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
-all enabled M3U playlist sources. Startup runs the same reconciliation after
-settings load and playlist migration. Ordinary saves skip unchanged normalized
-source sets; an explicitly edited EPG field can retry a failed cleanup.
-A cleanup failure after persistence still mirrors committed settings to Electron;
-the form stays dirty for retry. Failed storage writes never mirror to main.
-Failed settings reads and incomplete playlist migration never authorize pruning.
-Removed sources retire queued/running imports and dismiss retained error rows
-before worker-owned deletion. Retry waits for reconciliation and rechecks its
-error row, including after trust-setting writes. Shared channel IDs survive while
-another source has programmes or per-source channel metadata. The additive
-`epg_channel_sources` table preserves each imported source's name, logo, URL and
-timestamp plus transaction-ordered `write_order`, so removal restores the latest
-surviving snapshot even when import timestamps tie; ambiguous legacy metadata
-falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
-longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
-invalidation prevent late results from restoring removed programmes. Provider
-EPG is independent. See `docs/architecture/m3u-playlist-module.md`
-("XMLTV source lifecycle").
-
-## Web Backend Provider Redirects
-
-All four provider proxy routes use `ValidatedHttpClient`: automatic redirects
-are disabled, the initial URL and at most five redirect hops pass full URL/DNS
-validation, and fresh agents pin each connection to that hop's validated IPs.
-Host/SNI and TLS verification remain intact; outbound environment proxies are
-disabled. Private-network opt-in applies to the chain. Cross-origin redirects
-strip session headers; original query params are not replayed. One portal
-admission owns the entire chain and final body, with explicit redirect evidence
-preventing destination failures from penalizing the initial endpoint. Contracts:
-`docs/architecture/pwa-self-hosted.md` and
-`docs/architecture/host-connectivity-guard.md`.
-
-## Portal Connectivity Preference
-
-- Half-open trial slots follow the complete request lifetime with no elapsed-time
- expiry. All four Electron/web-backend portal handlers release in `finally`,
- independently of outcome reporting; cleanup preserves trial/epoch ownership
- and works while the environment override is disabled. Contract:
- `docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
- request lifetime").
-- Desktop Settings > General > Portal connections exposes default-on
- `Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
- the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
- settings bootstrap restores it before the renderer loads. It controls Xtream
- and Stalker together. Preference transitions clear cooldowns and invalidate old
- request completions; unchanged saves preserve evidence. The environment switch
- `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
- control the shared backend's guard.
-- Both account-info dialogs explain guard refusals with localized paused-request
- copy and Retry now; Stalker preserves cached account data on a failed refresh.
- Contract: `docs/architecture/host-connectivity-guard.md`.
-
-## Live Channel Return
-
-Xtream and Stalker (including radio) capture displayed playback order on explicit
-selection. Remote up/down, numbers and status use that queue while browsing
-categories or search. Stalker commits after successful current URL resolution
-and extends only loaded pages of the original scope. The conditional channel
-header action clears search, returns to the accessible playing category and
-focuses its row without changing playback. Contract:
-`docs/architecture/remote-control.md` (Live channel return and playback order).
-
-## Live Channel Open In Playlist
-
-Live rows in the unified favorites/recent tab carry the live counterpart of
-the VOD "View in portal" handoff: `getLiveCollectionPlaylistNavigation()`
-(`libs/portal/shared/util`) lands on the channel INSIDE its playlist — Xtream
-via `openXtreamLiveItemId`, M3U via `openM3uChannelUrl`, Stalker via
-`buildStalkerLiveNavigationTarget` + `openStalkerLiveItemId`, consumed by
-`StalkerLiveAutoOpen` in the ITV layout (waits for the requested portal,
-locates the channel in the full ITV list cache, selects its genre, defers
-playback until that genre's rows are on screen; a portal without a full list,
-a transient list failure or a censored channel falls back to the remembered
-genre; Stalker radio stays hidden, M3U radio keeps the row menu). Surfaces: `app-open-in-playlist-chip` in the EPG
-toolbar (`[epgToolbarAction]` slot) and the row context menu. Contract:
-`docs/architecture/portal-detail-navigation.md`.
-
-## Stalker Live Search
-
-ITV sidebar and fullscreen searches independently filter the complete selected
-category; only All Items searches the whole public catalog. Cached categories
-search before windowing; missing/censored genres keep provider pagination,
-including automatic continuation for short or empty search results. ITV search
-never narrows shared provider pages or resets their index. Category changes
-reset list windows and retain playback/active EPG. Contract:
-`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
-
-## Live TV Panel Levels
-
-Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
-from the outside in, in three nested levels owned by `LiveSidebarState`
-(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
-+ player), `categories-hidden` (channels rail + player) and `collapsed`
-(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
-surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
-shell context sidebar folds the categories rail on
-`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
-a selected category — the live root has no channels header to host the way
-back — and always at level 3), the channels rail folds on
-`isCollapsedFor('portal')`. While the rail is folded the
-channels header turns its title into a category dropdown that opens the same
-`WorkspaceContextPanelComponent` as a CDK popover through the
-`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
-`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
-closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
-the live layouts reach it through `createLivePanelsController()` (level
-flags, dropdown bridge and focus handoff in one shared object; the token is
-optional). `Cmd/Ctrl+B`, the header toggle and the
-floating restore handle return to the level the user collapsed from (the
-target is session-only; every level is restored as stored per surface).
-Folded rails carry `inert`, and
-`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
-replacement affordance only when the activated button was removed or inerted.
-M3U and the unified live tab have no categories rail and treat level 2 like
-level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
-("Collapsible Live Sidebar").
-
-## Channel and Detail Keyboard Scrolling
-
-Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
-focuses the viewport, native scrolling survives virtual row recycling, and
-row Enter/Space activation stays separate from focus movement. Portal Live TV
-uses ArrowRight from the selected category and ArrowLeft from the channels
-pane to move between columns. Shared live sidebars reserve scrollbar space
-beside the resize handle. `PortalDetailShellComponent` owns a visible native
-scrollbar and guarded initial page focus. Its one sticky arrow is the host's
-Back action in browse and watch alike; only Escape unwinds one level (close
-inline playback to browse, then Back), and the now-playing bar's Close button
-is the pointer way back to browse. Browse Escape requires
-focus inside the shell; watch preserves the global close shortcut. Menus,
-dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain
-their keys. M3U and collection bootstrap shells set `backAvailable=false` when
-there is no browse return action. Contracts:
-`docs/architecture/iptvnator-ui-guidelines.md` and
-`docs/architecture/portal-detail-navigation.md`.
-
-## Xtream Connection Test
-
-Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before
-the click and can replace an unavailable HTTPS base with a
-verified active HTTP base in the form. Only initial refused-port or TLS
-wrong-version evidence permits the same-host attempt; HTTP errors, certificate
-failures and redirect failures do not. Add/Save persists `serverUrl`, and the
-routed session observes the metadata change. Passive checks never change the
-protocol. Separate XMLTV and already-issued media/download URLs stay independent.
-Contract: `docs/architecture/xtream-portal-compatibility.md`
-("Explicit protocol discovery").
-
-## Xtream Live Auto Format
-
-The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
-HLS+TS account evidence, using the canonical URL builder and original headers.
-The same web player may try TS once after an owned initial terminal HTTP failure,
-before `playing`; the old transport unmounts before the guarded render callback
-starts TS. No player preference or playlist cache changes. Manual formats,
-unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External
-MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
-a terminal diagnostic also need manual TS. Contract and full support matrix:
-`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
-
-## Xtream Catch-Up Server Timezone
-
-The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS
-timezone (`server_info.timezone`), never the viewer's (issue #1562).
-`withPortal.checkPortalStatus()` normalizes it with
-`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
-name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
-pair) and persists it on the playlist row through
-`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
-`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
-current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
-the Favorites / Recent resolver reads the STORED row, not the store, and the
-worker interleaves requests, so no read may precede the write.
-`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
-change drops it until the next account-info check. The same value converts
-timestamp-less EPG
-`start`/`end` strings. Contract:
-`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
-panel's clock, not the viewer's").
-
-## Radio / Audio Player
-
-M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
-
-- The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player
-- The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored
-- The EPG panel is hidden (radio streams have no EPG data)
-- The layout uses a cinematic hero pattern: the station logo is blurred as a full-area backdrop with a vignette overlay, and the artwork card + controls float above it
-- Volume is shared with the video player via `localStorage` key `'volume'`
-- Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle)
-- Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean
-
-Key files:
-
-- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component
-- `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling
-- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video
-- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface
-
-## M3U Playback Mode
-
-`isLikelyM3uVod` in `libs/shared/m3u-utils` recognizes video-file extensions
-and exact `/movie/`, `/movies/`, `/vod/`, `/series/` URL pathname segments,
-independently of TMDB and `Settings.m3uVodDetails`. The M3U host's
-`embeddedPlayback()` sets `isLive: false` for those entries or a catch-up URL;
-the movie detail host forwards the same payload. Movie metadata recognition
-still excludes episodes. Ordinary HLS/TS and unknown URLs without VOD evidence,
-DASH and radio retain their existing behavior. Actual seeking requires source
-support. Xtream/Stalker, external MPV/VLC payloads and session identity are
-unchanged. Contract: `docs/architecture/m3u-playlist-module.md`
-(M3U Playback Mode).
-
-## M3U URL User-Agent
-
-- `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a
- route opened during refresh reads after its pending save. Mutation-internal
- reads keep using `getPlaylistById()` directly to avoid queue re-entry.
-- The URL import form accepts an optional User-Agent and stores it as
- `Playlist.userAgent`. Electron sends it on initial download, manual refresh,
- and startup auto-update. The self-hosted PWA sends it through the registered
- target `/parse` backend proxy for import and refresh; a matching backend is
- required, and browser playback-header restrictions still apply.
-- Reuse the existing source editor and channel-over-playlist playback header
- precedence. Contract: `docs/architecture/m3u-playlist-module.md`
- ("User-Agent for URL sources").
-
-## Shared Player Controls
-
-- Stream info popover: an `info` button in the top-right corner of the shared
- controls overlay shows live stream data — resolution + aspect ratio, measured
- frame rate, video/audio codec and bitrate, audio channels and sample rate,
- container, buffer, and dropped frames. Rendered only when the engine reports
- something (`capabilities.streamStats`), sampled once a second and only while
- the popover is open. Web engines read the `` element plus the active
- HLS/Shaka/VHS rendition; embedded MPV gets the numbers from observed mpv
- properties on the session snapshot (frame-copy engine only — the native-view
- dock does not mount the shared controls). See
- `docs/architecture/player-controls-contract.md` ("Stream info popover") and
- `docs/architecture/embedded-mpv-native.md` ("Stream Stats Properties").
- Web FPS excludes dropped frames and uses a fresh measurement window on open;
- nominal FPS and aggregate rendition bitrate have separate rows. Unknown
- video bitrate is never filled with aggregate bandwidth. MPV clears dimensions
- on a new file and clears individual diagnostics on unavailable-property events.
-
-- The Embedded MPV native-view dock follows app theme tokens as a solid app
- surface, including Material icon-button disabled states. Over-video loading,
- stalled and feedback overlays keep a paired light-on-dark palette. Video
- viewports remain black in windowed and fullscreen modes. Shared EPG panels
- use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
- Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
-
-- `libs/ui/playback/src/lib/player-controls/` contains the additive,
- engine-neutral `PlayerController` contract, standalone
- `app-player-controls`, generic web-video adapter/helper, and component-scoped
- `WEB_PLAYER_SHARED_CONTROLS` rollout token.
-- The subtitle menu carries capability-gated advanced subtitle support
- (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and
- size/color styling persisted in the shared `subtitleStyle` localStorage key.
- HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt`
- via a DOM file picker with encoding detection, native `TextTrack` rendering,
- `::cue` styling, delay only while the loaded file is the selected track;
- picks are source-generation-guarded and engine deselection precedes external
- track activation). The canonical style shape and clamp/normalize rules are
- shared with the main process via `@iptvnator/shared/interfaces`
- (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through
- helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`,
- main-process file dialog, ASS supported, delay for all tracks). Video.js
- shared mode, vendor-chrome paths, native-view, and the Linux out-of-process
- path advertise no such capability and render no UI. Contract details:
- `docs/architecture/player-controls-contract.md` ("Advanced subtitle
- support").
-- In fullscreen, `app-player-controls` shows a pointer-transparent media-title
- overlay at the top while controls are revealed (`mediaTitle` input:
- movie/channel/series name, plus an `S01E03` second line for episodes). Series
- names flow from the Xtream/Stalker detail views through
- `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;
- movie and live hosts fall back to `playback.title`, skipping raw stream-URL
- fallbacks. Outside fullscreen the overlay stays hidden.
-- Auto-hide pauses while the pointer is over the controls bar or keyboard
- focus is inside it, but only keyboard-originated focus pins the bar open.
- Chromium also focuses a clicked ``, so
- `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent
- `pointerdown` inside the focused element; such focus reveals without
- blocking auto-hide (otherwise the fullscreen button left the controls on
- screen until a click-to-pause on the viewport). The press record is
- discarded on the first bar focus event it is asked about or on any
- `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a
- `keydown` bubbling out of a bar control re-pins it, since operating a
- focused control produces no focus event. A completed pointer click then
- releases the focus it left on the control (`onBarClick` →
- `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`:
- non-empty click `pointerType`, else a recent press inside the clicked
- element), because a focused control captures the keyboard: Space and
- Enter re-activated the clicked button and `ControlsShortcuts` yields to
- any interactive element in the key's path, so after a click on fullscreen
- Space left fullscreen instead of pausing. Keyboard activation (empty
- `pointerType`) keeps focus, only buttons and range sliders are released,
- Chromium keeps its sequential-focus starting point at the blurred control
- so Tab continues from it, and the volume popover ignores the release's
- `focusout` (`wasPointerFocusRelease`). Contract:
- `docs/architecture/player-controls-contract.md` (auto-hide paragraph).
-- Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored
- values coerce with `!== false`; only an explicit false opts out to the legacy
- vendor chrome), and its checkbox appears only when HTML5, Video.js, or
- ArtPlayer is selected.
- `WebPlayerViewComponent` snapshots the preference into
- `WEB_PLAYER_SHARED_CONTROLS` for each new player host. The parent `/workspace`
- route awaits the initial `SettingsStore` load, including cold-start direct
- links, before this snapshot can occur. Saving applies to the next host without
- an application restart; an existing session never changes controls mode in
- place.
-- `Settings.showCaptions` is deliberately outside this rollout gate: it is
- engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both
- modes — shared controls through their controls bridge, the preference-off
- paths through the same helpers without an adapter (`WebVideoSourceTracks` for
- HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference
- as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it
- from `SettingsStore` rather than a host input, so the M3U player, the
- Xtream/Stalker live layouts, and the portal detail inline player all inherit
- it (#1155).
-- The modes differ in how long the preference is enforced. Shared controls are
- authoritative for the session; user intent arrives through `setSubtitleTrack`
- and wins until the source changes. Vendor chrome is source-default: the
- preference seeds each new source and is released once the media element
- reports `playing`, so the engine's own caption menu keeps working. The mode is
- selected by the optional `playbackStarted` probe the legacy owners pass to all
- three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper
- deselects the track (`subtitleTrack = -1`) instead of hiding it, because
- `subtitleDisplay` would silently override whatever the vendor menu picks. For
- DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,
- so the helper only stops re-suppressing afterwards.
-- Shared controls include a per-session quality menu (Auto + "1080p"-style
- levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR). The
- capability derives from the manifest — advertised only when the source
- exposes >1 video rendition (multi-variant HLS via hls.js
- `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the
- active variant's exact audio stream (`audioId`, language fallback) with ABR
- toggled off for manual picks, Video.js via videojs-contrib-quality-levels) —
- so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to
- Settings, and Embedded MPV/external players report the capability false.
-- Embedded MPV ignores the web-player preference. Frame-copy always uses shared
- DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while
- native-view retains the legacy compositor-safe dock and external MPV/VLC
- retain their own UI. The host must render exactly one controls system for the
- reported Embedded MPV engine.
-- Frame-copy shared controls own DOM surface interactions, shortcuts,
- fullscreen, and recording feedback. `showControls=false` detaches the shared
- surface, modal overlays gate playback shortcuts, fullscreen still triggers
- bounds sync, and a playback/session transition key prevents engine or session
- handoff from presenting stale recording feedback while timers and pending
- commands are cancelled. Same-session IPC replies also yield to a broadcast
- snapshot received while the command was pending, preventing a successful
- recording acknowledgement from being rolled back by a stale reply.
-- `WebPlayerViewComponent` renders `app-fullscreen-channel-panel`
- (`libs/ui/playback/src/lib/fullscreen-channel-panel/`) beside the engine,
- staged on the view's `fullscreenSurface` — the same host element every engine
- receives as `fullscreenTarget` — so it lives inside the fullscreen element
- and survives the engine remount a channel switch causes. It is withheld
- (`enabled=false`) for native-view Embedded MPV, which paints above the DOM.
- A confirmed frame-copy capability survives the unknown support probe during
- an engine remount, preserving panel search/scroll on channel changes; the
- first unknown probe and confirmed native/unsupported results withhold it.
- A host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional
- `panelTitle`, `panelSearchEnabled` and `panelKind: 'channels' | 'episodes'`;
- the template context carries `searchTerm`, `open` and `close`) and the
- panel slides that list over the video: left-edge hover
- dwell, a click or tap on that edge, or `C`. The hot zone stays mounted above
- the scrim and below the panel during opening, so a delayed paint cannot turn
- stationary hover into a synthetic leave. Nothing is drawn while it is closed
- and the pointer rests — mouse movement over the stage reveals a slim edge
- hint tab that fades after 2.5 s idle — the hot zone stops above the controls
- bar, and scrim/Escape/mouse-leave close it, mouse-leave after 1 s and only
- once the pointer has been inside the panel (a `C`-opened panel survives the
- mouse roaming over the video) — while a CDK overlay opened from the list
- counts as the panel, so hover keeps it open and Escape closes the overlay
- first. The
- header is one row (search whose placeholder carries the host title, plus
- close) and the list stays mounted per fullscreen session.
- `Settings.fullscreenChannelPanel` (default on) gates it, offered only for the
- web players with shared controls and for Embedded MPV — the legacy vendor
- chrome fullscreens the engine's own element, outside which the panel cannot
- render. Providers: M3U `VideoPlayerComponent` (returns null while its VOD
- detail hosts the player; radio and recognized movies are filtered out of
- the list it is handed, since `app-audio-player` and the VOD detail shell
- each replace the fullscreen-owning `app-web-player-view`; with MPV/VLC
- configured, only DASH rows stay offered because other streams leave the
- inline host for the external-player UI), Xtream
- `LiveStreamLayoutComponent`,
- `StalkerLiveStreamLayoutComponent` (one `ng-template` stamped twice; a blank
- panel field shows the category untouched by the sidebar's search term (or
- the windowed full cache when playback starts from All Items without a
- category), the
- panel's search results are windowed by `PanelSearchWindow`, and on a paged
- portal the panel copy keeps requesting pages while its matches do not fill
- it, even while the sidebar's own search is active; the retained closed
- panel pauses paging and resumes automatic filling when reopened; inline video
- commits the selected channel with the resolved playback, retaining the old
- selection, EPG and recording metadata during a pending or failed replacement), and
- `UnifiedLiveTabComponent` (radio filtered the same way; it keeps the previous
- detail mounted until the next selection resolves, with `activeItem` paired
- to that detail so the session key and recording metadata keep describing
- the stream on screen — only the `activeUid` row highlight moves ahead; a
- second activation of the row still resolving folds its start-playback or
- auto-open intent into that request instead of launching the retained
- stream, and a
- failed replacement restores that highlight and retains the previous video,
- catch-up and session). M3U PageUp/PageDown yield to already-handled events
- and menu/dialog overlay targets even when the menu has no scroll overflow.
- Numeric and adjacent-channel commands share the panel eligibility filter
- while the live web-player host owns fullscreen (itself, or through the
- nested surface a legacy player fullscreens under the vendor-chrome
- opt-out); numbers keep their original positions and ineligible numbers are
- ignored. Windowed commands keep the
- complete catalog.
- Xtream's two `PortalChannelsListComponent` instances relay favorite toggles
- through `XtreamFavoriteMarksService`. Series playback gets the same panel
- as an episode list: `PortalInlinePlayerComponent` (the component both
- series hosts render around the view, and the Up Next rail's host) is the
- nearest provider — `panelKind: 'episodes'`, no search field (a title row
- instead, `C` focuses the panel) — and stamps `app-fullscreen-episode-panel`
- (`libs/ui/playback/src/lib/fullscreen-episode-panel/`: `SeasonTabsComponent`
- over the selected season's rows with TMDB still or numeral tile, `S01E03`
- label, runtime, clamped overview, progress bar, watched check and
- now-playing marker; the tab follows the playing season, the playing row is
- centred on open) built by `buildFullscreenEpisodePanelSeasons` from the
- hosts' `seriesEpisodes` / `episodePlaybackPositions` / `seasonLoadStates`
- inputs. An episode click travels `upNextEpisodeSelected` (the rail's path,
- so fullscreen survives the engine remount) and closes the panel; a season
- tab click travels `episodePanelSeasonSelected` into the hosts'
- `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD season
- load — a spinner row while in flight, a Retry row after a failed request).
- Movies never get it, external
- MPV/VLC never mount the inline player, native-view Embedded MPV is
- withheld by the view. CDK overlays follow the
- fullscreen element via `FullscreenOverlayContainer`. Contract:
- `docs/architecture/player-controls-contract.md` ("Fullscreen channel panel",
- "Fullscreen episode panel").
-- Embedded MPV seek steps (arrow keys, ±10 s buttons, `PlayerController.seekBy`)
- go through the relative `seekEmbeddedMpvBy` IPC: every backend forwards the
- delta as mpv `seek relative+exact` (addon export `seekBy`, helper
- stdin command `seek-by`, Linux JSON IPC) and never advances the snapshot
- position itself. Do not derive an absolute target from the renderer's
- `positionSeconds`: it is floored to whole seconds, polled every 500 ms, and
- a seek reply does not carry the new position, so rapid presses computed from
- it collapse onto one target. Only the timeline scrub commits an absolute
- `seek`. Contract: `docs/architecture/embedded-mpv-native.md` ("Resume And
- Track Handling").
-- M3U Favorites and Recently Viewed resolve `Channel.drm` into
- `ResolvedPortalPlayback.drm` through `StreamResolverService`, with the same
- legacy raw KODIPROP fallback as the main M3U player. Both playlist and global
- collection scopes retain ClearKey playback and unsupported-DRM diagnostics.
- Collections also route M3U DASH inline through HTML5/Shaka (or ArtPlayer),
- regardless of the configured player, without changing the saved preference.
-- DASH (`.mpd`) sources play through a lazily imported Shaka Player source
- engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and
- ArtPlayer components; ClearKey keys come from KODIPROP-derived
- `Channel.drm` (hex, Base64URL or ordinary Base64, strictly 128-bit key/KID;
- refresh replaces cached unsupported parser results), and the shared bridge
- exposes Shaka audio/text tracks via
- source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in
- `libs/playback/util`; it version-locks public severity/category/code evidence,
- ignores recoverable error events,
- treats rejected loads as terminal lifecycle outcomes, preserves exact public
- DASH text-parser category/code evidence with unknown stage/failure, and never
- retains or renders raw messages or `error.data`. A failed browser-support
- preflight stays generic-unknown but carries the exact app-owned
- `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed
- external fallback only for clear transferable DASH; PWA capability and
- KODIPROP DRM still suppress it. See the CLAUDE.md "Video Players" feature
- entry and the "DASH + ClearKey Playback" section of
- `docs/architecture/m3u-playlist-module.md`.
-- mpegts.js `1.8.1` errors from HTML5, Video.js, and ArtPlayer cross one
- version-locked structured evidence boundary in `libs/playback/util`. Only
- exact public type/detail pairs, pair-derived stage/failure, terminal
- disposition, and the validated HTTP 4xx/5xx status slot are retained; raw
- messages and arbitrary `info`
- never reach diagnostics. This is a sibling of `PlayerController`, not part
- of the controls contract.
-- Browser playback diagnostics and recovery policy live in
- `libs/playback/util` and are exported by `@iptvnator/playback/util`.
- Public engine errors cross allowlisted sanitizers into a
- `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at
- most three actions, and `WebPlayerViewComponent` executes only the action
- the user selects. The policy is a sibling of `PlayerController`; shared
- controls only gate interaction while the diagnostic panel is visible.
- Technical details also expose localized stages, safe engine codec metadata
- and allowlisted source DRM names. DASH observes existing Shaka manifest
- responses (bounded to 2 MiB); it does not fetch again or retain license URLs,
- keys or XML. Evidence is scoped to one engine and never proves playability.
- The panel refines descriptions only from explicit runtime/engine evidence;
- HTTP 401/403 segment failures never imply token expiry. Copy diagnostics
- creates an allowlisted local report without URLs, credentials or raw messages;
- its content and copy status follow the current diagnostic.
- `WebPlayerViewComponent` owns a host-derived content-session key that is
- stable for the mounted logical selection, attempted target IDs, the temporary
- player override, and VOD handoff position. Its `PlaybackBinding` is exactly
- `{ generation, target }`, while every source/target/reload application uses a
- fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless
- intent `Symbol`, and source applications advance a third fieldless revision
- `Symbol` that clears only the VOD handoff position; target-only switches and
- Retry leave that revision stable. None of these ownership primitives contains
- URLs, headers, DRM material, or credentials. The application effect
- synchronizes the content session before tracking intent, so clearing a
- temporary player override cannot schedule a duplicate application or header
- handoff. Every application start clears both the diagnostic owner and backing
- signal before asynchronous header setup; a current false result or rejection
- leaves them clear, and a stale completion cannot erase a newer owned
- diagnostic. Each
- rendered web or Embedded MPV application captures its nullable binding, the
- application and source-revision tokens, and live/VOD flag; a time update
- changes resume state only while that exact capture still owns the current
- application. A recommended built-in
- player temporarily
- outranks the host override and saved player for that mounted content session,
- never mutates `Settings.player`, and resumes finite VOD position on a
- best-effort basis; live playback returns to the live edge. Retry and
- alternative sources preserve attempts, while a different content-session key
- or component teardown resets them. Recovery recommendations never
- auto-switch, persist history, learn across sessions, or emit telemetry.
- The policy projects attempted inline target IDs through the validated
- canonical source/target capabilities and excludes every attempted engine
- family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network
- and generic unknown evidence fail closed to Retry/alternative source; the
- exact Shaka browser-unsupported preflight marker is the sole unknown-code
- exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
- DRM suppresses external targets because its payload is not transferable. Raw
- engine messages, arbitrary data, and credentials never enter recommendation
- evidence or ownership state. MPV/VLC actions remain mounted after an attempt
- and expose credential-free per-target launching/started/playing/error state;
- only an exact Electron `playing` update is labelled Playing. One handshake is
- allowed at a time. The renderer claims the credential-free content identity
- before awaiting Electron, so primary Play is disabled and a launching or
- closable-error alternative remains owned before the controller commits it.
- Every route action that can start the same external playback, including
- Restart and the provider-source shortcut, observes that local pre-IPC guard.
- The Xtream VOD diagnostic-fallback handler records the same route-scoped
- destination and pending generation before invoking MPV/VLC, so route reuse
- cannot orphan that process outside the next route's close-before-play path.
- Its fieldless intent is bound to the exact session returned
- by the source owner's launch promise, so a late timed-out attempt cannot take
- over a retry; later global updates must match that ID. A replacement waits for
- confirmed teardown of the tracked external process, applies the old exact
- close before launch, and cancels an unlaunched handoff if diagnostic ownership
- changes. Process teardown has bounded graceful and forced confirmation
- windows, and reusable MPV bounds the IPC command that precedes them; if any
- stage cannot reach a confirmed exit, the exact session stays live and the
- replacement fails closed instead of overlapping it. A process-wide teardown
- gate starts before any potentially slow teardown preparation, including VLC
- position flush and a reused player's protocol quit, and rejects every
- MPV/VLC spawn until that exact child reports exit. If bounded
- teardown fails while a fresh launch is still pending, that launch IPC rejects
- and the exact session remains a closable error instead of hanging forever.
- If a pre-content reuse failure has no still-live displaced session to restore,
- the replacement error keeps its attached closer so Stop can retry the orphaned
- child teardown. A terminal error without a closer is never restorable.
- A failed close is single-flight only while its promise is pending: Stop can
- retry the same exact child after a bounded confirmation failure. Reuse maps
- the child to its current content session, so a stale older closer becomes a
- no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.
- A duplicate close for an already closed session returns its terminal snapshot
- without re-entering the saved closer, and a late process error cannot revive
- that terminal session. Reused MPV commands are bound to the socket captured
- for that exact child, so a later process cannot inherit a stale protocol quit.
- Stop observed before a pending MPV content command or VLC enqueue command
- prevents that command from dispatching. A source handoff fails closed while
- a live session has no closer (`canClose: false`); renderer Dismiss is not
- teardown confirmation. That denied handoff advances neither the multi-source
- switch token nor the playback generation, so it cannot cancel the sole launch
- already in flight.
- VLC rechecks the gate at each concrete spawn after port allocation or reuse
- work; if a post-start fallback is blocked there, the opened session becomes
- an error rather than retaining a false started status. A failed RC-port
- allocation never claims reuse ownership, so the fallback VLC child retains
- its exact one-shot closer.
- Reuse failures before a content command restore the globally displaced
- renderer session, not the reusable process's prior owner, and only while the
- exact displaced-session ID is still active; after
- `loadfile`/VLC `clear` is dispatched,
- the replacement owns the process and remains a closable error instead of
- restoring stale content metadata. Stop during an in-flight MPV or VLC reuse
- command, including during failed-command teardown or the subsequent VLC
- fallback port-allocation wait, settles that exact close without falling
- through to a fresh spawn;
- a stopped VLC spawn error that reports only `close` also settles its original
- launch IPC with the exact closed session;
- a fresh fallback retires the old child's exit under its prior session so it
- cannot close the replacement. Source handoffs recheck ownership after launch
- and accept only `opened`/`playing`; a stale returned session is closed exactly
- and a Stop-returned `closed` session is never committed. If that exact stale
- close fails, its credential-free destination owner is retained for the next
- close attempt. Retained destination ownership is scoped to the initiating
- playlist/VOD route key, so route reuse cannot expose Stop for the previous
- movie's external session. Play/Resume capture that route key before awaiting
- close and cancel if navigation changes it; a late diagnostic fallback closes
- its exact returned session instead of adopting it on the new route. They
- supersede an older source resolution before awaiting the shared
- close-before-replacement path, and accepting a diagnostic fallback retires
- the same older resolution before opening MPV/VLC. They publish the route-source
- badge, caption evidence, and position only after start succeeds.
- Closable errors still participate in every replacement close and keep Stop as
- the global dock's only teardown affordance; Dismiss is reserved for terminal
- errors that have no closer. The shared `isLiveExternalPlayerSession` predicate
- keeps M3U and series ownership while
- such an error can still be stopped; consumers must not treat every `error`
- status as terminal.
- If the local handshake times out after an exact Electron session is known,
- that ID remains
- correlated so a later exact update can recover the UI. The global dock mirrors
- those statuses, keeps closable errors visible until Stop confirms teardown and
- terminal errors visible until dismissal, and intentionally has no retry because
- it does not own the original launch headers or credentials.
-- The built-in HTML5/hls.js player is the second guarded consumer.
- `HtmlVideoPlayerComponent` provides a component-scoped
- `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared
- with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,
- caption preference, and source cleanup.
- `HtmlVideoElementSession` owns native video-event lifecycle, persisted
- volume, start-time/time/ended propagation, and legacy post-play caption
- suppression.
- `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD
- metadata, while a visible playback diagnostic disables both shared surface
- interaction and shortcuts and exits the shared controls' resolved fullscreen
- owner (the host-supplied `fullscreenTarget`, else the HTML5 shell) so the
- diagnostic actions remain visible. The preference-off path keeps native
- controls and legacy series navigation unchanged, while the playback keyboard
- shortcuts (Space/K, F, arrow seek/volume, M) attach through
- `LegacyPlayerShortcuts` with commands acting on the native video element
- (`html-video-legacy-shortcuts.ts`); seek requires authoritative VOD metadata
- plus a finite positive duration, and a visible diagnostic disables the keys.
-- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a
- component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech
- video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,
- preserves caption preference and explicit subtitle-off state, and reads
- duration from Video.js. Reset-driven raw MPEG-TS changes pause first,
- coalesce to the latest desired source, preserve actual volume across
- Video.js's reset, and restart when authoritative live/VOD metadata changes.
- The shared-controls path disables native controls, Video.js
- click/double-click/hotkey actions, and spatial navigation;
- diagnostic gating and owned-fullscreen exit match HTML5. The preference-off
- path keeps the existing Video.js skin and legacy series navigation unchanged
- (still without `userActions.hotkeys`), while the playback keyboard shortcuts
- attach through `LegacyPlayerShortcuts` and drive the player API so the
- vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`). That chrome
- also releases the focus a pointer interaction leaves on a control
- (`vjs-pointer-focus-release.ts`, sharing `pointer-focus-release.ts`'s
- `blurFocusedControl` with `ControlsSurface`): a focused Video.js component
- stops every key before the document and turns Space/Enter into a click, so
- after a click on fullscreen Space left fullscreen instead of pausing. It is
- driven mainly by `focusin`, not the click, because choosing a menu item
- moves focus to the menu button a tick later and that click never bubbles to
- the shell: an eligible control (button/`role=button`/slider, never a menu
- item) is released when its focus is attributable to a recent shell
- `pointerdown` not yet ended by a document `keydown`, so `Tab` focus is kept.
- A `click` runs the same release for a control clicked while already focused
- (Tab, then a mouse click), which fires no `focusin`. The release is scoped
- to `.vjs-control-bar`, so the caption-settings dialog (a modal sibling of
- the bar) keeps its focus trap. Menu buttons live in the bar and are not
- exempt: a popup is navigated through its focused item, so releasing the
- button never disturbs an open menu, and the button focus a pointer moves
- through (open, item selection, toggling an open menu shut) is released so
- Space works again after the menu closes. ArtPlayer
- (non-focusable divs) and the native HTML5 controls (focus lands on the
- ``) need no counterpart.
-- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a
- component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns
- HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and
- a destroyed-session guard for delayed `customType` callbacks, while
- `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses
- authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,
- MPEG-TS VOD duration correction, and reapplies app volume directly after
- ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,
- and a transparent capture layer gives shared controls exclusive click and
- double-click ownership. Diagnostic interaction gating and owned-fullscreen
- exit match the other web players. The preference-off path keeps the legacy
- ArtPlayer skin, source behavior, and series navigation unchanged, while the
- playback keyboard shortcuts attach through `LegacyPlayerShortcuts` using the
- vendor setters ArtPlayer's own hotkeys used
- (`art-player-legacy-shortcuts.ts`); the legacy chrome passes `hotkey: false`
- because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would
- double-handle every key, and the wiring restores its Escape-exits-web-
- fullscreen behavior.
-- Shared web picture-in-picture stays inside that default-on rollout.
- `PlayerController` exposes capability `pictureInPicture`, state
- `pictureInPictureActive`/`canPictureInPicture`, and command
- `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
- element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
- `pip: false`, while preference-off native/vendor controls keep their own UI. The
- capability-gated button sits before fullscreen and uses active enter/exit
- semantics; entry is disabled until metadata, and the action is disabled while
- an operation is pending. Embedded MPV reports capability/state false with a
- no-op command and has no popup/mini-window.
-- `WebVideoControlsAdapter` supplies its current video and binding generation to
- `WebVideoPictureInPictureController`; the controller reads the video's
- `ownerDocument`, while browser enter/leave events remain authoritative.
- Exact-owner exit stays available if request support changes. Request/exit
- invocation remains synchronous for user activation, one operation is
- serialized, and binding generation plus exact video identity protects
- replacement and teardown from stale completion. Video.js Tech reset and
- ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
- retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
- Tech replacement also release exact-owned PiP through
- `web-video-picture-in-picture-lifecycle.ts`, independent of the controls
- preference. A one-shot listener on the retired video closes late native/vendor
- entries without retaining the host or touching another video's PiP. Legacy
- WebKit presentation-mode PiP also returns the retired video to inline; its
- presentation-change listener ignores fullscreen/inline events until a late
- PiP entry consumes it.
- Standard PiP shows the browser/OS video surface without Angular control
- chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
- keyboard shortcut, and Embedded MPV popup/native support are out of scope.
-- Canonical docs: `docs/architecture/player-controls-contract.md` and
- `docs/architecture/embedded-mpv-native.md`
-
-## Display Sleep During Playback
-
-- `PlaybackKeepAwakeService`
- (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every
- `` via document-level capture listeners (media events don't bubble;
- release listeners sit on the tracked element because Chromium's
- removed-from-DOM pause never reaches the document) and, while any video is
- playing and the document is visible (or the playing video is in
- picture-in-picture — the PiP surface survives a minimized window), holds a
- display-sleep lock.
-- Electron: a main-process `powerSaveBlocker` behind
- `window.electron.setPlaybackKeepAwake`
- (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`);
- the renderer's vote is auto-cleared on renderer reload, crash
- (`render-process-gone`), or destruction. PWA: the Screen Wake Lock API,
- re-requested after browser auto-release; state changes masked by an
- in-flight `request()` queue one re-evaluation on rejection.
-- Radio's `` deliberately never blocks display sleep. Embedded MPV
- holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
- inhibit the screensaver themselves.
-
-## Windows Embedded MPV Pin Maintenance
-
-- PR, master, and tag builds resolve the Windows runtime only from
- `tools/embedded-mpv/windows-runtime-pin.json`; repository variables are not
- build inputs.
-- Validate the checked-in schema and provenance with
- `pnpm embedded-mpv:windows-runtime-pin:check`.
-- Prepare a manual rotation with
- `pnpm embedded-mpv:windows-runtime-pin:refresh -- --force`. The weekly
- `refresh-windows-embedded-mpv-runtime.yaml` workflow runs the same updater
- and opens a reviewable PR before upstream retention expires.
-- The PAT-backed refresh job must keep every third-party action pinned to a
- full commit. Do not mirror the upstream binary without complete
- corresponding source, build records, license notices, and a validated
- transitive license closure.
-
-## Linux Embedded MPV Packaging
-
-- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,
- Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain
- marker-only and must never inherit x64 native artifacts from environment
- overrides.
-- Packaging runs three isolated profiles:
- - `system`: DEB/RPM/Pacman, no private `native/lib`, with package
- dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,
- RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
- Pacman=`mpv,libglvnd,mesa`
- - `portable`: AppImage/Snap with the pinned LGPL-compatible closure
- - `flatpak`: Flatpak with the same pinned closure
-- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real
- Electron ELF so Electron Builder's `electron-wrapper` passes it directly to
- Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and
- `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.
-- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04
- provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the
- package dependency or advertising frame-copy without a compatible runtime.
-- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,
- Electron libraries, `embedded_mpv.node`, and
- `embedded_mpv_frame_reader.node` must not load or link it. Preserve this
- process-isolation contract in build, package, and smoke checks.
-- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`
- exclusively writes the profile-normalized unpacked native tree. Layout and
- final-artifact checks must reject every archived
- `/electron-backend/native/**` entry so system and marker-only packages cannot
- hide stale x64 artifacts.
-- Packaged addon, frame-reader, and helper discovery is package-owned
- `app.asar.unpacked` only. Writable cwd/dist candidates are development-only
- and must never satisfy packaged native-view support or the frame-copy gate.
-- Pristine afterPack/unpacked layouts scan Electron libraries recursively.
- Extracted Snap payloads exclude only the package-manager `lib/**` and
- `usr/lib/**` trees that Snap overlays into the same root; every other
- directory remains recursive, and Electron-library symlinks still fail
- closed.
-- Linux frame-copy availability is fail-closed. The packaged manifest,
- artifact modes, declared bundled hashes/closure, and bounded
- `--runtime-probe` must all succeed before frame-copy can relax the renderer
- sandbox. Any failure reports a stable reason and falls back to native-view
- without crashing; an environment flag never bypasses this gate.
-- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus
- the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,
- with `mesa-core22` as default provider. It declares only the canonical
- provider layouts: `/usr/share/libdrm` binds from
- `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to
- `$SNAP/graphics/drirc.d`. The provider is external shared content, not part
- of IPTVnator's package size, source archive, or notices. Installed-Snap CI
- must prove controlled unavailable exit after disconnect, then reconnect and
- prove success. Static artifact verification requires regular
- `desktop-init.sh`, `desktop-common.sh`, and `desktop-gnome-specific.sh`
- files at the Snap root, with `desktop-init.sh` executable. The helper links
- `libGL.so.1` rather than `libOpenGL.so.0`.
-- The probe and playback helper share one sanitized loader environment:
- ambient audit, preload, library, graphics-driver, and shell-startup overrides
- are removed; the validated private closure wins; trusted Snap GL,
- `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots
- precede generic in-snap roots. The core22 base must precede GNOME so its
- `libedit.so.2` cannot be replaced by the older copy requiring
- `libtinfo.so.5`. The extracted-artifact verifier removes the identical
- unsafe loader/graphics/shell set before direct helper smoke while preserving
- feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the
- wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches
- probe/playback through the regular executable
- `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
- disconnected provider returns `snap-graphics-provider-unavailable` before
- helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch
- runs the complete cached manifest/hash/helper gate before BrowserWindow
- startup and exits with one availability JSON line. A nonzero helper exit
- keeps top-level reason `helper-probe-failed`; `helperReason` is present only
- for an exact protocol-v1 line carrying a fixed allowlisted reason, and its
- optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid
- detail suppresses both helper fields. Every probe uses an explicit 16 MiB
- aggregate captured-output ceiling independent of tracing. With
- `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted
- separately as one JSON-escaped stderr line whose `stderr` field is limited
- to 16,384 characters and whose `truncated` field is always explicit;
- trace-write failure cannot change the capability result. Installed-Snap CI
- enables Mesa EGL/GL diagnostics through this bounded channel. Any loader
- failure remains a stable native-view fallback, never a flag-enabled success.
-- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop
- Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL
- extension loader path comes from the sandbox cache. Flatpak CI must invoke
- the application-level `--embedded-mpv-runtime-probe`, not a direct helper
- probe that bypasses capability detection.
-- The packaged x64 Playwright smoke runs its fixture-contract target first and
- passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.
- This launch-only flag does not bypass the manifest, hash, loader, or helper
- capability gate; `--no-sandbox` remains root-only.
-- Bundled Linux releases must publish the exact source archives/git records,
- checksums, licenses, flags, patches, build scripts, and the pinned hwdata
- `pnp.ids` input. Each bundled package carries
- `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact
- `licenses/**` files. CI may cache immutable source inputs, but regenerates
- notices and a VCS-metadata-free
- `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every
- run while retaining the exact pinned six recursive libplacebo submodule
- records. Each record is canonical `full-commit safe/path`; clone-depth
- dependent `git describe` annotations are discarded and never form part of
- the provenance identity. Its source index carries the globally sorted libplacebo
- directory/file/symlink inventory; file hashes, sizes, executable bits, link
- targets, aggregates, and canonical tree digest must match the trusted pinned
- checkout. The archive has an exact member/type layout and its
- `metadata/archive-sha256.txt` records must match the actual source archives.
- Concatenated tar/xz streams are inspected past every end marker. The final
- archive's SHA-256 and repository revision are copied into every bundled x64
- package manifest; system and marker-only packages carry no source-archive
- binding.
- Automated Snap Store publication is allowed only after a public `v*` GitHub
- release contains both the Snap assets and exactly one matching source
- archive. Before any upload, the workflow hashes and inspects that archive,
- verifies its exact member/type set and size bounds, clean tag revision,
- pinned sources including the six recursive submodule records and exact
- libplacebo tree digest, legal files, and exact released tooling, then
- performs bounded extraction and static package validation for every Snap.
- That public-release boundary independently revalidates the exact strict
- `meta/snap.yaml` graphics/shared-memory contract and enumerates
- `resources/app.asar`, rejecting any archived
- `electron-backend/native/**` payload before publication. Its bounded ASAR
- header reader uses only Node built-ins and released local tooling, so the
- clean tag checkout does not require `node_modules`.
- Exactly one x64 Snap must have matching
- `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain
- marker-only. Checkout and the artifact-transfer actions are pinned to full
- commits; checkout does not persist credentials, and repository credentials
- are limited to download steps. A secretless verification job copies assets
- through no-follow descriptors, checks pre/post hashes, writes an exact
- receipt, repeats the complete source/package verification on a root-owned
- read-only snapshot, and transfers only that data through the pinned artifact
- service while its receipt digest travels separately through a job output.
- The dependent publish job runs on a bounded `ubuntu-latest` runner with no
- checkout or release-tag code, verifies that digest plus the exact receipt,
- asset hashes, and file-only layout, root-seals the data again, and installs
- Snapcraft directly. Store credentials exist only in its final fixed shell
- step, which resolves no PATH command, executes no released code, and exposes
- the credential only to each exact
- `/snap/bin/snapcraft upload --release=edge` process.
- Candidate/stable promotion is manual after installed-Snap frame-copy and
- missing-runtime fallback smoke; GitHub Actions never promotes automatically.
- Canonical maintenance docs:
- `docs/architecture/embedded-mpv-native.md` and
- `tools/embedded-mpv/README.md`.
-
-## Repo Skills
-
-- `.codex/skills/iptvnator-nx-architecture/SKILL.md`
-- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`
-- `.codex/skills/iptvnator-theme-style/SKILL.md`
-- `.codex/skills/iptvnator-ui-design/SKILL.md`
-- `.codex/skills/release-cut/SKILL.md`
-- `.codex/skills/release-notes/SKILL.md`
-- `.codex/skills/stalker-portal/SKILL.md`
-- `.codex/skills/xtream-electron/SKILL.md`
-
-Descriptions and trigger conditions are canonical in each skill's frontmatter;
-do not duplicate them here.
+# Repository guidance
+
+IPTVnator is an Angular/Electron IPTV player with a browser/PWA runtime.
+These are the common instructions for all coding agents. Read the relevant
+contracts below before changing a subsystem; do not load every document.
+
+## Bootstrap and commands
+
+- Use the Node version in `.nvmrc` and the repository's pnpm version.
+- In a fresh worktree, run `pnpm install --frozen-lockfile` before Nx discovery,
+ tests, lint or builds. Each worktree needs its own install.
+- Repeat the install after checkout changes, pulls, resets or rebases. Compare
+ `pnpm-lock.yaml` with `node_modules/.pnpm/lock.yaml`; a difference means stale
+ dependencies. Do not diagnose stale modules as application failures.
+- Verify discovery with `pnpm nx show projects`. Inspect the owning project's
+ targets before choosing checks. Prefer the smallest relevant Nx target.
+- Development: `pnpm run serve:frontend` or `pnpm run serve:backend`.
+- Tests: `pnpm nx test `; lint: `pnpm nx lint `.
+- Find checks and E2E commands in the [validation map](docs/architecture/validation-map.md).
+- Packaging, native dependencies and runtime patches have additional contracts
+ in the context map; read them before dependency or release changes.
+
+## Implementation invariants
+
+- Use scoped aliases from `tsconfig.base.json`, such as `@iptvnator/services`.
+ Do not introduce legacy bare aliases or bypass public project boundaries.
+- Nx projects keep `scope:*`, `domain:*` and `type:*` tags. Shared cross-project
+ files must belong to a project. SCSS imports need explicit hash dependencies.
+- Target under 300 production TypeScript lines; the hard limit is 400 (1200
+ for tests), excluding comments/blanks. Never add entries to the legacy baseline.
+- Preserve existing persisted data. Users may skip releases: migrations must
+ apply in dependency order, preserve data and be safe on repeated startup.
+ Test actual historical SQLite schemas, not only SQL mocks.
+- Choose runtime behavior through the relevant capability contract; a generic
+ `window.electron` check does not prove that a particular bridge is available.
+- Use shared redacting logging before emitting settings, portal or trace data.
+ Never log credentials or expanded SQL/bound values.
+- Keep UI consistent with the shared guidelines. Read the repository UI/theme
+ skills before changing user-visible Angular views or shared styles.
+
+## Validation and completion
+
+- Before finishing, assess affected projects and test impact. Bug fixes normally
+ include regression coverage that fails before the fix and passes afterwards.
+- Update stale tests, fixtures and E2E flows when behavior changes. Run targeted
+ unit checks and affected E2E for routing, persistence, playback and user flows.
+- Electron-only IPC, database, packaging, players and filesystem changes need
+ Electron E2E where available, otherwise CDP/manual validation with a reason.
+- Report checks and results, any skipped checks with reasons, documentation
+ changes, and whether a release note was added or why it was unnecessary.
+- Every user-visible change needs a note under `.changes/`; follow the
+ [release-note format](.changes/README.md) and the repository release-notes skill.
+ Docs, tests, CI and behavior-preserving refactors do not need a note. Apply
+ `no-release-note` on exempt PRs touching runtime code.
+- Validate notes with `pnpm run release:notes:validate`. Release publication has
+ separate ordered gates; follow the release-cut skill and release contract.
+
+## Keep guidance small and canonical
+
+- Update the affected subsystem's canonical document after meaningful changes.
+ Prefer an existing authoritative doc; keep user/developer entry points in
+ README and detailed contracts in architecture docs or a module README.
+- Add to this file only repository-wide rules and navigation. Implementation
+ details, incident history and multi-step procedures belong in linked docs.
+ Do not duplicate subsystem contracts here or in CLAUDE.md.
+- `AGENTS.md` is the single source of common rules. `CLAUDE.md` imports it and
+ contains only Claude-specific guidance. Limits: 200 lines / 16 KiB here,
+ 30 lines / 2 KiB for CLAUDE.md. Do not raise loading limits to fit more prose.
+- Read the [context map](docs/maintenance/agent-context-map.md) when ownership
+ is unclear. Read each affected domain for cross-domain tasks, not the whole map's documents.
+- Preserve exceptions and rationale when moving knowledge. Correct stale facts
+ against code; do not silently discard a contract. Maintenance details are in
+ the [agent workflow](docs/development/agent-workflow.md).
+- Never run whole-file `prettier --write` on AGENTS.md, CLAUDE.md or `docs/**`.
+ Edit only intended lines; upstream Markdown is not uniformly Prettier-clean.
+- After changing guidance, run `pnpm run agents:validate`; after editing a
+ repository skill or a literal path it documents, run `pnpm run skills:validate`.
+ Keep release-cut and release-notes copies byte-identical for Codex and Claude.
+- Save finalized plans only in `.plans/YYYY-MM-DD-short-topic.md`; if a filename
+ exists, append `-2`, `-3`, etc. Respect active mode restrictions on file writes;
+ if writing is forbidden, save the approved plan when execution starts.
+
+## Read by task
+
+| Task | Required starting point |
+| --- | --- |
+| Project layout, imports, dependencies, lint configuration | [Nx boundaries](docs/architecture/nx-workspace-boundaries.md) |
+| Angular conventions, docs and skills maintenance | [Agent workflow](docs/development/agent-workflow.md) |
+| Electron debugging, CDP, trace flags | [Electron debugging](docs/development/electron-debugging.md) |
+| SQLite, worker IPC, persistence migrations | [DB worker](docs/architecture/sqlite-db-worker.md), [database migrations](libs/shared/database/README.md) |
+| M3U, XMLTV, startup, source health, OS playlist opening | [M3U contracts](docs/architecture/m3u-playlist-module.md), [adding sources across layers](docs/development/agent-workflow.md#adding-behavior-across-layers) |
+| Xtream / Stalker | [Xtream compatibility](docs/architecture/xtream-portal-compatibility.md), [Stalker contracts](docs/architecture/stalker-portal.md) (affected provider only) |
+| Player controls, diagnostics, radio, keep-awake | [Controls contract](docs/architecture/player-controls-contract.md) |
+| Embedded MPV, native runtime and packaging | [Embedded MPV](docs/architecture/embedded-mpv-native.md) |
+| UI, keyboard, detail navigation, remote control | [UI guidelines](docs/architecture/iptvnator-ui-guidelines.md), then matching topic in context map |
+| PWA, backend networking, connectivity guard | [PWA contract](docs/architecture/pwa-self-hosted.md), [host connectivity](docs/architecture/host-connectivity-guard.md) |
+| Downloads, TMDB, multi-source, workspace, backup, website | [Context map](docs/maintenance/agent-context-map.md) |
+| Release or packaging metadata | [Release pipeline](docs/architecture/release-pipeline.md), release-cut skill |
+
+Repository skills live in `.codex/skills/`. Their frontmatter owns trigger
+conditions; use the context map to locate a relevant skill. Read its linked
+contract before editing. A missing optional global skill/tool is not a blocker:
+use repository documentation and available CLI discovery.
## General Guidelines for working with Nx
-- For navigating/exploring the workspace, invoke the `nx-workspace` skill first when it is available - it has patterns for querying projects, targets, and dependencies. If it is unavailable, use `pnpm nx show projects`, `pnpm nx graph`, and project `project.json` files directly.
-- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
-- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
-- You have access to the Nx MCP server and its tools, use them to help the user
-- For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
-- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
+- For workspace exploration, use the `nx-workspace` skill when available;
+ otherwise inspect project-local project.json files, `pnpm nx show projects` and `pnpm nx graph`.
+- Run project tasks through local `pnpm nx`, not a global Nx installation.
+- Use the Nx MCP server when available; otherwise use CLI discovery.
+- Check `node_modules/@nx//PLUGIN.md` for plugin guidance when present.
+- Never guess unfamiliar flags: consult `--help` or available `nx_docs`.
-## Scaffolding & Generators
+## Scaffolding and generators
-- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
+- Use the `nx-generate` skill first when available. Otherwise discover the
+ generator with local Nx help and follow repository boundary rules.
## When to use nx_docs
-- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
-- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
-- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
+- Use available `nx_docs` for migrations, unfamiliar configuration and flags.
+- Basic task syntax does not require a docs lookup; generator discovery belongs
+ to the generator skill or local CLI help.
-
-## Catch-Up URL Copying
-
-EPG timeline/list programme details expose Copy archive URL for supported
-Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
-clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
-the currently non-catch-up M3U guide expose no action. See
-`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
-
-## Xtream Archive Downloads
-
-Desktop Xtream Live TV programme details can enqueue completed catch-up as
-`contentType: catchup`. The queue uses the existing timeshift resolver, original
-timestamps and playback headers. `programme_start` plus playlist/channel provides
-identity; JSON `catchup` metadata retains channel, broadcast window and known
-expiry. `download-schema.ts` owns the transactional CHECK/index migration;
-`download-tables.ts` exports the download tables. The cascading
-`download_archive_finalizations` table records write-ahead file identity/size
-proof before promotion (before writing a fallback copy), allowing startup to
-recover completed unknown-length archives and clean only their owned partials.
-The same journal stores transfer-phase descriptor identity before truncation;
-Resume checks it at open, and rejected replacements are preserved and detached
-so Retry can reserve a fresh path. A synchronous completion-commit boundary
-rejects late pause/cancel commands before awaited cleanup and persistence.
-Archive ownership reads device/inode as BigInt and journals decimal strings
-without losing 64-bit Windows file references, alongside positive creation time to reject
-reused inodes after unlink; old proofs without creation time remain untrusted.
-Fresh reservations atomically commit their row path/name and captured ownership
-before the initial HTTP wait;
-no preexisting partial is truncated without matching expected ownership.
-Captured foreign files retain their recovery copy and journal even after public
-restoration, until the user explicitly removes the recovery copy. Remove/Clear
-show its full path and recovery instructions in a persistent dialog with Copy
-recovery path.
-Private cleanup captures are journaled before relocation, keeping failed
-Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active
-failures, promotion and startup share that cleanup; Remove waits for active
-archive cancellation to settle before deleting its row and journal.
-Archive transfers validate TS framing, restart from byte zero after interruption
-and check expiry again at transfer start. Completed cards play locally and never
-route to VOD details. Contract and EOF/duration limits:
-`docs/architecture/download-manager.md` (Xtream archive downloads).
-
-## Desktop Source Health
-
-Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL
-checks through `SourceHealthService` in portal shared data access. Confirmed
-account expiry/disablement is distinct from failed authorization or network
-checks. Stalker probes reuse session ownership without endpoint repair; M3U
-reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
-`docs/architecture/m3u-playlist-module.md` (Desktop source health).
-
-Desktop Sources also offers library-wide selective cleanup through dialog-scoped
-`SourceCleanupService`. Only confirmed expired/disabled accounts are preselected;
-playback/import/refresh/delete-busy sources are skipped. Deletion goes through
-one serialized `PlaylistsService` operation and awaited cleanup hooks;
-`PlaylistActions.playlistRemovalCommitted` updates state without another DB
-delete. Stop finishes the current source. Same contract: Desktop inactive-source
-cleanup in `docs/architecture/m3u-playlist-module.md`.
-
-Startup source auto-refresh uses `SourceActivityService` to protect busy IDs
-from cleanup. Late batch refreshes skip deleted rows instead of recreating them.
-Contract: `docs/architecture/m3u-playlist-module.md` (Desktop inactive-source cleanup).
diff --git a/CLAUDE.md b/CLAUDE.md
index 01db26830..83f10b6ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,2035 +1,11 @@
-# CLAUDE.md
+# Claude Code guidance
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+@AGENTS.md
-> The process sections below (Plan Mode, Documentation After Changes, Upgrade And Migration Compatibility, Regression Prevention, Agent Bootstrap, Electron CDP Debugging) are mirrored in `AGENTS.md`, which is the canonical copy for agent workflows. When updating one, keep the other in sync.
+Common rules and task routing are maintained in [AGENTS.md](AGENTS.md).
+Read only the linked contracts relevant to the task. Do not add subsystem
+summaries or duplicate common instructions here.
-## Plan Mode
-
-- When Claude Code is in Plan Mode and produces a final ``, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.
-- Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`.
-- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.
-- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.
-
-## Documentation After Changes
-
-- After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.
-- Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries.
-- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.
-- Prefer updating an existing authoritative doc before creating a new one:
- 1. `README.md` for top-level developer or user workflows
- 2. `docs/architecture/` for architecture, ownership, and behavior contracts
- 3. the nearest module `README.md` for local usage or behavior
-- Keep this file (`CLAUDE.md`) itself up to date. It is a living document: whenever a change touches something it describes — monorepo structure (new/moved/renamed apps or libs), routes, database schema/tables, stores and their features, key components, commands, environment behavior, or coding conventions — update the affected `CLAUDE.md` sections as part of the same task, and keep the mirrored process sections in `AGENTS.md` in sync.
-- When adding a new feature area, check whether the Architecture or Key Features sections of `CLAUDE.md` describe the surrounding area; if they do, reflect the addition there instead of leaving the description stale.
-- Do not let `CLAUDE.md` drift: a stale path or route in this file poisons the context of every future agent session. If you notice an outdated claim while working, fix it (or flag it in the final summary) even if it is unrelated to the current task.
-- Repo docs are canonical even when they were originally drafted by an LLM.
-- Final task summaries should state whether docs were updated and which doc changed.
-
-## Release Notes For User-Visible Changes
-
-- Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`.
-- Name it ` -.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
-- Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
-- `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.
-- `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, open the blog scaffold (a "What changed" table row plus a leading `##` section each, while the remaining features fold into themed sections and non-highlighted fixes collapse under a spoiler — `tools/release/release-notes-blog.mjs`), and are the input the highlight-card generator renders from. A release where everything is a highlight has none.
-- Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label.
-- CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt).
-- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`.
-- Validate before finishing: `pnpm run release:notes:validate`.
-- Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically.
-- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
-- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
-- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
-- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`. A manifest shot may carry `browser: {url, viewport}` to frame a loopback page the app serves (the remote-control phone view) in a separate mobile-sized Chromium instead of the Electron window, behind the same network and content guards; the Xtream mock's `marketing`/`marketing2` scenarios serve movie, episode and live stream URLs from local bytes so download and playback shots never leave the machine.
-- Final task summaries should state whether a release note was added or why it was skipped.
-
-## AppImage Manager Metadata
-
-AppManager full-download discovery uses `appImage.desktop.entry` URL fields.
-Electron Builder generates the version; `extraMetadata.desktopName=iptvnator`
-preserves Linux window identity without a shared `linux.desktop.entry` object
-(builder's nested merge would leak AppImage fields into Snap). This does not
-enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md`
-(AppImage external-manager metadata).
-
-## Upgrade And Migration Compatibility
-
-- Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version.
-- Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception.
-- Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns.
-- For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores.
-- See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance.
-
-## Regression Prevention And Test Updates
-
-- Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required.
-- Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed.
-- Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite.
-- Default validation ladder:
- 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.
- 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
- 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.
- 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`.
-- Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below.
-- Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.
-
-## Project Overview
-
-IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.
-
-**Dual Environment Support**: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts.
-
-## Development Commands
-
-### Agent Bootstrap
-
-```bash
-pnpm install --frozen-lockfile
-pnpm nx show projects
-```
-
-- Run the install step in a fresh worktree before relying on Nx discovery, lint, test, or build commands. Without `node_modules`, local Nx modules are unavailable.
-- Re-run the install whenever the checkout moves — `git pull`, `git reset --hard`, a rebase, or a worktree branch being re-pointed. Git rewrites `pnpm-lock.yaml` but never re-links `node_modules`, so a tree installed at an older commit keeps serving the old dependency versions and tests fail locally while CI stays green. Check with `cmp pnpm-lock.yaml node_modules/.pnpm/lock.yaml`; any difference means the tree is stale, and a plain `pnpm install --frozen-lockfile` in that directory repairs it. Each worktree needs its own install — with no local `node_modules`, Nx aborts with `Could not find ".modules.yaml"`.
-- Never run `prettier --write` on `CLAUDE.md`, `AGENTS.md` or `docs/**`. These files are not Prettier-clean upstream, so a whole-file write reflows passages the change never touched — a nested list item loses its indentation, a `+ player` continuation line turns into a `- player` bullet — and the review bots flag the diff as corrupted guidance (PR #1628). Format only the lines you wrote. If a write already happened, restore the file from the branch's merge base (`git show $(git merge-base HEAD origin/master):CLAUDE.md > CLAUDE.md`) and re-apply the intended edit by hand.
-- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`.
-- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.
-- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.
-- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.
-- Keep `nx` and every official `@nx/*` package on the same exact version; run
- `pnpm run deps:nx:validate` after dependency updates.
-- Use the Node version in `.nvmrc` for development and CI. Angular 22 requires
- Node `^22.22.3 || ^24.15.0` and TypeScript `>=6.0 <6.1` in this workspace.
-- Vite `8.1.5`, resolved through Angular's build tooling, retains upstream
- precise matchers and adds bounded raw-code prefilters through
- `patches/vite@8.1.5.patch`. Keep the patch until upstream also preserves
- comment-bearing asset/worker expressions; run `pnpm run deps:vite:test`
- after related dependency updates.
-- `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in
- `patches/app-builder-lib@26.15.7.patch` with the upstream backport
- electron-userland/electron-builder#10172: `security set-key-partition-list -k`
- must receive the temporary keychain's own password, not the `.p12` import
- password. macOS runner images since `macos-26-arm64` 20260831 verify that
- password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user
- name or passphrase you entered is not correct`. Keep the patch until
- electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+),
- and run `pnpm run deps:electron-builder:test` after related dependency
- updates — the test fails when the patched version no longer matches the
- installed one.
-- `node-gyp` is a declared root devDependency because
- `apps/electron-backend/build-embedded-mpv.js` resolves it with
- `require.resolve`. Do not drop it as "unused": without the declaration it is
- reachable only through pnpm's hidden hoist (`node_modules/.pnpm/node_modules`),
- which pnpm's `.bin` shims put on `NODE_PATH` — so `pnpm nx …` and CI keep
- working while a plain `node apps/electron-backend/build-embedded-mpv.js`
- fails on a clean install with "Unable to resolve node-gyp".
-- `nx-electron@22.0.0` uses a local Nx 23 export-path patch and an explicit
- `webpack-node-externals` package extension. Scoped peer allowances for it
- and `ngx-indexed-db@22.0.0` live in `pnpm-workspace.yaml`; they are project
- compatibility bridges, not upstream support declarations. See
- `docs/architecture/nx-workspace-boundaries.md` before removing them.
-- A directory holding files consumed by other projects must be an Nx project.
- Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`
- across project roots creates no edge and the imported file lands in no task
- hash — edits then return a cache hit instead of rebuilding. Shared partials
- live in `libs/ui/styles` (project `ui-styles`), and each consumer declares
- `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate`
- after adding a cross-project stylesheet import.
-- Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the
- lockfile, run generated migrations when present, and validate before opening
- a PR. Major updates are always manual. Replace incomplete Dependabot security
- PRs with a coordinated update instead of editing the bot branch.
-- Repository-specific skills live under `.codex/skills/`.
-- Frontmatter descriptions are trigger-only and begin with `Use when`; keep
- each skill at or below 500 words.
-- Run `pnpm run skills:validate` after editing a committed skill or a literal
- path it documents.
-- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`
- byte-identical.
-
-### Building and Serving
-
-```bash
-# Serve the Angular web app only (development mode, baseHref="/")
-pnpm run serve:frontend
-# or
-nx serve web
-
-# Serve with PWA configuration (optimized, baseHref="/")
-pnpm run serve:frontend:pwa
-# or
-nx serve web --configuration=pwa
-
-# Serve the Electron app (starts both frontend and backend)
-pnpm run serve:backend
-# or
-nx serve electron-backend
-
-# Build frontend for Electron (baseHref="./")
-pnpm run build:frontend
-# or
-nx build web
-
-# Build frontend for PWA deployment (baseHref="/")
-pnpm run build:frontend:pwa
-# or
-nx build web --configuration=pwa
-
-# Build backend (Electron)
-pnpm run build:backend
-# or
-nx build electron-backend
-
-# Package the app (creates distributable without installers)
-pnpm run package:app
-# or
-nx run electron-backend:package
-
-# Create installers/executables
-pnpm run make:app
-# or
-nx run electron-backend:make
-```
-
-### Windows Embedded MPV Pin Maintenance
-
-- PR, master, and tag builds resolve the Windows runtime only from
- `tools/embedded-mpv/windows-runtime-pin.json`; repository variables are not
- build inputs.
-- Validate the checked-in schema and provenance with
- `pnpm embedded-mpv:windows-runtime-pin:check`.
-- Prepare a manual rotation with
- `pnpm embedded-mpv:windows-runtime-pin:refresh -- --force`. The weekly
- `refresh-windows-embedded-mpv-runtime.yaml` workflow runs the same updater
- and opens a reviewable PR before upstream retention expires.
-- The PAT-backed refresh job must keep every third-party action pinned to a
- full commit. Do not mirror the upstream binary without complete
- corresponding source, build records, license notices, and a validated
- transitive license closure.
-
-### Electron CDP Debugging
-
-- Start Electron in dev mode with: `nx serve electron-backend`
-- Package-script equivalent: `pnpm run serve:backend`
-- The workspace is configured to always launch Electron with: `--remote-debugging-port=9222`
-- Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222`
-- When the task is Electron automation/debugging, use the `electron` skill
-- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
-- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`.
-- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue.
-
-For startup tracing or white-screen debugging:
-
-```bash
-IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
-```
-
-Useful narrower flags:
-
-- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
-- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events
-- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections
-- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle
-- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity, bounded Embedded MPV runtime-probe stderr, and embedded MPV session status transitions (the input of the reconnect policy; never the stream URL)
-- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal
-- `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
-- `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
-
-Settings, portal request/response, and trace payloads must use
-`@iptvnator/shared/logging` or the redacting portal logger before reaching
-`console.*`; never log raw credentials while debugging.
-
-If the Nx daemon gets into a bad state before rerunning Electron:
-
-```bash
-pnpm nx reset
-```
-
-Use global `agent-browser` (preferred):
-
-```bash
-# Verify CDP targets
-agent-browser --cdp 9222 tab list
-
-# Switch to the app tab and inspect interactive elements
-agent-browser --cdp 9222 tab 1
-agent-browser --cdp 9222 snapshot -i -c -d 4
-
-# Capture debug artifacts
-agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png
-agent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip
-agent-browser --cdp 9222 wait 1500
-agent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip
-```
-
-If `agent-browser` is not in PATH, use:
-
-```bash
-npx --yes agent-browser --cdp 9222 tab list
-```
-
-### Testing
-
-```bash
-# Run frontend tests
-pnpm run test:frontend
-# or
-pnpm nx test web
-
-# Run backend tests
-pnpm run test:backend
-# or
-pnpm nx test electron-backend
-
-# Run targeted E2E tests (Playwright)
-pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts
-pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts
-
-# Run broad E2E suites only when the impact justifies it
-pnpm nx e2e web-e2e
-pnpm nx e2e electron-backend-e2e
-
-# Run tests with coverage when needed
-pnpm nx test web --configuration=ci
-```
-
-Before finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.
-
-### Linting
-
-```bash
-# Lint all projects (CI runs this on master; PRs lint affected projects)
-pnpm run lint
-
-# Lint a single project
-nx lint web
-nx lint electron-backend
-```
-
-CI lints affected projects on PRs (`nx affected`) and every project on master
-pushes (`.github/workflows/ci.yml`). This enforces the
-Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint
-rule. The limits and their rationale live in one place,
-`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the
-baseline generator import so the enforced rule and the generated list cannot
-drift:
-
-- **Production TypeScript: hard maximum 400 lines.**
-- **Tests: 1200.** `**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts` and
- everything under `apps/*-e2e/**` — a spec is a flat list of independent
- cases, so splitting one at the production limit yields arbitrary
- `-2.spec.ts` files, and length there signals coverage rather than the
- design debt the production limit catches. `.spec-data.ts` fixtures (flat
- case lists consumed only by a spec, e.g. the worker IPC contract table)
- grow with coverage the same way.
-- **Blank lines and comments are not counted** (`skipBlankLines`,
- `skipComments`), so a docblock is never the reason a file must be split.
-
-Pre-existing oversized files are baselined in
-`tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with
-`node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The
-generator decides who belongs on the list by running ESLint's own `max-lines`
-rule, not by counting lines itself — a private reimplementation would silently
-disagree with the rule and produce a baseline that turns CI red while looking
-correct. Never add new files to the baseline — the list must only shrink. A new
-file that genuinely cannot be split (for example a function serialized into
-another process) instead carries its own file-wide
-`/* eslint-disable max-lines -- */`; the generator skips those files, so
-a justified exemption never lands in the baseline. If such a directive later
-becomes unnecessary, ESLint reports it as an unused disable directive — remove
-it rather than leaving a stale justification behind.
-
-Project `lint` targets that shell out to eslint must quote the glob, e.g.
-`eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX
-shell on Linux and macOS (which has no `globstar`, so it matches only a
-shallow subset of files) while Windows passes the literal pattern to ESLint,
-which expands it recursively — the two hosts then lint different file sets.
-The target still reports success either way, so a broken glob hides missing
-coverage instead of failing. After changing such a target, compare the linted
-file count against `find -name '*.ts' | wc -l`.
-
-## Legacy Desktop Profile Migration
-
-`electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend`
-profile before eager main-process imports only when current Chromium storage
-is unused. Existing profiles retain their settings and offer explicit recovery
-of missing sources from a disposable legacy snapshot. Playlist rows and a
-completion receipt commit atomically in the DB worker; original IndexedDB is
-retained, current payload rows are preserved, and completed imports never
-replay deleted sources. Contract and recovery limits:
-`docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles).
-
-Startup shows `AppStartupStatusComponent` until the initial route and source
-inventory are ready, including XMLTV reconciliation. Inventory reads retry once;
-failed reads show an explicit Retry action instead of an empty library. Successful
-inventory reads first await settings loading, then pending XMLTV reconciliation,
-and retry failed cleanup
-with its last committed URLs before exposing the workspace. See the
-same contract for startup readiness and error handling.
-
-## Architecture
-
-### Monorepo Structure (Nx Workspace)
-
-This is an Nx monorepo with the following structure:
-
-- **apps/web** - Angular application (frontend, shared by Electron and PWA)
-- **apps/electron-backend** - Electron main process
-- **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints). At startup it raises Node's happy-eyeballs per-attempt connection timeout to 2500 ms (`applyDefaultAutoSelectFamilyAttemptTimeout` in `libs/shared/host-health`; the Electron main process and its playlist-refresh and EPG workers apply the same default through `apps/electron-backend/src/app/util/network-defaults.ts`, since Node keeps it per isolate) so dual-stack provider hostnames fall back to IPv4 behind IPv6-less VPN/Docker networks; an explicit `--network-family-autoselection-attempt-timeout` passed via `NODE_OPTIONS`/CLI always wins. Outbound provider failures are logged hostname-only with the underlying Node error codes and return the primary code in the error body (`provider-error.ts`) — the proxied URL query carries credentials and must never be logged. Every proxied request carries the same timeout as its Electron counterpart (Xtream 30 s, Stalker 15 s / 30 s for `create_link`, playlist and XMLTV 30 s). The shared per-host circuit breaker (`host-guard.ts`, injected via `WebBackendAppOptions.hostGuard`) covers `/xtream` and `/stalker` only — playlist/XMLTV downloads keep the timeout but no breaker, matching Electron. A fast-fail keeps the route's normal failure shape (HTTP 200 with a `{message, status}` body), `skipConnectionGuard=true` carries the Stalker discovery exemption through the proxy, and `POST /connectivity-guard/reset` is the PWA's counterpart to the `CONNECTIVITY_GUARD_RESET` IPC
-- **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend
-- **apps/web-e2e** - Playwright E2E tests against the web app
-- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app
-- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E
-- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E
-- **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD and open with `src/components/blog/ContentDisclaimer.astro`, whose `offline` variant is mandatory for posts about downloads or recordings; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag//` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`; most compare IPTVnator's own options, and a page that names other software must carry a dated `ThirdPartyNote` — see "Pages that name other software" in `apps/website/README.md`); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`)
-- **libs/** - Shared libraries:
- - **epg/data-access** - EPG services, runtime bridge, program normalization
- - **m3u-state** - NgRx state management for M3U playlists
- - **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs, and the "Auto-detect" method: paste a provider message, `detectProviderImportCandidates` in `libs/shared/interfaces` deterministically extracts URLs/credentials/MAC+device identity and prefills the matching form — detection only proposes, the target form's own validation and behavioral probes stay authoritative)
- - **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes
- - **playlist/shared/{ui,util}** - Shared playlist UI and utilities
- - **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components
- - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components
- - **portal/catalog/feature** - Portal catalog UI
- - **portal/downloads/feature** - Download manager UI
- - **portal/shared/{data-access,ui,util}** - Cross-portal shared code: stateful collection services and VOD multi-source discovery/resolve/ranking live in `data-access`; reusable views live in `ui`; `util` is for pure contracts/helpers
- - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)
- - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`). `foldSearchText` (`search-text-fold.util.ts`) is the one case fold every in-memory search filter uses (channel lists, catalog/category filters, the command palette, sources, downloads): `toLowerCase()`, then NFC, then a combining-mark strip, never `toLocaleLowerCase()`. Composing before the strip is what makes canonically equivalent spellings fold to one string (a decomposed `e`+U+0301 title and a precomposed `é` query, a Greek `Ά` and `ά`); only marks that cannot compose are dropped, the Turkish dotted İ's leftover dot among them, so the fold matches a plain `i` while staying accent-sensitive and locale-independent (issue #609). A filter must call it on BOTH sides, and a list's row filter and its count/queue source must use the same fold or the two disagree. The Electron content search (`content-search.util.ts`) folds the same way and additionally spells explicit `'tr'`-locale İ forms into its LIKE/GLOB variants, because SQLite LIKE folds only ASCII
- - **shared/logging** - Dependency-free structured redaction for diagnostic logs
- - **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Also home to the two Node networking helpers both backends share: the happy-eyeballs attempt-timeout default (`network-family-autoselection.ts`) and the socket-connect observer that feeds the guard's `connected` flag (`socket-connect-observer.ts`). Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438)
- - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)
- - **shared/m3u-utils** - M3U playlist utilities
- - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/.svg`)
- - **shared/testing** - Shared test helpers
- - **ui/components** - Reusable UI components (incl. channel list)
- - **ui/epg** - EPG UI (timeline ribbon, programme guide grid via `EPG_GUIDE_SOURCE`, progress panel, program dialogs)
- - **ui/playback** - Player UI (video/audio players)
- - **ui/pipes** - Angular pipes
- - **ui/remote-control** - Remote-control UI pieces
- - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`)
- - **ui/styles** - Shared styles/theme
- - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard
-
-### Frontend Architecture (Angular)
-
-**State Management**: Uses NgRx for playlist state management:
-
-- Store configuration in `apps/web/src/app/app.config.ts`
-- Playlist state, actions, effects, and reducers in `libs/m3u-state/`
-- Entity adapter pattern for managing playlists collection
-- Router store integration for route-based state
-
-**XtreamStore Architecture** (Signal Store with Feature Composition):
-
-The Xtream Codes module uses NgRx Signal Store with a layered architecture:
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ PRESENTATION LAYER │
-│ Components use XtreamStore (facade) │
-└─────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ FACADE LAYER │
-│ XtreamStore │
-│ (Composes feature stores, unified API) │
-└─────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ withPortal · withContent · withSelection · withSearch · withEpg │
-│ withPlayer · withFavorites · withRecentItems │
-│ withPlaybackPositions │
-└─────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────┐
-│ DATA SOURCE LAYER │
-│ IXtreamDataSource │
-│ ┌───────────────────┬───────────────────┐ │
-│ ▼ ▼ │
-│ ElectronDataSource PwaDataSource │
-│ (DB-first + API) (API-only) │
-└─────────────────────────────────────────────────────────────────┘
-```
-
-File structure:
-
-```
-libs/portal/xtream/
-├── data-access/src/lib/
-│ ├── stores/
-│ │ ├── features/
-│ │ │ ├── with-portal.feature.ts # Playlist & portal status
-│ │ │ ├── with-content.feature.ts # Categories & streams
-│ │ │ ├── with-selection.feature.ts # UI selection & infinite-scroll window
-│ │ │ ├── with-search.feature.ts # Search functionality
-│ │ │ ├── with-epg.feature.ts # EPG data
-│ │ │ ├── with-player.feature.ts # Stream URLs & player
-│ │ │ ├── with-playback-positions.feature.ts # Resume/playback positions
-│ │ │ └── index.ts
-│ │ ├── xtream.store.ts # Facade composing all features
-│ │ └── index.ts
-│ ├── services/
-│ │ ├── xtream-api.service.ts # Xtream Codes API calls
-│ │ ├── xtream-url.service.ts # Stream URL construction
-│ │ ├── favorites.service.ts # Favorites persistence
-│ │ ├── epg-queue.service.ts # EPG fetch queueing
-│ │ ├── xtream-xmltv-fallback.service.ts # XMLTV fallback EPG
-│ │ └── index.ts
-│ ├── data-sources/
-│ │ ├── xtream-data-source.interface.ts # Abstract interface + types
-│ │ ├── electron-xtream-data-source.ts # DB-first implementation
-│ │ ├── pwa-xtream-data-source.ts # API-only implementation
-│ │ └── index.ts # provideXtreamDataSource() factory
-│ ├── with-favorites.feature.ts # Favorites feature
-│ └── with-recent-items.ts # Recently viewed feature
-└── feature/src/lib/ # Routed components
- ├── xtream-feature.routes.ts # createXtreamRoutes(): /workspace/xtreams/:id tree
- ├── live-stream-layout/, vod-details/, serial-details/, ...
- ├── portal-channels-list/
- │ ├── epg-preview-program.ts # Pure "which programme is current" rules
- │ ├── epg-refill-limiter.service.ts # Floor on refetching an exhausted guide
- │ └── epg-refresh-coordinator.service.ts # One refresh timer, merged queue requests
- └── global-search-results/ # Global search (Electron-only route)
-```
-
-Key patterns:
-
-- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality
-- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility
-- **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and
- API/in-memory implementations
-- **Factory injection**: `provideXtreamDataSource()` selects
- `ElectronXtreamDataSource` only when
- `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it
- selects `PwaXtreamDataSource`
-- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging.
- `withSelection` keeps a `visibleCount` render window over the in-memory
- catalog plus bounded per-selection scroll snapshots for detail/tab
- round-trips; the shared `InfiniteScrollDirective`
- (`libs/portal/shared/ui`) measures container overflow to auto-fill tall
- viewports (terminating on lack of container growth, not on a load count)
- and fires `loadMore` near the bottom. The search layout routes its results
- container through the same directive (`nearEnd*` inputs). Stalker feeds the
- same contract from server-paged appends: portal pages accumulate into one
- deduplicated list, `hasMoreContent` derives from accumulated length vs
- `total_items`, a failed append keeps loaded pages and offers a tail retry,
- and the facade maps page 0 to the skeleton and later pages to the tail
- spinner. No paginator remains anywhere in the app
-
-Xtream data strategies by runtime capability:
-
-| Capability | Strategy |
-| --------------------------------- | -------------------------------------------------------- |
-| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB |
-| **Bridge unavailable** | API-only: fetch from API and keep session data in memory |
-
-**M3U Playlist Module Architecture**:
-
-The M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ VIDEO PLAYER PAGE │
-│ libs/playlist/m3u/feature-player/src/lib/video-player/ │
-├─────────────────────────────────────────────────────────────────────┤
-│ ┌─────────────┐ ┌───────────────────────────────────────────────┐│
-│ │ Sidebar │ │ Video Player (ArtPlayer/Video.js) ││
-│ │ ┌─────────┐ │ │ ││
-│ │ │Channel │ │ ├───────────────────────────────────────────────┤│
-│ │ │List │ │ │ EPG timeline ribbon (app-epg-timeline) ││
-│ │ │Container│ │ │ horizontal, under the player ││
-│ │ └─────────┘ │ └───────────────────────────────────────────────┘│
-│ └─────────────┘ │
-└─────────────────────────────────────────────────────────────────────┘
-```
-
-The live EPG panel is a horizontal **timeline ribbon** under the player (`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`), not a right-side drawer (reworked in PR #1102). See `docs/architecture/m3u-playlist-module.md` for the timeline's controllers and scroll behavior.
-
-**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`.
-
-**Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, plus `--season-cover-width` 96/120/144px for the season cover on series detail pages, written to `` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`.
-
-**Radio Channel Layout** (when `channel.radio === 'true'`):
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ ┌─────────────┐ ┌────────────────────────────────────────────────┐│
-│ │ Sidebar │ │ Blurred backdrop (station logo) ││
-│ │ │ │ ┌──────────┐ ││
-│ │ │ │ │ Artwork │ ← cinematic hero layout ││
-│ │ │ │ └──────────┘ ││
-│ │ │ │ Station Name ││
-│ │ │ │ [LIVE] badge ││
-│ │ │ │ ⏮ ▶/⏸ ⏭ ← transport controls ││
-│ │ │ │ 🔊 ━━━━━━━━━ ← volume slider ││
-│ │ │ │ (no EPG panel) ││
-│ └─────────────┘ └────────────────────────────────────────────────┘│
-└─────────────────────────────────────────────────────────────────────┘
-```
-
-Key radio behavior:
-
-- Detection: `channel.radio === 'true'` (string from M3U `radio` attribute)
-- The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio
-- EPG panel is conditionally hidden in the template when radio is active
-- Volume is shared with video player via `localStorage` key `'volume'`
-- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute
-- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
-
-**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry
-recognized as a movie FILE swaps the player + EPG area for the portals'
-two-state VOD detail shell fed by TMDB, watch-first (activation still plays
-immediately; Esc reveals the Browse hero). Detection is synchronous URL-shape
-heuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`)
-or an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/`
-paths and episode-marker names (`S01E02`, "2 серия") fail toward the live
-layout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB
-enrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in
-Settings → Metadata (TMDB)). Host: `m3u-vod-detail/` in
-`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`,
-parent's unchanged `embeddedPlayback()` payload); external MPV/VLC users
-keep Browse. See "Movie Recognition (VOD Detail View)" in
-`docs/architecture/m3u-playlist-module.md`.
-
-M3U playback mode is independent of this metadata gate: `isLikelyM3uVod`
-recognizes video-file extensions and exact `/movie|movies|vod|series/` URL
-segments, including episodes. The M3U parent's `embeddedPlayback()` sets
-`isLive: false` for those entries or a catch-up URL, even with TMDB/details
-disabled; the detail host forwards that same payload. Ordinary HLS/TS and
-unknown URLs without VOD evidence, DASH and radio retain their existing
-behavior. Seeking requires a seekable source and duration. Xtream/Stalker,
-external MPV/VLC launch payloads and session identity are unchanged. Contract:
-`docs/architecture/m3u-playlist-module.md` (M3U Playback Mode).
-
-Channel List Component Structure (parent coordinator pattern):
-
-```
-libs/ui/components/src/lib/channel-list-container/
-├── channel-list-container.component.ts # Parent - shared state coordinator
-├── all-channels-view/ # Virtual scroll + debounced search
-├── groups-view/ # Expansion panels + infinite scroll
-├── favorites-view/ # CDK drag-drop reordering
-├── recent-view/ # Recently viewed channels
-└── channel-list-item/ # Individual channel display
-```
-
-Key patterns:
-
-- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance
-- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)
-- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists
-- **Infinite scroll**: IntersectionObserver in groups view loads 50 items at a time
-- **Global progress tick**: Single 30s interval instead of per-item intervals
-
-State management via NgRx (`libs/m3u-state/`):
-
-- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
-- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive
-- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
-- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites
-
-See `docs/architecture/m3u-playlist-module.md` for complete documentation.
-
-**Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts`. All user-facing routes are nested under the workspace shell (`/workspace/...`); `/` redirects into the workspace.
-
-- Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources`
-- M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player`
-- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `discover`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`
-- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `discover`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`
-- Global collections: `/workspace/global-favorites`, `/workspace/global-recent`
-- Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`)
-- Downloads: `/workspace/downloads` with focused
- `/workspace/downloads/:downloadId`; source-scoped equivalents are
- `/workspace/xtreams/:id/downloads/:downloadId` and
- `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
- the workspace context panel.
-- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
-
-**Service Architecture** (Factory Pattern):
-
-- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract
-- Two environment-specific implementations:
- - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend
- - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version
-- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject:
- ```typescript
- if (window.electron) {
- return inject(ElectronService);
- }
- return inject(PwaService);
- ```
-
-**Data Storage (Environment-Specific)**:
-
-- **Electron**: SQLite database via Drizzle ORM (`better-sqlite3` driver)
- - Location: `~/.iptvnator/databases/iptvnator.db`
- - Full-featured relational database with foreign keys and indexes
- - Canonical schema and connection live in `libs/shared/database`
-- **PWA (Web)**: IndexedDB via `ngx-indexed-db`
- - Browser-based NoSQL storage
- - Same schema structure but implemented in IndexedDB
- - Limited by browser storage quotas
-
-**TypeScript File Size Rule**:
-
-Keep production TypeScript files under **300 lines**. Hard maximum is
-**350–400 lines**, and CI enforces the 400. Blank lines and comments do not
-count toward it, so documenting a file never costs you headroom. Tests
-(`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held
-to 1200 instead — the guidance below is about production code.
-
-- When creating new files, design them to stay within this limit from the start.
-- When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code.
-- When you notice a file already exceeds 350 lines, **proactively suggest a refactoring** (or perform it if the change is straightforward) — even if the immediate task is small.
-
-Typical split strategies:
-
-- Angular components: extract child components, move logic to a dedicated service or store feature
-- Signal store features: split into smaller `with*` feature functions in separate files
-- Services: split by responsibility (e.g. separate API, transformation, and state concerns)
-- Utility files: group by domain and export from a barrel `index.ts`
-
-This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.
-
----
-
-**Angular Coding Standards**:
-
-This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:
-
-- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators
-
- ```typescript
- // ✅ Correct - Signal-based
- readonly menu = viewChild.required('menuRef');
- readonly items = viewChildren('item');
-
- // ❌ Incorrect - Old decorator syntax
- @ViewChild('menuRef') menu!: MatMenu;
- @ViewChildren('item') items!: QueryList;
- ```
-
- **Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:
-
- ```html
-
- Open Menu
-
-
- Open Menu
- ```
-
-- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators
-
- ```typescript
- // ✅ Correct - Signal-based
- readonly title = input.required();
- readonly size = input(10); // with default value
- readonly clicked = output();
-
- // ❌ Incorrect - Old decorator syntax
- @Input({ required: true }) title!: string;
- @Input() size = 10;
- @Output() clicked = new EventEmitter();
- ```
-
-- **Reactive State**: Use signal primitives for reactive state management
-
- ```typescript
- // ✅ Use signal(), computed(), effect(), linkedSignal()
- readonly count = signal(0);
- readonly doubled = computed(() => this.count() * 2);
-
- constructor() {
- effect(() => {
- console.log('Count changed:', this.count());
- });
- }
- ```
-
-- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)
-
- ```typescript
- @HostBinding('class.active') get isActive() { return this.active(); }
- @HostListener('click') onClick() { /* ... */ }
- ```
-
-- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`
-
- ```typescript
- // ✅ Correct - Modern syntax
- @if (isLoggedIn()) {
- Welcome!
- }
-
- @for (item of items(); track item.id) {
- {{ item.name }}
- }
-
- // ❌ Incorrect - Old syntax
- Welcome!
- {{ item.name }}
- ```
-
-### Backend Architecture (Electron)
-
-**Main Entry**: `apps/electron-backend/src/main.ts`
-
-- Bootstraps Electron app and initializes database
-- Registers event handlers for IPC communication
-- Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen
-- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). The zoom shortcuts (Cmd/Ctrl and +/−/0, numpad included) are a renderer key binding in `WorkspaceKeyboardShortcutsService` — the Windows/Linux window has no menu (`setMenu(null)`) — calling the synchronous preload-local bridge method `adjustZoomLevel`, which steps the same frame-bound level (`stepZoomLevel` in `libs/shared/interfaces`: 0.5 per press like Electron's `zoomIn`/`zoomOut` roles, clamped to levels −4…6, a stored level already outside them never moved against the request, so the returned level is not itself guaranteed in range); on macOS the renderer's `preventDefault()` keeps the menu role from stepping a second time. Contract: `docs/architecture/workspace-shell.md`, "Zoom level"
-- Recovers a renderer reload on an in-app route: the packaged renderer is `index.html` over `file://` with path routing, so a reload of `file:///…/web/workspace/sources` asks for a path with no file behind it. A main-process reload (the macOS default menu's View › Reload, DevTools) failed with `ERR_FILE_NOT_FOUND` and stranded the window on Chromium's error page; a renderer-initiated one (the settings unsaved-changes guard's confirmed `location.reload()`) was cancelled by the `will-navigate` trust check and silently did nothing. `app/services/renderer-reload-fallback.ts` handles both — `attachRendererReloadFallback` answers the main-frame `did-fail-load` (deferred to the error page's `dom-ready`: a load issued from inside the failure event yields a document that never paints), and `handleRendererNavigation` recognizes a routed renderer URL (`resolveRoutedRendererUrl`) — by re-loading the packaged index with the route in the `restoreRoute` query parameter (`restoreRendererRoute`); `apps/web/src/main.ts` consumes it before Angular bootstraps (`resolveRestoredRendererRoute` in `libs/shared/interfaces`, resolved against `document.baseURI` and confined to the renderer directory). A failed `index.html` itself is never re-requested. Contract: `docs/architecture/workspace-shell.md`, "Reloading the renderer on an in-app route"
-- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded.
-
-**Database**:
-
-- **ORM**: Drizzle ORM with `better-sqlite3` (local SQLite file)
-- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)
-- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):
- - `playlists` - Playlist metadata (M3U, Xtream, Stalker)
- - `categories` - Content categories (live, movies, series)
- - `content` - Streams/VOD/series items. Besides the catalog fields it carries what a detail view learned and handed back: `backdrop_url`, plus the TMDB identity (`tmdb_id`, `release_year`, `original_title`) that lets an activity row repeat the detail view's lookup instead of rebuilding a weaker one from the display title
- - `favorites` - User favorites
- - `recentlyViewed` - Watch history
- - `epgChannels`, `epgPrograms` - Persisted EPG data
- - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)
- - `playbackPositions` - Resume positions
- - `downloads` - Download manager state
- - `recordings` - Live-TV recording lifecycle + start-time channel/EPG snapshot (defined in `schema.ts` beside `downloads`)
- - `appState` - Key-value app state (also tracks one-off data migrations)
- - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)
- - `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`)
-- **Connection**: `libs/shared/database/src/lib/connection.ts`
- - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)
- - Provides full read-write access for `electron-backend` and a read-only mode
- - A root `drizzle.config.ts` configures Drizzle Kit tooling (points at the schema via the compat shim)
-
-**IPC Communication**:
-
-- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts`
- - Exposes `window.electron` API via `contextBridge`
- - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)
- - The canonical TypeScript contract is `ElectronBridgeApi` in `libs/shared/interfaces/src/lib/electron-api.interface.ts`; `global.d.ts`, `apps/web/src/typings.d.ts`, and `main.preload.ts` must reference this shared type instead of maintaining separate method lists.
-- **Event handlers**: `apps/electron-backend/src/app/events/`
- - `database.events.ts` - Database CRUD operations
- - `playlist.events.ts` - Playlist import/update
- - `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts`
- - `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, source orchestration in `epg-worker.service.ts`, per-import lifecycle in `epg-fetch-operation.ts`, worker bootstrap/shutdown and clear protocol in `epg-worker-runtime.ts`, DB lookups in `epg-query.service.ts`
- - `xtream.events.ts` - Xtream Codes API
- - `stalker.events.ts` - Stalker portal API
- - `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`, and never a timeout after the TCP handshake — both transports report `connected` through an `onConnect` hook, and a panel that accepted the connection and then went silent is slow, not dead: the connection clears the streak the moment it happens through `reportConnected`, never when the timeout settles, never closes an open breaker, and is not credited at all through an environment proxy) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error `, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md`
- - `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers
- - `settings.events.ts` - App settings
- - `electron.events.ts` - App version, etc.
-
-**Workers** (`apps/electron-backend/src/app/workers/`):
-
-- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`
-- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back
-- Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`)
-
-### Xtream Category Management
-
-The Electron Live TV, Movies, and Series category dialog applies Select/Deselect
-to search results while a filter is active and to the whole type otherwise.
-Button states use the matching group; "Total selected" counts the whole catalog.
-Save persists the complete draft, Close discards it, and refresh restores hidden
-categories by provider ID and type. See `docs/architecture/category-management.md`.
-
-### Key Features
-
-#### Xtream Connection Test
-
-Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before
-the click and can replace an unavailable HTTPS base with a
-verified active HTTP base in the form. Only initial refused-port or TLS
-wrong-version evidence permits the same-host attempt; HTTP errors, certificate
-failures and redirect failures do not. Add/Save persists `serverUrl`, and the
-routed session observes the metadata change. Passive checks never change the
-protocol. Separate XMLTV and already-issued media/download URLs stay independent.
-Contract: `docs/architecture/xtream-portal-compatibility.md`
-("Explicit protocol discovery").
-
-#### Xtream Live Auto Format
-
-The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
-HLS+TS account evidence, using the canonical URL builder and original headers.
-The same web player may try TS once after an owned initial terminal HTTP failure,
-before `playing`; the old transport unmounts before the guarded render callback
-starts TS. No player preference or playlist cache changes. Manual formats,
-unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External
-MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
-a terminal diagnostic also need manual TS. Contract and full support matrix:
-`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
-
-#### Xtream Catch-Up Server Timezone
-
-The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS
-timezone (`server_info.timezone`), never the viewer's (issue #1562).
-`withPortal.checkPortalStatus()` normalizes it with
-`resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable
-name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock
-pair) and persists it on the playlist row through
-`IXtreamDataSource.rememberServerTimezone` — Electron: one conditional
-`json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's
-current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because
-the Favorites / Recent resolver reads the STORED row, not the store, and the
-worker interleaves requests, so no read may precede the write.
-`DB_GET_PLAYLIST` projects it back from the row payload, and a server URL
-change drops it until the next account-info check. The same value converts
-timestamp-less EPG
-`start`/`end` strings. Contract:
-`docs/architecture/xtream-portal-compatibility.md` ("Start time is the
-panel's clock, not the viewer's").
-
-#### M3U URL User-Agent
-
-- `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a
- route opened during refresh reads after its pending save. Mutation-internal
- reads keep using `getPlaylistById()` directly to avoid queue re-entry.
-- The URL import form accepts an optional User-Agent and stores it as
- `Playlist.userAgent`. Electron sends it on initial download, manual refresh,
- and startup auto-update. The self-hosted PWA sends it through the registered
- target `/parse` backend proxy for import and refresh; a matching backend is
- required, and browser playback-header restrictions still apply.
-- Reuse the existing source editor and channel-over-playlist playback header
- precedence. Contract: `docs/architecture/m3u-playlist-module.md`
- ("User-Agent for URL sources").
-
-**Playlist Support**:
-
-- M3U/M3U8 files (local or URL)
-- Xtream Codes API (`username`, `password`, `serverUrl`)
-- Stalker portal (`macAddress`, `url`)
-
-**Stalker playback links**: `create_link` runs only when the catalog row sets
-`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays
-directly. One helper decides
-(`resolveStalkerStaticPlaybackUrl` in
-`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied
-by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by
-`StreamResolverService` for Favorites/Recently Viewed. It falls back to
-`create_link` for anything it cannot resolve alone: no row to read flags from,
-a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,
-or a loopback host; an episode (`series` set) always mints, since the parameter
-selects the episode server-side. Temporary links live ~5 s, so no resolved URL
-is persisted or replayed — favorites and recently-viewed store the `cmd`,
-playback positions store ids, and the main-process context map stores headers
-keyed by origin+path. Downloads are the one exception (they must retry a URL).
-`forced_storage`/`play_token` are deliberately unwired. Contract:
-`docs/architecture/stalker-portal.md` ("Playback Link Resolution").
-
-**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed
-on the command line, opened through a file association, or delivered by macOS'
-`open-file` event is normalized to an absolute path in the main process
-(`services/playlist-open-request.ts`) and queued there. The renderer
-(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the
-`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is
-what makes the main process flush. `OPEN_FILE` is the only way out of the
-queue, and a request stays there until the renderer confirms receipt via
-`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the
-listener runs, and a reload or dead render process keeps the `WebContents`
-alive, so a successful push is not proof of delivery. Anything unacknowledged
-is replayed to the next renderer that announces itself. The renderer
-imports them on a single promise chain so a burst arrives in a deterministic
-order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)
-for the same reason: each action carries a different playlist, so a newer add
-must never cancel an older one's write, EPG fetch and navigation. The import
-itself reuses the normal file path
-(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,
-playlist-scoped EPG, and the navigation to the new playlist all behave exactly
-like a dialog import.
-
-The OS-level registration that makes those paths reachable is
-`fileAssociations` in `electron-builder.json` — one entry per extension, each
-with its own `mimeType`. Electron Builder derives all three platform
-registrations from it: macOS `CFBundleDocumentTypes` (which is what makes
-`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the
-desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
-deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading
-`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and
-must not be used; and it appends `%U` to `Exec`, so Linux file managers hand
-over percent-encoded `file://` URIs rather than paths —
-`createPlaylistOpenRequest` decodes them before the extension check. `%U` is
-also the _plural_ exec code, so a multi-file selection arrives as one launch
-with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all
-of them and `enqueueAll` queues the batch, because stopping at the first match
-would silently drop the rest of the selection. Adding an exec code to
-`linux.executableArgs` would suppress the `%U` but also pass that code to the
-app as a real argument, so it is not an option.
-
-**Video Players**:
-
-- The Embedded MPV native-view dock follows app theme tokens as a solid app
- surface, including Material icon-button disabled states. Over-video loading,
- stalled and feedback overlays keep a paired light-on-dark palette. Video
- viewports remain black in windowed and fullscreen modes. Shared EPG panels
- use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`.
- Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`.
-
-- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5
- player and ArtPlayer pick their source engine from one URL rule,
- `resolvePlaybackUrlSourceKind()` in `libs/playback/util` (`mpd` → Shaka,
- `m3u8`/`m3u` → hls.js, `ts`/`m2ts`/extension-less → mpegts.js, every other
- container → native ``), so hls.js never receives an `.mkv`/`.webm`
- file. The HTML5 native `` carries a `video/mp4` hint only for
- MP4-family files (`resolveNativeSourceMimeType`); a hint `canPlayType()`
- rejects would make the browser skip the source.
-- mpegts.js `1.8.1` errors from all three built-in players cross one
- version-locked structured evidence boundary in `libs/playback/util`. It
- retains only exact public type/detail pairs, pair-derived stage/failure,
- terminal disposition, and a
- validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach
- stored or rendered diagnostics. HTTP/network failures avoid false decoder
- recommendations, while exact format, codec, truncated-stream, and
- MediaSource failures retain actionable recovery guidance. This diagnostic
- layer remains separate from the shared `PlayerController` controls contract.
-- Browser playback diagnostics and recovery policy live in
- `libs/playback/util` and are exported by `@iptvnator/playback/util`.
- Public engine errors cross allowlisted sanitizers into a
- `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at
- most three actions, and `WebPlayerViewComponent` executes only the action
- the user selects. The policy is a sibling of `PlayerController`; shared
- controls only gate interaction while the diagnostic panel is visible.
- Technical details also expose localized stages, safe engine codec metadata
- and allowlisted source DRM names. DASH observes existing Shaka manifest
- responses (bounded to 2 MiB); it does not fetch again or retain license URLs,
- keys or XML. Evidence is scoped to one engine and never proves playability.
- The panel refines descriptions only from explicit runtime/engine evidence;
- HTTP 401/403 segment failures never imply token expiry. Copy diagnostics
- creates an allowlisted local report without URLs, credentials or raw messages;
- its content and copy status follow the current diagnostic.
- `WebPlayerViewComponent` owns a host-derived content-session key that is
- stable for the mounted logical selection, attempted target IDs, the temporary
- player override, and VOD handoff position. Its `PlaybackBinding` is exactly
- `{ generation, target }`, while every source/target/reload application uses a
- fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless
- intent `Symbol`, and source applications advance a third fieldless revision
- `Symbol` that clears only the VOD handoff position; target-only switches and
- Retry leave that revision stable. None of these ownership primitives contains
- URLs, headers, DRM material, or credentials. The application effect
- synchronizes the content session before tracking intent, so clearing a
- temporary player override cannot schedule a duplicate application or header
- handoff. Every application start clears both the diagnostic owner and backing
- signal before asynchronous header setup; a current false result or rejection
- leaves them clear, and a stale completion cannot erase a newer owned
- diagnostic. Each
- rendered web or Embedded MPV application captures its nullable binding, the
- application and source-revision tokens, and live/VOD flag; a time update
- changes resume state only while that exact capture still owns the current
- application. A recommended built-in
- player temporarily
- outranks the host override and saved player for that mounted content session,
- never mutates `Settings.player`, and resumes finite VOD position on a
- best-effort basis; live playback returns to the live edge. Retry and
- alternative sources preserve attempts, while a different content-session key
- or component teardown resets them. Recovery recommendations never
- auto-switch, persist history, learn across sessions, or emit telemetry.
- The policy projects attempted inline target IDs through the validated
- canonical source/target capabilities and excludes every attempted engine
- family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network
- and generic unknown evidence fail closed to Retry/alternative source; the
- exact Shaka browser-unsupported preflight marker is the sole unknown-code
- exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP
- DRM suppresses external targets because its payload is not transferable. Raw
- engine messages, arbitrary data, and credentials never enter recommendation
- evidence or ownership state. MPV/VLC actions remain mounted after an attempt
- and expose credential-free per-target launching/started/playing/error state;
- only an exact Electron `playing` update is labelled Playing. One handshake is
- allowed at a time. The renderer claims the credential-free content identity
- before awaiting Electron, so primary Play is disabled and a launching or
- closable-error alternative remains owned before the controller commits it.
- Every route action that can start the same external playback, including
- Restart and the provider-source shortcut, observes that local pre-IPC guard.
- The Xtream VOD diagnostic-fallback handler records the same route-scoped
- destination and pending generation before invoking MPV/VLC, so route reuse
- cannot orphan that process outside the next route's close-before-play path.
- Its fieldless intent is bound to the exact session returned
- by the source owner's launch promise, so a late timed-out attempt cannot take
- over a retry; later global updates must match that ID. A replacement waits for
- confirmed teardown of the tracked external process, applies the old exact
- close before launch, and cancels an unlaunched handoff if diagnostic ownership
- changes. Process teardown has bounded graceful and forced confirmation
- windows, and reusable MPV bounds the IPC command that precedes them; if any
- stage cannot reach a confirmed exit, the exact session stays live and the
- replacement fails closed instead of overlapping it. A process-wide teardown
- gate starts before any potentially slow teardown preparation, including VLC
- position flush and a reused player's protocol quit, and rejects every
- MPV/VLC spawn until that exact child reports exit. If bounded
- teardown fails while a fresh launch is still pending, that launch IPC rejects
- and the exact session remains a closable error instead of hanging forever.
- If a pre-content reuse failure has no still-live displaced session to restore,
- the replacement error keeps its attached closer so Stop can retry the orphaned
- child teardown. A terminal error without a closer is never restorable.
- A failed close is single-flight only while its promise is pending: Stop can
- retry the same exact child after a bounded confirmation failure. Reuse maps
- the child to its current content session, so a stale older closer becomes a
- no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.
- A duplicate close for an already closed session returns its terminal snapshot
- without re-entering the saved closer, and a late process error cannot revive
- that terminal session. Reused MPV commands are bound to the socket captured
- for that exact child, so a later process cannot inherit a stale protocol quit.
- Stop observed before a pending MPV content command or VLC enqueue command
- prevents that command from dispatching. A source handoff fails closed while
- a live session has no closer (`canClose: false`); renderer Dismiss is not
- teardown confirmation. That denied handoff advances neither the multi-source
- switch token nor the playback generation, so it cannot cancel the sole launch
- already in flight.
- VLC rechecks the gate at each concrete spawn after port allocation or reuse
- work; if a post-start fallback is blocked there, the opened session becomes
- an error rather than retaining a false started status. A failed RC-port
- allocation never claims reuse ownership, so the fallback VLC child retains
- its exact one-shot closer.
- Reuse failures before a content command restore the globally displaced
- renderer session, not the reusable process's prior owner, and only while the
- exact displaced-session ID is still active; after
- `loadfile`/VLC `clear` is dispatched,
- the replacement owns the process and remains a closable error instead of
- restoring stale content metadata. Stop during an in-flight MPV or VLC reuse
- command, including during failed-command teardown or the subsequent VLC
- fallback port-allocation wait, settles that exact close without falling
- through to a fresh spawn;
- a stopped VLC spawn error that reports only `close` also settles its original
- launch IPC with the exact closed session;
- a fresh fallback retires the old child's exit under its prior session so it
- cannot close the replacement. Source handoffs recheck ownership after launch
- and accept only `opened`/`playing`; a stale returned session is closed exactly
- and a Stop-returned `closed` session is never committed. If that exact stale
- close fails, its credential-free destination owner is retained for the next
- close attempt. Retained destination ownership is scoped to the initiating
- playlist/VOD route key, so route reuse cannot expose Stop for the previous
- movie's external session. Play/Resume capture that route key before awaiting
- close and cancel if navigation changes it; a late diagnostic fallback closes
- its exact returned session instead of adopting it on the new route. They
- supersede an older source resolution before awaiting the shared
- close-before-replacement path, and accepting a diagnostic fallback retires
- the same older resolution before opening MPV/VLC. They publish the route-source
- badge, caption evidence, and position only after start succeeds.
- Closable errors still participate in every replacement close and keep Stop as
- the global dock's only teardown affordance; Dismiss is reserved for terminal
- errors that have no closer. The shared `isLiveExternalPlayerSession` predicate
- keeps M3U and series ownership while
- such an error can still be stopped; consumers must not treat every `error`
- status as terminal.
- If the local handshake times out after an exact Electron session is known,
- that ID remains
- correlated so a later exact update can recover the UI. The global dock mirrors
- those statuses, keeps closable errors visible until Stop confirms teardown and
- terminal errors visible until dismissal, and intentionally has no retry because
- it does not own the original launch headers or credentials.
-- M3U Favorites and Recently Viewed resolve `Channel.drm` into
- `ResolvedPortalPlayback.drm` through `StreamResolverService`, with the same
- legacy raw KODIPROP fallback as the main M3U player. Both playlist and global
- collection scopes retain ClearKey playback and unsupported-DRM diagnostics.
- Collections also route M3U DASH inline through HTML5/Shaka (or ArtPlayer),
- regardless of the configured player, without changing the saved preference.
-- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded
- Shaka Player source engine inside the HTML5 and ArtPlayer components (no new
- player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`
- lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in
- `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all
- import paths; hex, Base64URL or ordinary Base64, strictly 128-bit key/KID;
- refresh replaces cached unsupported parser results). DASH channels always
- play inline: `isDashChannel()` bypasses
- the external-player setting (radio precedent) and routes Video.js/MPV/VLC/
- embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps
- ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,
- need the castLabs Electron fork) surface a DRM playback diagnostic instead
- of crashing. ClearKey EME works in stock Electron. Engine:
- `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.4`
- diagnostic boundary lives in `libs/playback/util`; it version-locks public
- severity/category/code evidence, ignores
- recoverable error events, treats rejected loads as terminal lifecycle
- outcomes, preserves exact public DASH text-parser category/code evidence with
- unknown stage/failure, and never retains or renders raw messages or
- `error.data`. A failed browser-support preflight stays generic-unknown but
- carries the exact app-owned
- `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed
- external fallback only for clear transferable DASH; PWA capability and
- KODIPROP DRM still suppress it. Details in
- `docs/architecture/m3u-playlist-module.md` ("DASH + ClearKey Playback").
-- Stream info popover: an `info` button in the top-right corner of the shared
- controls overlay shows live stream data — resolution + aspect ratio, measured
- frame rate, video/audio codec and bitrate, audio channels and sample rate,
- container, buffer, and dropped frames. Rendered only when the engine reports
- something (`capabilities.streamStats`), sampled once a second and only while
- the popover is open. Web engines read the `` element plus the active
- HLS/Shaka/VHS rendition; embedded MPV gets the numbers from observed mpv
- properties on the session snapshot (frame-copy engine only — the native-view
- dock does not mount the shared controls). See
- `docs/architecture/player-controls-contract.md` ("Stream info popover") and
- `docs/architecture/embedded-mpv-native.md` ("Stream Stats Properties").
- Web FPS excludes dropped frames and uses a fresh measurement window on open;
- nominal FPS and aggregate rendition bitrate have separate rows. Unknown
- video bitrate is never filled with aggregate bandwidth. MPV clears dimensions
- on a new file and clears individual diagnostics on unavailable-property events.
-- External players: MPV, VLC (via IPC to Electron backend)
-- Display sleep during playback: `PlaybackKeepAwakeService`
- (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every
- `` via document-level capture listeners (media events don't bubble;
- release listeners sit on the tracked element because Chromium's
- removed-from-DOM pause never reaches the document) and, while any video is
- playing and the document is visible (or the playing video is in
- picture-in-picture — the PiP surface survives a minimized window), holds a
- display-sleep lock: in
- Electron a main-process `powerSaveBlocker` behind
- `window.electron.setPlaybackKeepAwake`
- (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`;
- auto-cleared on renderer reload/crash), in the PWA the Screen Wake Lock
- API. Radio's `` deliberately never blocks display sleep. Embedded
- MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC
- inhibit the screensaver themselves.
-- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. Two per-session knobs are captured at session creation from the main-process settings mirror (`readEmbeddedMpvSessionOptions()`, read in the `EMBEDDED_MPV_CREATE_SESSION` handler — never inside the service, whose specs would otherwise construct electron-conf): `Settings.embeddedMpvExtraOptions` (free-form `key=value` libmpv lines, forbidden embed-critical keys refused by the form, network defaults `network-timeout=10` + ffmpeg `reconnect` prepended, applied on every engine after its built-ins — Windows/macOS via `mpv_set_option_string`, Linux through a user-only `--include` config file, frame-copy as the helper's first stdin line — never on a command line, where `ps` could read a credential-bearing header option) and `Settings.embeddedMpvAutoReconnect` (default on: `EmbeddedMpvReconnectCoordinator` in `embedded-mpv-reconnect.ts` reloads the last playback on `error`, or `ended` for live, only if it had played, with 2 s→30 s backoff, six attempts per outage, budget reset after 30 s stable playing, cancelled by user loads/pause/dispose; a recording running at the drop is finalized as an interrupted partial when the reload actually replaces the stream (a stream that recovers on its own keeps recording) and restarted into a new file once that reload plays, and an external subtitle file added through `sub-add` is re-added once the reload plays unless the user picked another track or loaded something else since; the renderer only displays `EmbeddedMpvSession.reconnect`). Contract: `docs/architecture/embedded-mpv-native.md` ("Session Options", "Network Auto-Reconnect"). macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (`embedded-mpv-bounds.util.ts`: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when `devicePixelRatio` changes and polls (500 ms, drift-gated) for position-only layout shifts that `ResizeObserver` cannot observe. Arrow-key and ±10 s button steps go through the relative `seekEmbeddedMpvBy` IPC (mpv `seek relative+exact`; addon export `seekBy`, helper stdin command `seek-by`, Linux JSON IPC), never an absolute target computed from the renderer's whole-second, 500 ms-polled `positionSeconds` — that stale base collapsed rapid presses onto one target (about 1 s of progress per press); only the timeline scrub commits an absolute `seek`. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.
-- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
- x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy
-engine` (restart required) or
- `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV
- experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,
- EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the
- preload frame pump uploads them to
- ``. Shared `app-player-controls` owns the DOM
- UI; native-view retains the legacy dock. On Linux, only
- `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the
- addon, and frame reader must not. Pristine afterPack/unpacked layouts scan
- Electron libraries recursively; extracted Snap payloads exclude only the
- package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.
- Every other directory remains recursive, and Electron-library symlinks still
- fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;
- `afterPack` alone owns the profile-normalized unpacked native tree, and
- package checks reject every archived `/electron-backend/native/**` entry.
- Packaged addon, frame-reader, and helper discovery uses only package-owned
- `app.asar.unpacked` paths; cwd/dist candidates remain development-only.
- Official x64 packages use three separate profiles:
- DEB/RPM/Pacman depend on system libmpv plus the helper's direct
- EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and
- Flatpak bundles the same closure. Flatpak is an isolated packaging pass and
- keeps `iptvnator` as the real Electron ELF so Electron Builder's
- `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the
- conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed
- Flatpak/non-Flatpak target sets fail before mutation. Exact system
- dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,
- RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and
- Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;
- Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.
- ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the
- fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;
- any failure keeps the sandbox enabled, records a stable reason, and falls
- back to native-view without crashing. Snap is `core22`/strict and uses an
- exact private `shared-memory` plug plus the `graphics-core22` content plug at
- a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the
- default provider. Its only provider-data layouts bind `/usr/share/libdrm`
- from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to
- `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable
- status after disconnect, then reconnects and requires success. Static
- artifact verification requires regular `desktop-init.sh`,
- `desktop-common.sh`, and `desktop-gnome-specific.sh` files at the Snap root,
- with `desktop-init.sh` executable. The helper links `libGL.so.1`, and
- probe/playback share a sanitized loader environment
- in which ambient audit, preload, library, graphics-driver, and shell-startup
- overrides are removed; the validated private closure plus trusted host GL,
- graphics-content, core22 base x64, and exact GNOME-platform roots have
- explicit precedence. The core22 base stays ahead of GNOME so the older
- `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The
- extracted-artifact verifier removes the identical unsafe loader/graphics/
- shell set before direct helper smoke while preserving selectors such as
- `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,
- removes exported `BASH_FUNC_*` functions, and
- launches probe/playback through the regular executable
- `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or
- disconnected provider returns `snap-graphics-provider-unavailable` before
- helper spawn. The packaging-only
- `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate
- before BrowserWindow startup and emits one availability JSON line. A nonzero
- helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is
- present only for an exact protocol-v1 line carrying a fixed allowlisted
- reason, and its optional `helperDetail` must be 1–1024 printable ASCII
- characters. Invalid detail suppresses both helper fields. Every probe uses
- an explicit 16 MiB aggregate captured-output ceiling independent of tracing.
- With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately
- as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an
- explicit `truncated` field; trace-write failure cannot change availability.
- Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded
- channel. The exact packaged Flatpak `/app` context reconstructs only
- Freedesktop Platform 24.08's immutable
- `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that
- application-level probe instead of the helper directly. The packaged x64
- Playwright smoke runs its fixture-contract target first and passes Chromium
- `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass
- the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux
- packages carry hash-validated
- `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.
- CI caches the staged runtime plus immutable source inputs, never finished
- notices or the compliance tarball; it regenerates those notices and the
- VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current
- checkout while preserving the exact pinned six recursive libplacebo
- submodule records. Each record is canonical `full-commit safe/path`;
- clone-depth dependent `git describe` annotations are discarded and never
- form part of the provenance identity. Its source index carries the globally sorted libplacebo
- directory/file/symlink inventory; file hashes, sizes, executable bits, link
- targets, aggregates, and canonical tree digest must match the trusted pinned
- checkout. The archive has an exact member/type layout and its
- `metadata/archive-sha256.txt` records must match the actual source archives.
- Concatenated tar/xz streams are inspected past every end marker. Every
- bundled x64 package manifest binds the final archive's SHA-256 and repository
- revision; system and marker-only packages do not carry that binding. Snap
- Store
- publication runs only from a public `v*` GitHub release that already
- contains the Snap assets and exactly one source archive. Before any upload,
- the workflow hashes and checks the archive's exact member/type set and size
- bounds, verifies its clean tag revision, pinned sources including the six
- recursive submodule records and exact libplacebo tree digest, legal payload,
- and exact released tooling, then performs bounded extraction and static
- validation for every Snap. That public-release boundary independently
- revalidates the exact strict `meta/snap.yaml` graphics/shared-memory
- contract and enumerates `resources/app.asar`, rejecting any archived
- `electron-backend/native/**` payload before publication. Its bounded ASAR
- header reader uses only Node built-ins and released local tooling, so the
- clean tag checkout does not require `node_modules`. Exactly one x64 Snap
- must have matching
- `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.
- Checkout and artifact-transfer actions are pinned to full commits; checkout
- does not persist credentials, and repository credentials are scoped to
- download steps. A secretless verification job copies assets through
- no-follow descriptors, checks them before and after inspection, writes an
- exact receipt, fully reverifies a root-owned read-only snapshot, and
- transfers only that data through the pinned artifact service while passing
- the receipt digest separately through a job output. The dependent publish
- job uses a bounded `ubuntu-latest` runner with no checkout or release-tag
- code, verifies that digest plus the exact receipt, asset hashes, and
- file-only layout, root-seals the data again, and installs Snapcraft directly.
- Its final fixed shell step alone receives the Store credential, resolves no
- PATH command, executes no released code, and exposes that credential only to
- each exact
- `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable
- promotion is manual after installed-Snap frame-copy and missing-runtime
- fallback smoke; GitHub Actions never promotes automatically. On Windows,
- package validation requires the exact MPV DLL named by the helper's PE import
- table beside the executable.
- Backend adapter:
- `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;
- shared-controls adapter:
- `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;
- helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime
- contracts: `docs/architecture/embedded-mpv-native.md` and
- `tools/embedded-mpv/README.md`.
-- Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file loading, a ±0.5 s timing-offset row, and size/color styling persisted in the shared `subtitleStyle` localStorage key. HTML5/ArtPlayer implement it through the neutral source bridge (`.srt`/`.vtt` via a DOM file picker with encoding detection, native `TextTrack` rendering, `::cue` styling, delay only while the loaded file is the selected track; picks are source-generation-guarded and engine deselection precedes external track activation); the canonical style shape and clamp/normalize rules are shared with the main process via `@iptvnator/shared/interfaces` (`subtitle-style.util.ts`). Embedded MPV frame-copy implements it through new helper protocol commands (`sub-add`/`sub-delay`/`sub-scale`/`sub-color`, main-process file dialog, ASS supported, delay for all tracks). Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no such capability and render no UI. Contract details: `docs/architecture/player-controls-contract.md` ("Advanced subtitle support"). Shared controls include a per-session quality menu (Auto + “1080p”-style levels via `setQualityLevel`; `AUTO_QUALITY_LEVEL_ID` restores ABR): the capability derives from the manifest — advertised only when the source exposes >1 video rendition (multi-variant HLS via hls.js `nextLevel`/`manualLevel`, DASH via Shaka variant tracks pinned to the active variant's exact audio stream (`audioId`, language fallback) with ABR toggled off for manual picks, Video.js via videojs-contrib-quality-levels) — so single-bitrate VOD and raw MPEG-TS never show it, nothing persists to Settings, and Embedded MPV/external players report the capability false. In fullscreen, `app-player-controls` shows a pointer-transparent media-title overlay at the top while controls are revealed (`mediaTitle` input: movie/channel/series name, plus an `S01E03` second line for episodes; series names flow from the detail views through `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`). Persisted `Settings.webPlayerSharedControls` is default-ON (absent stored values coerce with `!== false` in every normalization site; only an explicit false — the Settings > Playback checkbox — opts out to the legacy vendor chrome), and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. The shared surface has explicit touch semantics (`ControlsSurface.wasTouchInteraction`): viewport taps toggle overlay visibility instead of pausing, the volume popover opens on tap instead of hover, coarse pointers get a taller scrub strip, and at container widths ≤640px the bar reflows to two rows (full-width timeline above transport + an end-aligned, wrapping actions cluster with 40px buttons whose panels remain unclipped). Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked ``, so `ControlsSurface.wasPointerInteraction` attributes a `focusin` to a recent `pointerdown` inside the focused element and such focus reveals without blocking auto-hide (otherwise the fullscreen button left the controls on screen until a click-to-pause on the viewport); the press record is discarded on the first bar focus event or any `keydown`, a `pointerdown` inside the bar releases a keyboard pin, and a `keydown` bubbling out of a bar control re-pins it, since operating a focused control produces no focus event. A completed pointer click then releases the focus it left on the control (`onBarClick` → `ControlsSurface.releasePointerFocus`, attributed by `wasPointerClick`: non-empty click `pointerType`, else a recent press inside the clicked element): a focused control captures the keyboard — Space and Enter re-activated the clicked button and `ControlsShortcuts` yields to any interactive element in the key's path, so after a click on fullscreen Space left fullscreen instead of pausing. Keyboard activation (empty `pointerType`) keeps focus, only buttons and range sliders are released, Chromium keeps its sequential-focus starting point at the blurred control so Tab continues from it, and the volume popover ignores the release's `focusout` (`wasPointerFocusRelease`). Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer screenshot/AirPlay/web-fullscreen/mini-progress/vendor gestures — listed in the contract doc's "Known differences" section. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync — its owner is the `app-web-player-view` host (`WebPlayerViewComponent.fullscreenSurface`, passed to every engine as `fullscreenTarget`), not the engine shell, because the view remounts the engine component per playback application and the Fullscreen API exits when its element leaves the document; that is what keeps fullscreen across episode/channel/alternative-source switches (the vendor-chrome opt-out still loses it) — and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the shared controls' resolved fullscreen owner (the host-supplied `fullscreenTarget`, i.e. the `app-web-player-view` host, else the engine shell) so ranked recovery actions remain visible. The view also renders `app-fullscreen-channel-panel` beside the engine, staged on `fullscreenSurface` and withheld (`enabled=false`) for native-view Embedded MPV, which paints above the DOM (confirmed frame-copy support survives the unknown probe during a channel remount, preserving panel search/scroll; initial unknown and confirmed native/unsupported results withhold the panel): a host provides `FULLSCREEN_CHANNEL_PANEL` (`panelTemplate` + optional `panelTitle`, `panelSearchEnabled` and `panelKind: 'channels' | 'episodes'`; the template context carries `searchTerm`, `open` and `close`; gated on `Settings.fullscreenChannelPanel`, default on, offered only for web players with shared controls and for Embedded MPV; the M3U host returns null while its VOD detail hosts the player) and the panel slides that list over the video in fullscreen — left-edge hover dwell (the hot zone stays mounted above the scrim and below the panel through opening, so delayed paint cannot turn stationary hover into a synthetic leave), a touch tap on that edge, or `C`; nothing is drawn while it is closed and the pointer rests — mouse movement over the stage reveals a slim edge hint tab that fades after 2.5 s idle, a click or tap on the edge opens at once, and the hot zone stops above the controls bar; hover-away closes after 1 s only once the pointer has been inside the panel, so a `C`-opened panel survives the mouse roaming over the video; scrim/Escape/mouse-leave close it, while a CDK overlay opened from the list counts as the panel (hover keeps it open, Escape closes the overlay first); the header is one row (search whose placeholder carries the host title + close); the list stays mounted per fullscreen session. Providers: M3U `VideoPlayerComponent` (`app-m3u-fullscreen-channel-list`, a local icon-only all/groups/favorites/recent switcher over a second `ChannelListContainerComponent` in `compact` mode — no per-view title/sort/collapse headers, and the groups rail pinned to 148px without the sidebar's persisted width — with `resetActiveChannelOnDestroy=false`, and radio plus recognized movies filtered out of the list it is handed, since `app-audio-player` and the VOD detail shell each replace the fullscreen-owning `app-web-player-view`; every panel view resolves against that one list; with MPV/VLC configured only DASH rows remain, since other streams replace the inline host with the external-player UI), Xtream `LiveStreamLayoutComponent`, `StalkerLiveStreamLayoutComponent` (list markup is one `ng-template` stamped twice, `#scrollContainer` per copy; a blank panel field shows the category independently of sidebar search, or the windowed full cache when All Items playback has no selected category; the panel's search results are windowed by `PanelSearchWindow`, and on a paged portal the panel copy keeps requesting pages while its matches do not fill it, even while the sidebar's own search is active; closing the retained panel pauses paging, and reopening resumes automatic filling; inline video commits the selected channel with resolved playback, retaining the old selection, EPG and recording metadata during a pending or failed replacement), `UnifiedLiveTabComponent` (keeps the previous detail, and so the fullscreen player, mounted until the next selection resolves, with `activeItem` paired to that detail so the session key and recording metadata keep describing the stream on screen — only the `activeUid` row highlight moves ahead, a second activation of the row still resolving folds its start-playback or auto-open intent into that request instead of launching the retained stream, and a failed replacement retains the previous video, catch-up and session and restores its row highlight; Xtream's two `PortalChannelsListComponent` instances relay favorite toggles through `XtreamFavoriteMarksService`). CDK overlays follow the fullscreen element via `FullscreenOverlayContainer` in `app.config.ts`. Series playback gets the same panel as an **episode list**: `PortalInlinePlayerComponent` — the component both series hosts render around the view and that feeds the Up Next rail — is the nearest provider (`panelKind: 'episodes'`, no search field: a title row instead, and `C` focuses the panel) and stamps `app-fullscreen-episode-panel` (`libs/ui/playback/src/lib/fullscreen-episode-panel/`: `SeasonTabsComponent` on top, the selected season's rows with TMDB still or a numeral tile, `S01E03` label, runtime, clamped overview, progress bar, watched check and now-playing marker; the tab follows the playing season and the playing row is centred on open) built by `buildFullscreenEpisodePanelSeasons` from the hosts' `seriesEpisodes` / `episodePlaybackPositions` / `seasonLoadStates` inputs; an episode click travels `upNextEpisodeSelected` — the rail's path, so the engine remount keeps fullscreen — and closes the panel, a season tab click travels `episodePanelSeasonSelected` into the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD season load — a spinner row while in flight, a Retry row after a failed request, since the tabs never re-emit the selected key). Movies never get it (`contentType !== 'episode'` → null), external MPV/VLC never mount the inline player, and native-view Embedded MPV is withheld by the view as for channels. M3U also zaps with PageUp/PageDown, yielding to already-handled events and menu/dialog or scrollable-list targets. While the live web-player host owns fullscreen (itself, or through the nested surface a legacy player fullscreens under the vendor-chrome opt-out), numeric and adjacent-channel commands use the same eligible channel set as the panel, preserving original numeric positions and ignoring ineligible numbers; windowed commands keep the complete catalog. Contract section "Fullscreen channel panel" in `docs/architecture/player-controls-contract.md`. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation — but the playback keyboard shortcuts (Space/K, F, arrow seek/volume, M) still work: each vendor-chrome player attaches `LegacyPlayerShortcuts` (a wrapper over the same `ControlsShortcuts` arbitration/ignore rules) with engine-specific command wiring (`html-video-legacy-shortcuts.ts`, `vjs-legacy-shortcuts.ts`, `art-player-legacy-shortcuts.ts`); seek is gated on authoritative `isLive` plus a finite positive duration, `interactionEnabled` (visible playback diagnostic) disables the keys, and the legacy ArtPlayer chrome passes `hotkey: false` because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would double-handle every key (its lost Escape-exits-`fullscreenWeb` behavior is restored by the wiring). The legacy Video.js chrome also releases the focus a pointer interaction leaves on a control (`vjs-pointer-focus-release.ts`, sharing `pointer-focus-release.ts`'s `blurFocusedControl` with `ControlsSurface`): a focused Video.js component stops every key before the document and turns Space/Enter into a click, so after a click on fullscreen Space left fullscreen instead of pausing. It is driven mainly by `focusin`, not the click, because choosing a menu item moves focus to the menu button a tick later (`MenuItem.handleTapClick`) and that selection click never bubbles to the shell: an eligible control (button/`role=button`/slider, never a `role=menuitem*`) is released when its focus is attributable to a recent shell `pointerdown` not yet ended by a document `keydown`, so keyboard `Tab` focus is kept — plus a `click` runs the same release for a control clicked while already focused (Tab then a mouse click, which fires no `focusin`). The release is scoped to `.vjs-control-bar` so the caption-settings dialog (a modal sibling of the bar) keeps its focus trap; menu buttons live in the bar and are not exempt (a popup is navigated through its focused item, so releasing the button never disturbs an open menu, and the button focus a pointer moves through on open, item selection, or toggling an open menu shut is released so Space works after the menu closes) — ArtPlayer (non-focusable divs) and the native HTML5 controls (focus lands on the ``) need no counterpart. `Settings.showCaptions` is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (`WebVideoSourceTracks` for HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via `setSubtitleTrack`), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports `playing`, so the engine's own caption menu keeps working. Mode selection is the optional `playbackStarted` probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (`subtitleTrack = -1`) rather than hiding, since `subtitleDisplay` would override the vendor menu, and DASH is seeded by `ShakaVideoSession.start()` after the manifest loads. `WebPlayerViewComponent` reads it from `SettingsStore` instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: `docs/architecture/player-controls-contract.md`.
-- Shared web picture-in-picture stays inside that default-on rollout.
- `PlayerController` exposes capability `pictureInPicture`, state
- `pictureInPictureActive`/`canPictureInPicture`, and command
- `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard
- element PiP from the adapter's attached video; shared ArtPlayer keeps vendor
- `pip: false`, while preference-off native/vendor controls keep their own UI. The
- capability-gated button sits before fullscreen and uses active enter/exit
- semantics; entry is disabled until metadata, and the action is disabled while
- an operation is pending. Embedded MPV reports capability/state false with a
- no-op command and has no popup/mini-window.
-- `WebVideoControlsAdapter` supplies its current video and binding generation to
- `WebVideoPictureInPictureController`; the controller reads the video's
- `ownerDocument`, while browser enter/leave events remain authoritative.
- Exact-owner exit stays available if request support changes. Request/exit
- invocation remains synchronous for user activation, one operation is
- serialized, and binding generation plus exact video identity protects
- replacement and teardown from stale completion. Video.js Tech reset and
- ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a
- retained target preserve PiP. Legacy HTML5/ArtPlayer teardown and Video.js
- Tech replacement also release exact-owned PiP through
- `web-video-picture-in-picture-lifecycle.ts`, independent of the controls
- preference. A one-shot listener on the retired video closes late native/vendor
- entries without retaining the host or touching another video's PiP. Legacy
- WebKit presentation-mode PiP also returns the retired video to inline; its
- presentation-change listener ignores fullscreen/inline events until a late
- PiP entry consumes it.
- Standard PiP shows the browser/OS video surface without Angular control
- chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP
- keyboard shortcut, and Embedded MPV popup/native support are out of scope.
-
-**Download Manager**:
-
-- Fresh Xtream movie and series-episode downloads propagate the playlist's
- User-Agent, Referer, and Origin, defaulting User-Agent to the same
- provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and
- stream probes. Retry, resume, and missing-file
- recovery also add the fallback to legacy Xtream rows that have no stored
- User-Agent. Because download rows survive source deletion, a headerless
- legacy row whose playlist is already absent receives the same IPTV-player
- fallback; a known Stalker row remains unchanged. Allowlisted connection
- resets after bytes reach disk retain the partial and show a credential-safe
- `DOWNLOAD_NETWORK_INTERRUPTED` code. Retry resumes with Range/If-Range when
- the response supplied a strong ETag or Last-Modified validator; without one
- the Range request rewinds by a 256 KiB overlap window whose bytes must match
- the partial's tail before anything is appended (`download-overlap.ts`); a
- smaller partial is verified in full from byte zero and appended, never
- rewritten in place; reported progress is floored at the retained size while
- appending; and a mismatch truncates the partial and restarts from byte zero
- instead of risking mixed-representation corruption. The runtime also
- reconnects interrupted transfers automatically (`download-reconnect.ts`):
- reconnects continue while attempts end ≥64 KiB past the previous attempt;
- restarts are an explicit `task.transferRestarts` signal (never byte
- inference) that opens a fresh progress epoch, at most twice per transfer;
- three consecutive stalled attempts surface the retained failure; and a
- reconnect that fails before any response is converted into the same
- retained interruption so it can never delete the partial. Only the response's own
- total authorizes completion — an indeterminate `bytes X-Y/*` range stays
- incomplete even at a clean EOF; carried totals are informational and
- dropped when falsified; and any retainable network failure retains any
- nonempty partial (no evidence required), persisting a falsified total as
- unknown.
-- The desktop-only manager shares one global download store across the global,
- Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series
- cards use the global Small/Medium/Large cover-grid tokens; missing completed
- files move to Needs attention instead of remaining in Ready to watch.
-- Series details route individual and selected-season episode downloads through
- the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode
- pending identities synchronously, submits season candidates sequentially and
- best-effort through the existing `DOWNLOADS_START` path, performs one final
- authoritative refresh after added or stable duplicate submissions, and
- reports added, skipped, and failed counts. Xtream and Stalker adapters remain
- responsible for provider URLs, headers, and metadata; the backend still runs
- one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole
- start IPC. A reserved completed-missing match triggers one authoritative
- preflight refresh before provider preparation. Download-list loads are
- serialized as one active IPC plus one coalesced trailing refresh; a preflight
- assigned to that trailing refresh cannot be starved by later progress
- broadcasts. A restored Stalker file can therefore become a stable skip
- without a portal request. The IPC's stable
- `reason: 'already-in-progress'` and `reason: 'already-downloaded'`
- results are counted as skipped, and no batch IPC is introduced. The latter
- comes from an asynchronous main-process filesystem recheck before a
- completed-missing row can be reset, so a file restored after the renderer
- snapshot is not orphaned or downloaded again. The recheck has a one-second
- caller deadline that starts before shared-slot acquisition; timeout or probe
- failure leaves the row untouched and reports a failed submission so the
- season loop can continue. Completed-file list callers use the same deadline
- and report a timeout as missing for that snapshot. The underlying filesystem
- operation remains coalesced and charged against the four-probe cap until it
- settles, so later callers have independent bounded waits without duplicating
- stalled native work. Only `ENOENT` and `ENOTDIR` prove absence; permission,
- I/O, and other filesystem errors remain unknown and cannot clear a completed
- row. Before a completed-missing, failed, or canceled row clears its retained
- path, the start IPC asynchronously removes any `.part` through a separate,
- same-path-coalesced, four-operation cap. A one-second admission deadline
- rejects queued work before unlink starts; started work is awaited so it cannot
- mutate after a failure response. Non-absence errors keep the row's ownership
- intact; `ENOENT` and `ENOTDIR` safely proceed. Episode and season download
- actions require an authoritative global list. A
- successful snapshot remains authoritative while a later background refresh
- is in flight; a latest refresh failure leaves
- loading/empty-state resolution intact but disables starts until another
- snapshot succeeds. Overlapping download-list callers join one serialized
- trailing refresh, so responses commit in request order and frequent progress
- events cannot perpetually postpone a waiting series action.
-- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`
- for both providers; Stalker playback identifiers only resolve the URL. Exact
- `(playlistId, contentType, xtreamId)` matches are authoritative, while
- complete playlist/series/season/episode coordinates are a fail-closed legacy
- fallback that migrates reusable rows to the canonical id. Numeric season
- zero, including fallback key `"0"`, remains a valid Specials coordinate for
- both providers. Stalker persists
- `episode_identity_scope` separately for regular `/series`, embedded VOD
- `series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not
- match; a pre-scope coordinate row is ambiguous and blocked, while an exact
- canonical legacy row remains authoritative. Renderer lookup preserves that
- ambiguity or conflicting ownership as a distinct ineligible state, so
- neither the episode action nor the season count treats it as a row-less
- download. SQLite `null` and optional `undefined` coordinates both mean an
- incomplete canonical legacy row, matching the backend resolver. Pending and
- active rows plus completed available/unknown rows are skipped; failed,
- canceled, completed-missing, and unambiguous row-less episodes remain
- eligible.
-- Ready cards (movies, grouped series, and standalone episodes) open a focused
- local detail; local file actions (Play, Show in folder, Copy URL, Remove)
- live in the poster's overflow menu. Movies play the finalized local file;
- series list only locally available episode rows and every episode action
- targets its own downloaded file. Focused routes disable route search and use
- `contextPanel: 'none'`.
-- Downloads capture a versioned metadata snapshot from the rendered Xtream or
- Stalker movie/episode detail at start time, including already-merged TMDB
- fields. Legacy, sparse, stale, or wrong-language snapshots are safely
- backfilled from row/provider metadata and optional TMDB enrichment when the
- focused detail opens.
-- `View in portal` resolves a concrete Xtream category/item route. Stalker
- accepts a recently-viewed shape only when its raw movie/series mode matches
- the download, and prefers an exact numeric category from the download
- snapshot. Without that shape, only a movie carrying an exact category can
- form a metadata-only target; unproven episode and legacy-movie handoffs stay
- unavailable. The normal detail uses one-shot `provider-only` presentation:
- it exposes provider content/playback it can resolve while hiding
- Offline/local/download actions. A second, independent `View in portal`
- bridge exists for inline collection details — see **Collection Detail
- Portal Handoff** below; it deliberately does NOT use `provider-only`.
-- Download rows and local files survive source deletion. The global offline
- library remains visible with no playlists; only provider handoff is disabled
- until the source exists again.
-- If a finalized file disappears while a focused detail is open, the
- authoritative download list refreshes and returns to the manager. A failed
- redirect leaves an actionable missing-file state with Back and Retry.
-- Live-TV recordings (Embedded MPV `stream-record`) are tracked beside
- downloads in their own `recordings` table (no unique index, no playlist FK —
- recordings survive source deletion with a `playlistDisplayLabel` name
- snapshot). `EmbeddedMpvRecordingTracker` persists the lifecycle: start/stop
- hooks in `EmbeddedMpvNativeService` plus a session-snapshot observer. The
- stop hook only REQUESTS a stop (`addon.stopRecording()` dispatches
- asynchronously), so finalization waits for the snapshot reporting the
- recording inactive — bounded at 10s — and only a recording that never went
- active has its empty reservation unlinked; mpv's bytes are never deleted.
- The same observer covers the implicit stops (stream-replacement auto-stop,
- helper crash, session error/close). Rows carry `owner_pid`, so startup
- repair turns a hard kill's leftovers into playable `interrupted` partials or
- `failed` while skipping rows another live instance owns. Channel/EPG metadata is
- snapshotted at recording START (each live host passes
- `RecordingStartMetadata` down through the player chain; provider EPG never
- reaches SQLite so post-hoc lookup is impossible). `EmbeddedMpvPlayerComponent`
- owns the active→inactive recording edge and emits `recordingStopped` for
- every trigger (including the manager's Stop, which bypasses the player's own
- toggle); the host answers with enrichment: programs overlapping the recorded
- window, keyed by target path (`RECORDINGS_UPDATE_PROGRAMS`, which awaits only
- the tracker's write queue and then matches the newest row for that path in
- ANY status — `finalize()` never touches `programs_json`, so the two writes
- are order-independent and no deadline can drop the programs) — that covers
- recordings spanning a program boundary. Own `RECORDINGS_*` IPC + `RECORDINGS_UPDATE_EVENT` ping
- and a separate `supportsRecordings` capability gate (never folded into the
- all-or-nothing `supportsDownloads` allowlist). Manager UI: `recording`
- filter chip, "Recording now" queue section (REC pulse + elapsed, live size,
- Stop, no percentage), 16:9 channel-logo "Recordings" library cards, Needs
- attention with Remove only (a broadcast cannot be re-recorded), focused
- detail at `/workspace/downloads/recording/:recordingId` listing covered
- programs. Reveal/play shell IPCs are gated on the recordings table
- (`isManagedRecordingFile`).
-- Canonical contract: `docs/architecture/download-manager.md`; provider handoff:
- `docs/architecture/portal-detail-navigation.md`.
-
-**Collection Detail Portal Handoff** (`View in portal` for inline details):
-
-- Details opened outside portal category context — `/workspace/global-favorites`,
- `/workspace/global-recent` (which also receive the dashboard hero, Continue
- Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent`
- tabs — render full-width with no category sidebar. They expose a separate-row
- hero action that jumps to the item inside its owning portal.
-- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action`
- (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host
- provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are
- `XtreamCollectionDetailComponent` (through its dynamic detail injector) and
- `StalkerCollectionDetailComponent` (component providers), which exist only in
- collection contexts — so router-mounted category details need no opt-out. When
- hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim
- a phantom row in the hero action container.
-- Targets come from `getUnifiedCollectionDetailNavigation()`
- (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike
- `getUnifiedCollectionNavigation` it NEVER degrades to a category- or
- section-only route: an Xtream item without a resolvable category and positive
- item id keeps the action hidden rather than promising a jump to the title and
- landing in a list.
-- Stalker section resolution mirrors `resolveStalkerCollectionDetailMode()`
- (`libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`) and
- must not be
- simplified to `item.contentType`: `extractStalkerItemType()` reports `series`
- for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but
- both belong in the VOD catalog — the lazy season/episode fetch in
- `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so
- a `/series` route leaves the detail unable to load episodes. The virtual
- `series` category is normalized to `vod` the same way
- `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries
- `stalkerReturnTo` plus
- `stalkerReturnByHistory`, and the portal detail's back affordance
- (`StalkerCatalogDetailComponent.onVodBack()`,
- `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back
- one history entry instead of calling `navigateByUrl()`. The collection's
- active tab, scope and open inline detail live only in `window.history.state`
- (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would
- reopen it on the default `live` tab and leave the portal page one browser
- Back away. The marker carries the handed-off item's identity, not a bare
- `true`: `openStalkerItem` is consumed on arrival while the return keys stay
- on the entry, and a Stalker detail opens in place without pushing one — so
- after Back + browser Forward the same entry can host a different title, whose
- back affordance must just close it. A stale marker suppresses the whole
- return contract, and honouring it retires both keys from the entry so a
- browser Forward cannot replay them for a reopened title. Leaving with the
- browser's own Back runs no affordance, so `CategoryContentViewComponent`
- also retires the contract whenever it lands on the entry with no handoff
- item and no open detail. That retirement is gated on the marker, so a plain
- `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is
- restricted to what `buildStalkerSelectedVodItem()` preserves (`id ??
-stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- resolved id onto the handoff state item when the raw row carries neither —
- those rows then get the same history return instead of degrading to a
- re-navigation that resets the collection's tab.
- Only this builder sets the marker, so the
- dashboard handoff and any other `stalkerReturnTo` caller keeps
- re-navigating.
-- Unlike the download handoff this bridge does NOT pass
- `detailPresentation: 'provider-only'` — the item exists in the provider
- catalog, so the full normal detail (downloads included) is wanted.
-- Contract: `docs/architecture/portal-detail-navigation.md`.
-
-**VOD/Series Detail Pages (two-state layout)**:
-
-- Xtream and Stalker detail pages use the shared `PortalDetailShellComponent` (`libs/ui/components/src/lib/portal-detail-shell/`) with two states: **Browse** (hero with poster/metadata/actions, episodes below) and **Watch** (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
-- The inline player (`PortalInlinePlayerComponent`) renders a full-width **theater stage** (`.player-shell__viewport`): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in `playerAmbientMode` setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
-- For inline **series** playback on wide windows the stage instead docks the player left and shows an **"Up Next" episode rail** in the leftover column (`app-up-next-rail` in `libs/ui/playback/src/lib/portal-inline-player/`): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the `playerUpNextRail` setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill. In fullscreen the same host offers the whole series as the slide-in episode panel (see the fullscreen channel panel paragraph in Video Players)
-- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and the now-playing bar's "Close player" exit to browse without navigation; the shell's sticky back arrow is route-level back in both states (straight to the list via the host's `goBack()`), so the bar carries no second arrow
-- Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse `get_vod_info` data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from `movie_data` or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider `xtream_id` carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through `info`, `movie_data`, then catalog fields.
-- A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to `Play episode N`; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
-- Stalker preserves this contract for regular `/series`, embedded VOD `series[]`, and lazy Ministra VOD `is_series` items; `is_series` is normalized only from `true`, `1`, or `'1'`. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Single-season title markers correct both displayed and playback season coordinates; lazy VOD retains the original provider season key/number for stable IDs and old progress. Lazy VOD episode tracking IDs scope the parent series, provider episode, original season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match the episode and either its resolved or retained original provider season. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. Season resources ignore metadata-only selection patches, and episode responses belong to the exact loading VM so navigation cannot mix episode lists.
-- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block
-- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons; the dropdown's menu rows and closed trigger carry a 28×42 season thumbnail from the same `seasonPosters` map when that season has one — no placeholder when it does not, a failed image is dropped — while the pill row deliberately stays text-only) with auto-selection (playing episode's season → resume season → earliest season with unwatched episodes → latest non-empty season; Stalker lazy-VOD series with unhydrated seasons fall back to the first season, and a session's own watched-toggle echo never re-resolves the selection) that fires the same `seasonSelected` lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from `get_series_info` (Xtream, provider-first with URL-only junk filtered by `sanitizeProviderOverview` and a TMDB season-overview fallback stored as `tmdb_season_overviews` by the lazy season enrichment) or TMDB (Stalker). The tabs sit in a season card with the selected season's own **season cover** on the left (`SeasonContainerComponent.seasonPosters`, keyed like the descriptions; TMDB-first: `tmdb_season_posters` written by the same lazy season enrichment as a `w342` `tmdbSeasonPosterUrl`, then Xtream's provider `seasons[].cover_big`/`cover` when it is a trimmed http(s) URL other than the show poster — `buildSeasonPosters` in `serial-details/season-posters.util.ts`; Stalker is TMDB-only via `StalkerSeriesTmdbSeasonsService.posters()`). Sized by `--season-cover-width`; the column is not rendered for one-season items, seasons without a poster, or a failed image, so those cases are today's markup. The hero poster never follows the season. The fullscreen episode panel shows the same poster as a season strip (poster + name + episode count, `PORTALS.EPISODE_COUNT_ONE/OTHER`) above its tabs, fed through `PortalInlinePlayerComponent.seasonPosters` and `FullscreenEpisodePanelSeason.posterUrl`, under the same gates, and hands the same map to its tabs' dropdown
-- The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes full-progress position rows for the unwatched episodes only — skipping the episode currently playing/launching, whose position ticks would overwrite the row — and a fully watched season flips the action to unwatch-all (`buildSeasonWatchToggleRequest` in `libs/ui/components/.../season-watch-toggle.util.ts`). Xtream persists via the batch IPC `DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH` (one SQLite transaction; the PWA data source rewrites its localStorage blob once) and refreshes `XtreamStore.loadAllPositions` after any toggle so catalog progress badges follow; Stalker loops the serialized position-mutation queue (legacy-row reconciliation, one coalesced reload) and reports direction-specific partial failures. A batch resolving after navigation neither mutates the new page's state nor shows its snackbar. A series-level counterpart sits in a `⋮` menu at the end of the header row (`SeasonWatchPresenter` owns both scopes' state math; `buildSeriesWatchToggleRequest` flattens every loaded season; the direction is always the one the label advertised). It reuses the same host machinery per portal (Xtream: scope-parameterized `SerialDetailsSeasonWatchService`; Stalker: shared `runWatchToggleBatch` core). Stalker lazy-VOD hydrates unloaded seasons sequentially first (abort with zero writes on a failed fetch; a well-formed EMPTY portal answer marks the season loaded-and-empty via `VodSeriesSeasonVm.episodesLoaded` rather than eternally pending, while `fetchVodSeriesEpisodes` rejects malformed envelopes and answers without recognizable episodes; `loadEpisodesForSeason` is single-flight per season so concurrent callers join one request), re-runs the position reconcile synchronously so hydrated episodes' legacy rows are cleaned, then rebuilds the request keeping the clicked direction — the `hasUnloadedSeasons` container input blocks the unwatch verdict and the count label until everything is loaded. Contract: `docs/architecture/embedded-inline-playback.md`
-- Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite, `VodDetailsWatchedService`; Stalker: labelled button in the shared `app-vod-details`, both the routed catalog detail and the collection inline detail wire it). Both portals go through one helper, `createVodWatchedToggle` in `@iptvnator/portal/shared/util`: marking writes a full-progress `vod` position row (stored duration → provider `duration_secs` → 1 s fallback, since Stalker states no runtime), unmarking deletes the row and so forgets the resume point, both through the rejecting `*OrThrow` persistence boundary so the row on screen changes only after a confirmed write. The toggle is disabled while the movie plays inline or in an external session (the ~15 s position tick would overwrite the row), acts only on the route copy's row (a pinned multi-source alternative keeps its own), and a completion landing after navigation refreshes the catalog badges but neither patches the new page nor shows its snackbar. A watched copy shows Play, never "Resume" from its final seconds. Catalog cards on both portals derive their corner badge from one shared `PortalWatchState` (`resolvePortalWatchState` / `watchStateFromProgressPercent`, 90% threshold; `resolvePortalSeriesWatchState` reports a series as at most `in-progress`, because the list payload never carries the episode total).
-- The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for a series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Xtream consumes it in `SerialDetailsPlaybackService` (`XTREAM_SERIES_RESUME_TARGET`); Stalker in `StalkerSeriesViewComponent` (`STALKER_SERIES_RESUME_TARGET`, provided by `StalkerCollectionDetailComponent`; a lazy Ministra season the target lives in is hydrated first, and an episode matched by coordinates rather than tracking id still resumes at the row's offset). "Series" here is the item's WATCH kind, not its routing type. The shape that forces the distinction is a Stalker embedded-VOD row: its stored entry announces episodes through a `series[]` array but carries no `is_series` flag, and `extractStalkerItemType` is deliberately blind to that array (the item must route to the VOD catalog), so it reports `type: 'movie'` while its progress is a set of episode rows. The mappers set `watch_kind: 'series'` on it and on a lazy Ministra `is_series` row (which `extractStalkerItemType` already types `series`, the flag being read) through `isStalkerSeriesItem`, and every dashboard reader — position lookup, S·E badge, resume/mark-watched actions — goes through `resolvePortalActivityWatchKind` (`libs/shared/interfaces`). Hero and card subtitles no longer name the provider kind or content kind: the hero shows only the source name (`playlistDisplayLabel`, since stored names carry URLs/MACs), Continue Watching cards show the S·E chip plus "N min left", favorites cards show the title alone, and Recently Added keeps the source name. Continue Watching cards' DEFAULT click is detail-only (movie-like, issue #1441), and their ⋮ menu (`buildDashboardContinueWatchingActions`) also offers "Mark as Watched" (maxes out the existing position row via `DashboardDataService.markRecentItemWatched`) and "Remove from history". Ordinary global-recent grid clicks remain detail-only.
-- See `docs/architecture/embedded-inline-playback.md` ("Two-State Detail Layout")
-
-**VOD Multi-Source** (alternative sources for a movie):
-
-- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 660px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). A row's language is `vodSourceLanguage` (`libs/shared/interfaces/src/lib/vod-source-language.util.ts`): the title's own prefix (pipe incl. Unicode lookalikes, bracketed, or ALL-CAPS spaced-dash form; Latin/Cyrillic 2–4 letters + `MULTI`; only the legacy pipe form is permissive — bracket/dash matches must also pass `isKnownLanguageTag`, since those positions carry quality/rip tags like `[HD]`) wins, else the language the stream's visible categories unambiguously carry ("EN | Netflix" — discovery returns all category names — the FTS tier joins them with `group_concat(cat.name, char(31))` under the GROUP BY it already needs, the scan tier must NOT group (per-category uniqueness means sibling rows can carry different titles and grouping would drop a matching one) and its names merge in TypeScript, prefixed categories must agree, and category prefixes must pass `isKnownLanguageTag`, since `new`/`top`/`hot` are real ISO 639-3 codes but everyday category words; the route's own row reads the one category the route arrived through, overlaid late by the host's same-key `refreshRouteFacts` since cold/direct routes load categories after discovery). Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. Recognition alone is not enough — `normalizeTitleKeys` must STRIP the same tag or the copy is never discovered, so its leading-tag rule shares `PROVIDER_PIPE_CLASS` and drops the required space after a pipe. It goes no further on purpose: a wrong guess costs a filter option, a wrong strip corrupts identity, and on 1.27M real titles a case-insensitive/Cyrillic pipe rule corrupts 349 keys ("Akira | 1988", "Момо | Momo" — the name sits in the tag position) while `–`/`—` on the dash branch amputates 14 subtitled titles. The one shape that cannot decide itself is a strip leaving NO real word behind — decided by running the rest of the pipeline on the stripped form rather than re-implementing what later stages drop, since quality tags, trailing tags, underscore tags, double-dash suffixes and season markers each otherwise smuggle the strip through ("|TA| RRR - HEVC" → empty key, "IF - 2024_sub" → bare year "2024") — "IT - 65 (2023)" is the film "65" tagged Italian, "AKA - 2023" is the film "AKA" and its year — so there the leading token must be in `TRAILING_TAG_VOCABULARY` or the prefix-only list (`NF`, `EX`, `NRC`, `AMZ`, `D+`, `P+`, `OSN`, `VO`, …; a compound is read by its HEAD, so `4K-*` works and the film names "INU-OH"/"PC-4L" do not), and an unknown token keeps its title: a refused strip costs one unmatched copy, a wrong one produced a bare-year key that collapsed AKA/BDE/BRO/OUT/WIL/IF onto `"2023"`. Every vocabulary entry is one the catalog proves prefixes hundreds of ordinary titles — never one that merely looks like a provider ("MAX - 2015" is a film). Verify such widenings against the real catalog before shipping them, over movies AND series: a movie-only derivation missed `AMZ`/`D+`/`P+` and broke the numeric series 1923, 1883, 24 and 9-1-1. Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover).
-- Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing.
-- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch.
-- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an _exact_ match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
-- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read _before_ the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
-- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under **every** key in `write` (`setVodSourcePin(db, pin, retireKeys, aliasKeys)`: one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched `tmdb:` key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin **id** rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
-- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`.
-- Pins are included in playlist backup as the optional `sourcePins` collection, carried under the playlist they point at; `matchKey` survives untouched and only the playlist id is remapped on restore (older archives simply lack the field).
-- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state a spoken **language** as fact — `audioLanguage`, never `audio`. The latter holds the codec whenever the fact came from the API, and a codec cannot answer that question: AAC and AC3 routinely carry the same dub while two AC3 tracks can carry different ones, so comparing codecs fired on identical-language re-encodes and stayed silent on real dub changes. Few panels tag a language, so the warning is usually silent — which is the honest state.
-- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
-- See `docs/architecture/vod-multi-source.md`
-
-**Radio Player**:
-
-- Dedicated audio player for channels with `radio="true"` M3U attribute
-- Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls
-- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio
-- EPG panel is hidden for radio channels (radio streams have no EPG data)
-- Volume synced with video player via shared `localStorage` key `'volume'`
-- Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)
-- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`
-
-**EPG (Electronic Program Guide)**:
-
-- XMLTV format support, from `http(s)` links or local files (Electron only): a `file:` URL, an absolute POSIX path, or a Windows drive/UNC path, plain `.xml` or gzip (detected by signature). A folder button beside each row opens the native picker (`EPG_OPEN_FILE_DIALOG`, `RuntimeCapabilitiesService.supportsEpgFilePicker`). Shape rules: `classifyEpgSourceReference` in `libs/shared/interfaces`; the worker opens both kinds through `openEpgSourceStream` (`workers/epg-source-stream.ts`). Only hand-chosen sources may be local: `extractM3uEpgUrls` harvests only remote links from M3U headers (legacy stored non-remote entries are dropped unless manual), and `EpgWorkerService.startFetch` asks the main-process `EpgLocalSourceAuthorizer` before a local path reaches the worker — picker results are trusted, a typed path is confirmed once in a native message box, allowed paths persist under `TRUSTED_LOCAL_EPG_SOURCES`, and the worker's local branch requires the main-set `allowLocalFile` flag (deny-all until wired). Contract: `docs/architecture/m3u-playlist-module.md` ("Local XMLTV files")
-- Background parsing in worker thread; HTTP/file gzip compatibility follows `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression").
-- Stored in database for quick lookup
-- Source scope: batch "now" lookups search the playlist's own XMLTV first, then the Settings-managed global sources, and stop there; only `EpgLookupOptions.anySourceFallback` (renderer-only) retries the still-unresolved keys against every imported source. The dashboard live rails pass it, so a favourite whose guide lives in another playlist's XMLTV gets the same programme its "See all" row already showed; they still issue one lookup per distinct playlist source scope and namespace the answers by it (`liveEpgProgramKey`), since a `tvg-id` is unique inside a guide but not across imports, and only cards carrying a real XMLTV key are widened — a portal card's key is just its title (wiring: `DashboardLiveEpgPresenter`). The channel list keeps the strict scope. Contract: `docs/architecture/m3u-playlist-module.md` (the "Scoped lookups" bullet under playlist-scoped URLs)
-- Dashboard live rails, portal side: an Xtream or Stalker card carries no XMLTV key, so its programme comes from the portal instead, lazily and per card — `lib-dashboard-rail` reports the cards inside its viewport (`visibleCardsChanged`, `IntersectionObserver` on the track), `DashboardLiveEpgPresenter` forwards them plus the pinned hero to `DashboardPortalLiveEpgPresenter`, and `DashboardPortalLiveEpgService` (data-access, root) runs the bounded queue (2 in flight / 200 ms, 60 s TTL for a programme, 30 s for an empty answer) through `StreamResolverService.loadEpgForItems`, publishing each answer as it lands. A completion captures the display offset AND `EpgSourceSettingsService.revision()` and requeues itself when either moved; the presenter hands its wanted set back on destroy; desktop only, since the resolver is gated on `supportsProgramLookup`. The one shared facade the rails talk to is `DashboardLiveEpgPresenter`: a portal answer wins, an XMLTV title match is the fallback, and a shimmer placeholder shows only before a card's first portal answer. Contract: `docs/architecture/workspace-dashboard.md` (Data Flow, item 3)
-- Global display-time offset (`Settings.epgOffsetMinutes`, Settings → EPG, ±720 min, Electron only): display-only, provider data is never rewritten. Two equivalent forms in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` — `epgDisplayTimeMs` (shift the programme; `ui/epg` rendering via the `offsetMinutes` input, channel rows, dashboard/recording labels; the programme dialog and the programme guide read the store themselves) and `epgProviderClockMs` (shift "now"; every "currently airing" decision: the `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs` and `EpgService` tags its cache with the offset, Xtream/Stalker/M3U current-programme selection and previews, the unified collection resolver, dashboard progress, recording overlap). A consumer applies exactly one form per comparison. Contract: `docs/architecture/m3u-playlist-module.md` ("EPG display offset")
-- Xtream channel-row refresh (#767): the "current programme" under each Live TV row is re-checked once a minute, since `applyProgram()` otherwise runs only on scroll-into-view, a new EPG result or an offset change. A programme still on air only has its progress bar advanced (no cache read, no request); once it ends the row is re-picked and only an on-air or upcoming programme may replace it, because the earliest-item fallback that fills a blank row on first paint would otherwise move an advanced row backwards; a cached guide whose programmes have ALL ended is invalidated and refetched (the queue skips any stream still holding an answer) while an EMPTY answer means the provider has no guide for that channel and is left alone. A programme occupies `[start, stop)` in every comparison, so "has it ended" and "what is on air" cannot disagree on the boundary. Pure rules: `epg-preview-program.ts`. Two root services exist because a live layout mounts the list more than once over one `EpgQueueService` — `EpgRefillLimiter` (floor on dropping an exhausted cache, keyed by playlist + stream since ids are provider-local and the service outlives a playlist switch, expiring by age not viewport membership) and `EpgRefreshCoordinator` (owns the single timer and merges every mounted list's request, because `enqueue` is latest-wins and separate timers would cancel each other on every programme boundary). Contract: `docs/architecture/m3u-playlist-module.md` ("Xtream channel-row programme refresh")
-- Programme guide (Electron, M3U): `app-epg-guide` in `libs/ui/epg` fed by the host-provided `EPG_GUIDE_SOURCE`; the M3U host switches into guide mode (docked player strip, no sidebar/timeline, no remount) from the header action, the palette, the EPG panel's Guide button (timeline or list view) or `G`. Data: `EPG_GET_PROGRAMS_FOR_CHANNELS` / `EPG_GET_PROGRAM_COVERAGE` (keys resolved in main; manual mappings honoured). Contract: `docs/architecture/m3u-playlist-module.md` ("Programme guide").
-- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in `epg_channel_mappings` keyed by the M3U lookup key or a playlist-scoped portal key (`xtream:{playlistId}:{id}` / `stalker:{playlistId}:{id}`, helpers in `libs/shared/interfaces/src/lib/epg-mapping-key.util.ts`); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: `libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/`
-
-**TMDB Metadata Enrichment** (opt-in):
-
-- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works
-- The M3U player consumes it too: entries recognized as movie files open in the VOD detail shell fed purely by `enrichMovie` (no provider payload to merge); the extra `Settings.m3uVodDetails` toggle (default on) sits in the TMDB settings section — see "M3U Movie Recognition" above
-- "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
-- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
-- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`. Xtream rows carry the same identity on the `content` row: the detail views back-fill `tmdb_id`/`release_year`/`original_title` next to `backdrop_url` (`xtreamDetailContentMetadata` → `XtreamStore.backfillContentMetadata` → `DB_SET_CONTENT_METADATA_IF_MISSING` → `persistContentMetadataIfMissing`), the activity SELECTs project them onto `PortalActivityItem`, and `buildDashboardTmdbAttempts` reads them back. Writes are per-column and never overwrite (enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one); `release_year` is the year the PROVIDER stated, never one read out of the title (readers still apply that fallback themselves, so an absent column means "no provider date" — and "2001: A Space Odyssey" can never be frozen in as a 2001 film), which holds only because the TMDB merge marks the dates it substitutes itself with `tmdb_supplied_release_date` and the extractor skips those — the merge's other `tmdb_*` fields are conditional on having content, so they cannot serve as an "enrichment ran" signal; the id is stored unvetted because every consumer re-gates it through `assessProviderId`; and there is no media-type column, since for Xtream `content.type` already is the media type. Both sides validate through `normalizeContentMetadataPatch` (`libs/shared/interfaces`), so legacy rows, never-opened rows and provider junk all collapse to the title-only fallback — as does the PWA, whose catalog cache is rebuilt from the API on every load
-- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
-- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; shared UI in `libs/ui/shared-portals` (`ActorViewComponent`, whose grid is the extracted `TitleResultsComponent` shared with the Discover pages)
-- Discover pages (clickable metadata chips, issue #1449): year, genre, and country chips on all four detail views (Xtream VOD/series, shared Stalker detail, Stalker series) are clickable when TMDB matched the item — the merges emit structured `tmdb_genres`/`tmdb_countries` (+ `tmdb_media_type` on Stalker), the year chip renders from provider data so it is gated on the navigation TARGET instead of the item's identity — `createDiscoverFacetNavigation()` offers a facet only when a playlist resolves AND `TmdbEnrichmentService.isEnabled()`, since Discover reads its results from TMDB (gating on `typeof tmdb_id === 'number'` is WRONG: the field is `number | string` and a provider-sent number satisfied it with enrichment never having run) — and clicks navigate via `discoverLink()` (`libs/portal/shared/util`) to the portal-scoped `discover` route (`?type&year&genre&genreLabel&country&countryLabel`). The route containers (`XtreamDiscoverRouteComponent`, `StalkerDiscoverRouteComponent`) clone the actor-page pattern: TMDB `/discover` top-5-pages by popularity via `TmdbDiscoverService` (session-only Map cache, never persisted to `tmdb_metadata`), matched against the catalog (Xtream in-memory index / all-portals `DB_MATCH_TITLES`; Stalker search-prefill), rendered by `DiscoverViewComponent`. Facets change via query params on the same route instance, so the discover load is guarded by recency (`createLatestRequestGuard()`) — A→B→A leaves two in-flight requests sharing one `discoverFacetKey()` — while the catalog match uses the guard for its spinner and the facet key for its results; availability also waits for catalog readiness (in-flight flags, not `isContentInitialized`, so a failed import still settles). See "Discover Pages" in `docs/architecture/tmdb-metadata-enrichment.md`
-- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts`
-- All `DB_MATCH_TITLES` consumers (Trending rail, "Because you watched" recommendations rail, cross-portal Similar rail, actor "All portals" scope) resolve the worker's flat result list through the shared `groupTitleMatchesByKey()` + `pickTitleMatch()` in `libs/services/src/lib/catalog-title-match.service.ts`. The grouping keeps EVERY row per `type:exactNormalizedTitle` on purpose — the year that separates same-titled rows belongs to the lookup, which the grouping cannot see, so collapsing first made a catalog holding both "Dune 1984" and "Dune 2021" drop whichever copy the user actually owns. `pickTitleMatch` then ranks year-compatible rows by evidence (exact year → untagged → any compatible) across all title aliases at once; only the recommendations rail passes an alias (TMDB `original_title`, via `candidateLookup()`). Multi-source VOD discovery deliberately stays off these helpers: there every copy is a distinct selectable source, not one best answer
-- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button). Distributed builds ship without a shared key; users supply their own. `DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` is empty by default; `tools/tmdb/inject-tmdb-key.mjs` still supports optional CI injection from `TMDB_API_KEY`, but is a no-op when it is unset. A user key takes precedence over any injected default; without either key, enrichment stays inactive even when enabled. Requiring a personal key is project policy, not a categorical TMDB terms restriction; see the canonical "Settings and API Key" section in `docs/architecture/tmdb-metadata-enrichment.md`.
-- Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment. Several admitted results are ranked by year evidence FIRST (`yearEvidenceTier` in `tmdb-matcher.ts`: provider's exact year → off by one → the series "premiered earlier" tolerance), and only tie-broken by `vote_count`/`popularity` inside the strongest tier reached. That tolerance covers portals reporting the running season's year, but it is a last resort: ranked as an equal it handed every new series its older, better-known namesake, because TMDB returns titles in the REQUEST language (an unrelated 2018 foreign series comes back under the same `ru-RU` name as a 2026 local-language series and outvotes it — 20 of 400 sampled Cyrillic series titles had such a collision, 16 of them won by the older row). The mirror case is accepted knowingly: only the older show's season air dates could separate it, and a search response does not carry them
-- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
-- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned (`|v4`), and connection startup removes the rows of every retired generation once, each under its own app-state marker (`migration:tmdb-search-lookup-v2-cache-cleanup:v1` for unversioned rows, `…-v3-…` for `|v2` rows, `…-v4-…` for the `|v3` rows resolved before year evidence was tiered). The TMDB search query is `cleanTitleForSearch` (provider spelling, tags/brackets/season/year stripped), NOT the folded `normalizeTitle` key used by `pickConfidentMatch`; variant deduplication uses the lowercased query (`searchQueryIdentity`) and every attempted variant is cached under its own key, since "Феик"/"Фейк" fold to one key but are different searches and two items can share an original title while walking different variant lists: NFD folding rewrites Cyrillic "й"→"и" and "ё"→"е" and splits Arabic hamza forms, and TMDB answers such a query with nothing ("Фейк (10 серий)" never matched). Never send the folded key over the wire.
-- Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`)
-- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About
-- See `docs/architecture/tmdb-metadata-enrichment.md`
-
-**Portal Account Info**:
-
-- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in `libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts`; `WorkspaceShellHeaderService.openAccountInfoFor()` picks the dialog by playlist type.
-- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.
-- Stalker: `StalkerAccountInfoComponent` (`libs/portal/stalker/feature/src/lib/stalker-account-info/`), cached-first — renders the import-time `stalkerAccountInfo` snapshot instantly, then `StalkerAccountInfoService` refreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+`get_profile`; simple mode: best-effort `account_info/get_main_info`, nested `js.account_info` envelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details: `docs/architecture/stalker-portal.md` ("Account Info Dialog").
-- Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info. `DashboardSourceExpiryService` (`libs/workspace/dashboard/data-access/`) gathers the facts: Xtream from `PortalStatusService.checkPortalStatusDetails()` (the switcher's cached status check, now carrying `exp_date`), Stalker from the persisted `stalkerAccountInfo` snapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
-
-**Stalker Portal Mode and Endpoint Discovery**:
-
-- Every resolved Edit commit is guarded by the source connection authority captured when Edit began. Electron checks it inside the per-playlist write queue; PWA performs the read, predicate, and cursor update in one IndexedDB readwrite transaction, so another tab cannot interleave a replacement. The one-time legacy mode-flag migration also scans and updates rows through one readwrite cursor transaction and never replays a pre-transaction snapshot. Delete/restore or replacement under the same playlist ID aborts both ordinary and post-navigation writes; the latter still merge concurrent title/EPG metadata when authority matches.
-- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
-- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then ` /portal.php` → ` /server/load.php` → ` /stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to ` /portal.php`, while canonical-shaped unreachable addresses still abort. If bounded discovery returns while abandoned authentication remains on the wire, the refusal is shown immediately but Add and every form field stay disabled until its settlement promise resolves.
-- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays byte-identical even if the dialog hydrated before a concurrent discovery committed; it skips discovery. A persisted `portalUrl` keeps the row on the Stalker save path even if legacy Xtream fields remain. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery, PWA acquires a shared playlist-authority barrier plus an exclusive origin-wide per-playlist Web Lock and verifies the persisted source authority while holding both. Add/delete, backup restore, and bulk replacement take the same row lock, while Delete All takes the barrier exclusively, so authority cannot change between preflight and the identity-bearing request. A concurrent Edit or stale dialog fails before remote discovery; a replacement waits for the current owner. Same-tab Save first publishes its local authentication owner, drains an existing lazy repair through actual Web Lock request completion, and only then asks for the conflicting row lock; repair callers already queued behind that owner observe the Edit block and do not reserve again. PWA fails closed if Web Locks are unavailable, while Electron relies on its single-instance local owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; ordinary failure releases it without changing the saved or runtime connection. If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that result carries its settlement promise and both reservations remain installed until it resolves, so catalog, watchdog, repair, or retry authentication cannot race a late `get_profile`. Once Save starts, navigation or dialog destruction does not discard a later successful result: `get_profile` may already have pinned the submitted serial/device identity remotely and cannot be recalled. That late commit uses `transformPlaylistMeta()` inside the per-playlist write queue to merge only connection/session fields into the current row, so newer title/EPG/metadata edits win; its returned row feeds the state-only update together with discovery's transient session patch, so NgRx replaces or clears its session fields while success UI is suppressed. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard before dispatch and after transport, so a same-endpoint mode change rejects stale snapshots and completed responses in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
-- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair reads the persisted source or calls discovery, PWA takes the same playlist-authority barrier and row reservation as explicit Edit; contention or unavailable Web Locks declines repair without a remote request, and ownership is held through the conditional transform. This prevents repair in another tab from authenticating alongside Edit or crossing delete/restore. The persisted-row preflight still verifies that the caller owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or backup restore with the same playlist ID but different connection metadata retires the override and token only after the persisted row confirms ownership and only if no explicit Edit took ownership during that read, so a delayed stale request cannot remove valid runtime state or a token negotiated by the overlapping Edit. Each repair installs a session-level authentication fence synchronously, drains the existing token slot before probing, and keeps request routing ahead of effective-connection selection until repair finishes; an abandoned transport keeps both the repair and session fences until it actually settles. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
-- Explicit Edit advances the repair generation before installing its resolved session. Lazy repair captures that generation before any probe-history row read and rechecks it with the active Edit fence before reserving discovery. A repair that started earlier is therefore discarded even if it was restoring a `discarded` history record or had already verified its row, so it cannot probe alongside Edit or restore an older endpoint, mode or token afterwards.
-- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
-- Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream.
-- Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding").
-
-**Stalker Session Authentication**:
-
-- Full portals authenticate through `StalkerSessionService` (`libs/portal/stalker/data-access/src/lib/stalker-session.service.ts`), a thin facade over `stalker-auth.api.ts` (handshake / `get_profile` / `do_auth` + the `authenticate()` orchestration), `stalker-authenticated-request-client.ts`, `stalker-edited-session-coordinator.ts` (authoritative Edit/session serialization), `stalker-watchdog.controller.ts`, `stalker-token-cache.ts` (in-run token + pending-auth state, tagged with the identity fingerprint), `stalker-session-store.ts` (the session persisted on the playlist row), `stalker-portal-error.ts` and `stalker-response-classification.ts`.
-- `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = refused (`device-conflict` when the message says so, otherwise `blocked`), `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). A bare `{status: 1}` with no message is a refusal, not a success. Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it.
-- Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text.
-- Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections.
-- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin, path, and URL Basic-auth userinfo) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session; URL parsers omit `user:pass@` from `origin`, so userinfo is tracked separately while endpoints without it retain their previous fingerprint across upgrades). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start.
-- Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting.
-- Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle").
-
-**Stalker Identity Hardening**:
-
-- The MAC is canonicalized to `00:1A:79:XX:XX:XX` by `normalizeStalkerMacAddress` (`@iptvnator/shared/interfaces`) at the INPUT boundary only — the import dialog and the playlist-info edit dialog, on blur and again on submit. Stored MACs are never rewritten on read: the MAC is the account key, and a transport-level rewrite would move `stalkerSessionFingerprint` for every existing playlist with no user action. An edit does move it, deliberately. `validateStalkerMacAddressControl` is the shared form validator, typed structurally so the contracts lib stays Angular-free.
-- Format is enforced, the Infomir OUI is **advisory only**: `hasInfomirMacOui` drives a hint, never a rejection. The stock filter is off on most reseller panels, so non-Infomir MACs are working setups; refusing one would lock those users out (`AUTH_REJECTED_MAC` in `stalker.e2e.ts` relies on a non-Infomir MAC being importable, and the mock only applies `enforceMacFormat` on the strict endpoint). The edit dialog additionally grandfathers the stored value via `createStalkerMacAddressValidator` — a pre-validation playlist may hold arbitrary text, and blocking Save would strand its title/URL/EPG edits too.
-- `deriveStalkerDeviceIdsFromMac` returns the StbEmu / `stalker-to-m3u` PAIR: `SHA256(MAC)` for `device_id` and `SHA256(MAC + 'stalker')` for `device_id2`. They must differ — a real box reports them from separate firmware calls and never equal, and the pinning is permanent, so an identical pair could never be corrected. Offered as an opt-in checkbox **at import only**, writing into the visible fields and persisted as literal strings — never recomputed at request time. The portal pins the first non-empty `device_id`/`device_id2` to the MAC forever, refuses a different one, and treats a later empty value as a permanent lockout, so a derived value that silently followed a MAC edit would be unrecoverable. The edit dialog offers no derivation and shows `DEVICE_ID_PINNED_WARNING` once an ID is stored.
-- `get_profile` reports one coherent MAG250 via `STALKER_STB_PROFILE_PARAMS` (`ver`, `stb_type` — previously empty —, `hw_version`, `image_version`, `client_type`, `num_banks`, `video_out`, `hd`). Constants, identical per playlist, deliberately outside both fingerprints.
-- Contract: `docs/architecture/stalker-portal.md` ("Stalker Identity Policy").
-
-**Favorites and Recently Viewed**:
-
-- Per-playlist favorites and global favorites
-- Recently viewed tracks watch history
-- Live channels in the unified favorites/recent live tab (global collections
- and a portal's own tabs) carry the live counterpart of the VOD "View in
- portal" handoff: `getLiveCollectionPlaylistNavigation()`
- (`libs/portal/shared/util`) resolves the channel INSIDE its playlist —
- Xtream via `buildXtreamNavigationTarget` + `openXtreamLiveItemId` (the live
- layout's auto-open service plays it), M3U via `/workspace/playlists/:id/all`
- + `openM3uChannelUrl` (the player selects it by URL); Stalker via
- `buildStalkerLiveNavigationTarget` + `openStalkerLiveItemId`, consumed by
- `StalkerLiveAutoOpen` in the ITV layout, which waits for the requested
- portal, locates the channel in the full ITV channel list cache, selects its
- genre and plays it (a portal without a full list, or a censored channel
- missing from it, falls back to the remembered genre); Stalker radio stays
- hidden (separate legacy-paged section). Two
- surfaces share that verdict: `app-open-in-playlist-chip`
- (`libs/portal/shared/ui`), projected into the EPG timeline/list-view
- toolbars through their `[epgToolbarAction]` slot beside the channel name
- (visible collapsed too; absent for radio and without EPG support), and an
- "Open in " row in `app-global-favorites-list`'s context menu
- (`openInPlaylistRequested`), which also reaches rows that are not playing
- and M3U radio stations (Stalker radio resolves to null, so no surface
- offers it). Both label with
- `playlistDisplayLabel` and reuse `PORTALS.VIEW_IN_PORTAL_TOOLTIP`; the tab
- navigates. Contract: `docs/architecture/portal-detail-navigation.md`.
-
-**Internationalization**:
-
-- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/`
-
-## Development Notes
-
-### Environment Detection and Dual-Mode Architecture
-
-The app determines whether it's running in Electron or as a PWA by checking:
-
-```typescript
-window.electron; // truthy in Electron, undefined in browser
-```
-
-**Why Dual Mode?**
-IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:
-
-- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access
-- **PWA**: Lightweight web version that runs in any browser without installation
-
-**Environment-Specific Behavior**:
-
-- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment
-- `app.routes.ts` - Same `/workspace/...` route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA
-- Storage layer switches automatically:
- - Electron → SQLite/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db`
- - PWA → IndexedDB → Browser storage
-- External player support (MPV/VLC) only available in Electron
-- File system operations only available in Electron (uploading playlists from disk)
-
-**Base Href Configuration**:
-The app uses different base href values depending on the build target:
-
-- **Development & PWA**: `baseHref="/"` (from `index.html`)
- - Used by: `pnpm run serve:frontend`, `pnpm run build:frontend:pwa`
- - For web servers with proper routing
-- **Electron Production**: `baseHref="./"` (overridden in build config)
- - Used by: `pnpm run build:backend`, `pnpm run make:app`
- - Required for `file://` protocol in Electron
-
-Build configurations in `apps/web/project.json`:
-
-- `production`: Electron build with `baseHref="./"`
-- `pwa`: Web deployment with `baseHref="/"`
-- `development`: Dev mode with `baseHref="/"` from index.html
-
-**Factory Pattern Implementation**:
-The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
-
-**Build Commit In About**:
-CI injects the git commit into `apps/web/src/environments/build-commit.ts` via `tools/build/inject-build-commit.mjs` (same placeholder pattern as the TMDB key inject); `Settings > About` then shows `" ()"`. The semver version itself stays untouched on PR and tag builds — a `-sha` suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version.
-
-**Nightly Builds And Update Channel**:
-Master pushes are the nightly channel. The leading `nightly-version` job computes one `-nightly..` version per run (`tools/release/nightly-version.mjs`; the patch is bumped only when `v` already exists on origin, so the release-cut window stays below the imminent release), every build job writes it into `package.json` and sets `publish[0].channel: nightly` in `electron-builder.json` (`--apply --version`; electron-builder does not derive the channel from the prerelease tag for the GitHub provider), and the `create-release` job publishes the artifacts as a prerelease of `4gray/iptvnator-nightly` (secret `NIGHTLY_RELEASE_TOKEN`; missing token only warns) instead of the rolling `test-master` draft, keeping the newest 20. the explicit publish channel names the updater metadata `nightly-mac.yml` / `nightly.yml` / `nightly-linux.yml`, and upload globs plus the macOS merge accept both names. `Settings.updateChannel` (Settings → About, Electron only, default `stable`) is mirrored into the main-process config (`APP_UPDATE_CHANNEL`, `app-update-channel.ts`) for the startup check; `AppUpdateService` applies the channel to electron-updater before every check (`app-update-feed.ts`: feed repository, `allowPrerelease`, channel name, and `allowDowngrade` forced back to `false` — assigning a channel silently enables downgrades) and reads release notes from the repository the requested version belongs to (`AppUpdateReleaseCatalogs` in `app-update-release-notes.ts` over `app-update-release-catalog.ts`; a catalog is a process-lifetime snapshot, so a version missing from a fully paged list reloads it once, readers of one catalog are serialized through `runExclusive` because that reload rebuilds the array under an index another reader holds, and a newly found update drops every catalog — otherwise a nightly published after startup had "no release notes"; the dialog recognises that rejection by the shared `APP_UPDATE_RELEASE_NOTES_NOT_FOUND_MARKER` text and links to the channel's release list). Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a downgrade could hit a database schema a nightly migration already applied. The About section's status badge names the channel the verdict describes (`status.verdictChannel`, stamped by every check and kept across a channel change saved mid-download, since that download still belongs to the old channel), and while the select shows an unsaved other channel the plain "Check again" becomes "Save and check for updates" (submits the form; `setChannel` re-checks on its own) — a check against an unsaved channel is deliberately not offered. Contract: `docs/architecture/release-pipeline.md` ("Nightly channel").
-
-### Testing Strategy
-
-- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`
-- **E2E tests**: Playwright testing the web app and Electron app
-- Backend tests use standard Jest
-- Bug fixes should add focused regression coverage unless there is a documented reason not to.
-- Use the impact-based validation policy in `Regression Prevention And Test Updates` to choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification.
-
-### Nx Commands
-
-Use `nx` CLI for better performance:
-
-```bash
-pnpm nx run :
-# Example: pnpm nx run web:build
-# Example: pnpm nx run electron-backend:serve
-```
-
-To run multiple projects:
-
-```bash
-pnpm nx run-many --target=test --all
-```
-
-### Electron Build Process
-
-The Electron backend depends on the web app being built first:
-
-- `electron-backend:build` depends on `web:build`
-- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend)
-- Packaging combines both into distributable
-
-### Database Migrations
-
-Database initialization is owned by `libs/shared/database/src/lib/connection.ts`. `createTables()` creates missing schema objects, and `runMigrations()` applies column/index migrations and dedicated schema/data upgrades. `CREATE TABLE IF NOT EXISTS` does not add columns to existing tables. One-off data migrations use completion keys stored in `app_state` (exported as `appState`). Follow the Upgrade And Migration Compatibility policy above and the validation guidance in `libs/shared/database/README.md`; a new release must not depend on users having launched intermediate releases.
-
-### Common Patterns
-
-**IPC Communication**:
-
-1. Define handler in appropriate events file (e.g., `database.events.ts`)
-2. Register with `ipcMain.handle()` in the event bootstrap function
-3. Expose in preload script via `contextBridge.exposeInMainWorld()`
-4. Call from Angular via `window.electron.()`
-
-**Adding New Playlist Source**:
-
-1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts`
-2. Create event handler in `apps/electron-backend/src/app/events/`
-3. Add the import flow in `libs/playlist/import/feature/` (add-playlist dialog + per-source import components) and surface it on the dashboard (`libs/workspace/dashboard/`) if needed
-4. Update database schema if needed
-
-**State Management**:
-
-- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)
-- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)
-- Use NgRx signals for reactive data streams
-
-
-
-
-## General Guidelines for working with Nx
-
-- For navigating/exploring the workspace, invoke the `nx-workspace` skill first when it is available - it has patterns for querying projects, targets, and dependencies. If it is unavailable, use `pnpm nx show projects`, `pnpm nx graph`, and project `project.json` files directly.
-- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
-- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI
-- You have access to the Nx MCP server and its tools, use them to help the user
-- For Nx plugin best practices, check `node_modules/@nx//PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.
-- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure
-
-## Scaffolding & Generators
-
-- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools
-
-## When to use nx_docs
-
-- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
-- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know
-- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax
-
-
-
-## XMLTV Response Compression
-
-Electron decodes HTTP compression before the gzip file layer. For `.gz`/gzip
-metadata plus HTTP gzip, a streaming signature check unwraps one remaining
-file layer while preserving single-layer providers. Errors and cancellation
-close the decoding chain. Contract: `docs/architecture/m3u-playlist-module.md`
-("XMLTV response compression").
-
-## XMLTV Source Removal
-
-Saving Settings → EPG reconciles cached XMLTV with committed global URLs and
-all enabled M3U playlist sources. Startup runs the same reconciliation after
-settings load and playlist migration. Ordinary saves skip unchanged normalized
-source sets; an explicitly edited EPG field can retry a failed cleanup.
-A cleanup failure after persistence still mirrors committed settings to Electron;
-the form stays dirty for retry. Failed storage writes never mirror to main.
-Failed settings reads and incomplete playlist migration never authorize pruning.
-Removed sources retire queued/running imports and dismiss retained error rows
-before worker-owned deletion. Retry waits for reconciliation and rechecks its
-error row, including after trust-setting writes. Shared channel IDs survive while
-another source has programmes or per-source channel metadata. The additive
-`epg_channel_sources` table preserves each imported source's name, logo, URL and
-timestamp plus transaction-ordered `write_order`, so removal restores the latest
-surviving snapshot even when import timestamps tie; ambiguous legacy metadata
-falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no
-longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache
-invalidation prevent late results from restoring removed programmes. Provider
-EPG is independent. See `docs/architecture/m3u-playlist-module.md`
-("XMLTV source lifecycle").
-
-## Web Backend Provider Redirects
-
-All four provider proxy routes use `ValidatedHttpClient`: automatic redirects
-are disabled, the initial URL and at most five redirect hops pass full URL/DNS
-validation, and fresh agents pin each connection to that hop's validated IPs.
-Host/SNI and TLS verification remain intact; outbound environment proxies are
-disabled. Private-network opt-in applies to the chain. Cross-origin redirects
-strip session headers; original query params are not replayed. One portal
-admission owns the entire chain and final body, with explicit redirect evidence
-preventing destination failures from penalizing the initial endpoint. Contracts:
-`docs/architecture/pwa-self-hosted.md` and
-`docs/architecture/host-connectivity-guard.md`.
-
-## Portal Connectivity Preference
-
-- Half-open trial slots follow the complete request lifetime with no elapsed-time
- expiry. All four Electron/web-backend portal handlers release in `finally`,
- independently of outcome reporting; cleanup preserves trial/epoch ownership
- and works while the environment override is disabled. Contract:
- `docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the
- request lifetime").
-- Desktop Settings > General > Portal connections exposes default-on
- `Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors
- the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart;
- settings bootstrap restores it before the renderer loads. It controls Xtream
- and Stalker together. Preference transitions clear cooldowns and invalidate old
- request completions; unchanged saves preserve evidence. The environment switch
- `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not
- control the shared backend's guard.
-- Both account-info dialogs explain guard refusals with localized paused-request
- copy and Retry now; Stalker preserves cached account data on a failed refresh.
- Contract: `docs/architecture/host-connectivity-guard.md`.
-
-## Live TV Panel Levels
-
-Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
-from the outside in, in three nested levels owned by `LiveSidebarState`
-(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
-+ player), `categories-hidden` (channels rail + player) and `collapsed`
-(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
-surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
-shell context sidebar folds the categories rail on
-`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
-a selected category — the live root has no channels header to host the way
-back — and always at level 3), the channels rail folds on
-`isCollapsedFor('portal')`. While the rail is folded the
-channels header turns its title into a category dropdown that opens the same
-`WorkspaceContextPanelComponent` as a CDK popover through the
-`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
-`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
-closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
-the live layouts reach it through `createLivePanelsController()` (level
-flags, dropdown bridge and focus handoff in one shared object; the token is
-optional). `Cmd/Ctrl+B`, the header toggle and the
-floating restore handle return to the level the user collapsed from (the
-target is session-only; every level is restored as stored per surface).
-Folded rails carry `inert`, and
-`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
-replacement affordance only when the activated button was removed or inerted.
-M3U and the unified live tab have no categories rail and treat level 2 like
-level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
-("Collapsible Live Sidebar").
-
-## Live Channel Return
-
-Xtream and Stalker (including radio) capture displayed playback order on explicit
-selection. Remote up/down, numbers and status use that queue while browsing
-categories or search. Stalker commits after successful current URL resolution
-and extends only loaded pages of the original scope. The conditional channel
-header action clears search, returns to the accessible playing category and
-focuses its row without changing playback. Contract:
-`docs/architecture/remote-control.md` (Live channel return and playback order).
-
-## Stalker Live Search
-
-ITV sidebar and fullscreen searches independently filter the complete selected
-category; only All Items searches the whole public catalog. Cached categories
-search before windowing; missing/censored genres keep provider pagination,
-including automatic continuation for short or empty search results. ITV search
-never narrows shared provider pages or resets their index. Category changes
-reset list windows and retain playback/active EPG. Contract:
-`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
-
-## Channel and Detail Keyboard Scrolling
-
-Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
-focuses the viewport, native scrolling survives virtual row recycling, and
-row Enter/Space activation stays separate from focus movement. Portal Live TV
-uses ArrowRight from the selected category and ArrowLeft from the channels
-pane to move between columns. Shared live sidebars reserve scrollbar space
-beside the resize handle. `PortalDetailShellComponent` owns a visible native
-scrollbar and guarded initial page focus. Its one sticky arrow is the host's
-Back action in browse and watch alike; only Escape unwinds one level (close
-inline playback to browse, then Back), and the now-playing bar's Close button
-is the pointer way back to browse. Browse Escape requires
-focus inside the shell; watch preserves the global close shortcut. Menus,
-dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain
-their keys. M3U and collection bootstrap shells set `backAvailable=false` when
-there is no browse return action. Contracts:
-`docs/architecture/iptvnator-ui-guidelines.md` and
-`docs/architecture/portal-detail-navigation.md`.
-
-## Catch-Up URL Copying
-
-EPG timeline/list programme details expose Copy archive URL for supported
-Xtream/M3U archives, including Favorites/Recent. `EpgArchiveCopyService` owns
-clipboard feedback; hosts resolve URLs without mutating playback. Stalker and
-the currently non-catch-up M3U guide expose no action. See
-`docs/architecture/m3u-playlist-module.md` (Copy archive URL).
-
-## Xtream Archive Downloads
-
-Desktop Xtream Live TV programme details can enqueue completed catch-up as
-`contentType: catchup`. The queue uses the existing timeshift resolver, original
-timestamps and playback headers. `programme_start` plus playlist/channel provides
-identity; JSON `catchup` metadata retains channel, broadcast window and known
-expiry. `download-schema.ts` owns the transactional CHECK/index migration;
-`download-tables.ts` exports the download tables. The cascading
-`download_archive_finalizations` table records write-ahead file identity/size
-proof before promotion (before writing a fallback copy), allowing startup to
-recover completed unknown-length archives and clean only their owned partials.
-The same journal stores transfer-phase descriptor identity before truncation;
-Resume checks it at open, and rejected replacements are preserved and detached
-so Retry can reserve a fresh path. A synchronous completion-commit boundary
-rejects late pause/cancel commands before awaited cleanup and persistence.
-Archive ownership reads device/inode as BigInt and journals decimal strings
-without losing 64-bit Windows file references, alongside positive creation time to reject
-reused inodes after unlink; old proofs without creation time remain untrusted.
-Fresh reservations atomically commit their row path/name and captured ownership
-before the initial HTTP wait;
-no preexisting partial is truncated without matching expected ownership.
-Captured foreign files retain their recovery copy and journal even after public
-restoration, until the user explicitly removes the recovery copy. Remove/Clear
-show its full path and recovery instructions in a persistent dialog with Copy
-recovery path.
-Private cleanup captures are journaled before relocation, keeping failed
-Remove/Clear/cancel cleanup retryable across restarts without hardlinks. Active
-failures, promotion and startup share that cleanup; Remove waits for active
-archive cancellation to settle before deleting its row and journal.
-Archive transfers validate TS framing, restart from byte zero after interruption
-and check expiry again at transfer start. Completed cards play locally and never
-route to VOD details. Contract and EOF/duration limits:
-`docs/architecture/download-manager.md` (Xtream archive downloads).
-
-## Desktop Source Health
-
-Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL
-checks through `SourceHealthService` in portal shared data access. Confirmed
-account expiry/disablement is distinct from failed authorization or network
-checks. Stalker probes reuse session ownership without endpoint repair; M3U
-reads stop at 64 KiB. PWA retains existing Xtream behavior. Contract:
-`docs/architecture/m3u-playlist-module.md` (Desktop source health).
-
-Desktop Sources also offers library-wide selective cleanup through dialog-scoped
-`SourceCleanupService`. Only confirmed expired/disabled accounts are preselected;
-playback/import/refresh/delete-busy sources are skipped. Deletion goes through
-one serialized `PlaylistsService` operation and awaited cleanup hooks;
-`PlaylistActions.playlistRemovalCommitted` updates state without another DB
-delete. Stop finishes the current source. Same contract: Desktop inactive-source
-cleanup in `docs/architecture/m3u-playlist-module.md`.
-
-Startup source auto-refresh uses `SourceActivityService` to protect busy IDs
-from cleanup. Late batch refreshes skip deleted rows instead of recreating them.
-Contract: `docs/architecture/m3u-playlist-module.md` (Desktop inactive-source cleanup).
+Repository release skills are mirrored in `.claude/skills/`; their Codex copies
+must remain byte-identical. Other repository skill instructions can be read
+from the paths in the [context map](docs/maintenance/agent-context-map.md).
diff --git a/README.md b/README.md
index eaea6e8ae..7a9f69617 100644
--- a/README.md
+++ b/README.md
@@ -389,3 +389,11 @@ The name **"IPTVnator"** and the IPTVnator logo are unregistered trademarks of t
[](#contributors)
+
+## Developer and agent documentation
+
+Start with the [task context map](docs/maintenance/agent-context-map.md) to find
+the authoritative contract and validation for your area. Common agent rules are
+in [AGENTS.md](AGENTS.md); Claude Code imports that same file. Development and
+documentation-maintenance conventions live in the
+[agent workflow](docs/development/agent-workflow.md).
diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md
index 47181aeec..585ec4245 100644
--- a/docs/architecture/m3u-playlist-module.md
+++ b/docs/architecture/m3u-playlist-module.md
@@ -1906,3 +1906,44 @@ deletes with follow-up cleanup warnings. The UI does not resurrect a deleted
row after a cleanup failure. Downloaded files are not removed. The dialog's
confirmation covers deletion of the source and associated favorites, history
and playback positions; no deletion happens on merely opening the dialog.
+
+## Opening playlists from the operating system
+
+Electron only: a `.m3u`/`.m3u8` path passed
+on the command line, opened through a file association, or delivered by macOS'
+`open-file` event is normalized to an absolute path in the main process
+(`apps/electron-backend/src/app/services/playlist-open-request.ts`) and queued there. The renderer
+(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the
+`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is
+what makes the main process flush. `OPEN_FILE` is the only way out of the
+queue, and a request stays there until the renderer confirms receipt via
+`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the
+listener runs, and a reload or dead render process keeps the `WebContents`
+alive, so a successful push is not proof of delivery. Anything unacknowledged
+is replayed to the next renderer that announces itself. The renderer
+imports them on a single promise chain so a burst arrives in a deterministic
+order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)
+for the same reason: each action carries a different playlist, so a newer add
+must never cancel an older one's write, EPG fetch and navigation. The import
+itself reuses the normal file path
+(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,
+playlist-scoped EPG, and the navigation to the new playlist all behave exactly
+like a dialog import.
+
+The OS-level registration that makes those paths reachable is
+`fileAssociations` in `electron-builder.json` — one entry per extension, each
+with its own `mimeType`. Electron Builder derives all three platform
+registrations from it: macOS `CFBundleDocumentTypes` (which is what makes
+`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the
+desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for
+deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading
+`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and
+must not be used; and it appends `%U` to `Exec`, so Linux file managers hand
+over percent-encoded `file://` URIs rather than paths —
+`createPlaylistOpenRequest` decodes them before the extension check. `%U` is
+also the _plural_ exec code, so a multi-file selection arrives as one launch
+with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all
+of them and `enqueueAll` queues the batch, because stopping at the first match
+would silently drop the rest of the selection. Adding an exec code to
+`linux.executableArgs` would suppress the `%U` but also pass that code to the
+app as a real argument, so it is not an option.
diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md
index 2e7a0c2d7..8d238199b 100644
--- a/docs/architecture/nx-workspace-boundaries.md
+++ b/docs/architecture/nx-workspace-boundaries.md
@@ -130,6 +130,22 @@ patched prefilter/matcher wiring and version pin, stress-tests the false-positiv
chunk shape, and preserves ordinary and comment-bearing asset and worker
`new URL(..., import.meta.url)` matches.
+## Electron Builder signing patch
+
+`app-builder-lib` 26.15.7 is patched in
+`patches/app-builder-lib@26.15.7.patch` with the upstream backport
+electron-userland/electron-builder#10172. For macOS signing,
+`security set-key-partition-list -k` must receive the temporary keychain's own
+password rather than the `.p12` import password. macOS runner images since
+`macos-26-arm64` 20260831 verify that password; the old argument caused
+`SecKeychainUnlock: The user name or passphrase you entered is not correct`.
+Keep the patch until electron-builder resolves a fixed app-builder-lib (26.16.1+).
+Run `pnpm run deps:electron-builder:test` after related dependency updates; it
+also rejects a mismatch between the patched and installed version.
+
+Native addon builds additionally require the root `node-gyp` devDependency;
+see [runtime staging](embedded-mpv-native.md#runtime-staging) before removing it.
+
## Placement Decision
- `apps/` owns runtime applications, development servers, E2E applications,
diff --git a/docs/architecture/player-controls-contract.md b/docs/architecture/player-controls-contract.md
index f3e950773..aa8762eed 100644
--- a/docs/architecture/player-controls-contract.md
+++ b/docs/architecture/player-controls-contract.md
@@ -7,6 +7,9 @@ Embedded MPV rendering and native-view bounds behavior remain documented in
## Current status
+The shared-controls preference checkbox is visible only when HTML5, Video.js
+or ArtPlayer is selected in Settings → Playback.
+
The shared-controls foundation supports four runtime consumers and includes:
- the `PlayerController` contract, default state, and capability presets;
@@ -1533,3 +1536,36 @@ replacement, track-list lifecycle and stable IDs, caption preference and
explicit-off behavior, MPEG-TS live/VOD handling and duration projection,
volume preservation/authority, stale ArtPlayer `customType` callbacks, and
collaborator teardown. Persistent/background player ownership has not landed.
+
+## Radio and display sleep
+
+### Radio audio player
+
+M3U `radio="true"` entries use `AudioPlayerComponent` under
+`libs/ui/playback/src/lib/audio-player/`. The player always renders inline and
+uses HTML5 `` regardless of the configured video player. Radio bypasses
+`shouldShowInlinePlayer`'s external-player gate and hides the EPG ribbon and panel
+toggle. The station artwork, blurred logo background and glass controls form the
+radio layout; title/group scrolling is CSS-only. It supports play/pause, mute,
+and volume, including the volume keys in 5% steps. Volume shares the video
+players' `volume` localStorage key. The template, SCSS and TypeScript component
+live together; routing/integration stays in the M3U player template.
+
+### Display sleep during playback
+
+`PlaybackKeepAwakeService` in the web app watches `` using document-level
+capture listeners because media events do not bubble. Release listeners also
+attach to the tracked element: Chromium's pause after DOM removal never reaches
+the document. A playing video holds a display-sleep lock only while the document
+is visible or that video is in picture-in-picture, which survives minimization.
+
+Electron uses main-process `powerSaveBlocker` through
+`window.electron.setPlaybackKeepAwake`. The renderer vote clears on reload,
+main-frame non-same-document navigation, crash (`render-process-gone`) or
+destruction; Angular navigation does not itself clear it. The PWA uses Screen
+Wake Lock. Browser auto-release clears its sentinel; the next media, visibility
+or PiP synchronization can request another lock. If state changes during a
+pending request, rejection triggers one queued re-evaluation rather than losing
+that update. Radio `` deliberately never blocks display sleep.
+Embedded MPV owns a separate blocker in `EmbeddedMpvNativeService`, and external
+MPV/VLC inhibit their own screensaver.
diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md
index 6c236e9d9..90bd07156 100644
--- a/docs/architecture/pwa-self-hosted.md
+++ b/docs/architecture/pwa-self-hosted.md
@@ -277,3 +277,25 @@ For manual Docker smoke testing, run the Xtream and Stalker mock servers plus a
small M3U fixture, then verify in the browser that M3U, Xtream, and Stalker can
add sources, play an item, toggle favorites, populate global favorites,
populate recently viewed, and appear on the dashboard rails.
+
+## Service factory and build bases
+
+`DataService` in `libs/services/src/lib/data.service.ts` is the renderer service
+contract. `DataFactory()` in `apps/web/src/app/app.config.ts` chooses
+`ElectronService` for the desktop bridge and `PwaService` for browser HTTP and
+IndexedDB work. This environment-level selection is not evidence for an
+individual capability: Xtream data-source selection requires its complete
+SQLite bridge, and feature visibility follows `RuntimeCapabilitiesService`.
+The same workspace route tree is used in both runtimes.
+
+Desktop relational data uses the canonical schema/connection in
+`libs/shared/database`; the SQLite path is `~/.iptvnator/databases/iptvnator.db`.
+Desktop Chromium settings/storage still exist alongside SQLite. PWA data uses
+browser IndexedDB (with browser quotas); its structure is not the SQL schema.
+Browser-selected file uploads remain possible even though native filesystem
+access is Electron-only.
+
+Web development and PWA use `baseHref="/"`; packaged Electron frontend uses
+`baseHref="./"` so file URLs resolve. In `apps/web/project.json`, `production`
+is the Electron frontend build, `pwa` is the web build, and `development` uses
+the index base. Do not ship the Electron frontend build as a PWA deployment.
diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md
index 121be4848..1d2c6685c 100644
--- a/docs/architecture/validation-map.md
+++ b/docs/architecture/validation-map.md
@@ -11,6 +11,16 @@ pnpm nx show projects --withTarget lint
pnpm nx show projects --withTarget e2e
```
+## Manual CI Runs
+
+When a PR event does not start checks for the current head, dispatch CI and E2E
+with `gh workflow run ci.yml --ref ` and
+`gh workflow run e2e-tests.yaml --ref `. CodeQL also supports
+`gh workflow run codeql-analysis.yml --ref `; this analyzes the selected
+branch commit instead of the PR merge commit. Verify each run's head SHA before
+using its result as evidence. Docker validation can use
+`gh workflow run docker.yml --ref -f push=false`.
+
## Unit And Type Checks
| Area | Command |
@@ -152,3 +162,31 @@ are gated by:
```bash
IPTVNATOR_TRACE_PLAYER=1 pnpm run serve:backend
```
+
+## Test impact and completion
+
+Before finishing a feature, bug fix, data-flow or UI workflow change, identify
+the affected projects and choose unit, integration, E2E, build, lint and manual
+checks. Bug fixes normally include regression coverage that fails before the
+fix. If automation is impractical, explain why and report the strongest manual
+validation. Update fixtures, mocks, routes and E2E flows when behavior changes.
+Prefer extending the closest existing suite to introducing a parallel suite.
+
+Run targeted unit checks first, then affected E2E for workflows, routing,
+persistence, playback, portals, settings or import flows. Electron IPC, SQLite,
+packaged runtime, external players, native files and Electron-only routes require
+Electron E2E where available, otherwise CDP/manual verification using the
+[debugging guide](../development/electron-debugging.md). Prefer atomized E2E
+targets before broad suites. Final reports name tests changed, commands/results,
+and skipped validation with reasons. Docs-only changes need Markdown validation,
+not app unit/E2E. Tooling validation still requires its own focused tests.
+
+## Agent guidance checks
+
+`pnpm run agents:validate` checks root instruction budgets/imports and guidance
+navigation links/anchors. `pnpm run skills:validate` checks skill frontmatter,
+length, paths and release mirrors. Both tooling suites run through
+`pnpm nx test repository-skills`; syntax checks use
+`pnpm nx lint repository-skills`. The CI guidance check runs regardless of the
+Nx affected set. Semantic preservation of moved contracts is a review task;
+link validation alone cannot prove it.
diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md
index 529713149..51f61c67c 100644
--- a/docs/architecture/workspace-dashboard.md
+++ b/docs/architecture/workspace-dashboard.md
@@ -327,3 +327,13 @@ Intentionally out of scope:
2. Freeform widget grid with collision management.
3. External data rails such as RSS, sports, or news adapters.
4. Per-user A/B variants of rail ordering.
+
+## Source subscription expiry
+
+Source cards show a passive subscription-expiry chip: amber within seven days,
+error-toned once expired. Account details stay behind the Account info menu.
+`DashboardSourceExpiryService` in `libs/workspace/dashboard/data-access` reads
+Xtream expiry from cached `PortalStatusService.checkPortalStatusDetails()`
+(`exp_date`). Stalker uses the persisted `stalkerAccountInfo` snapshot from the
+playlist payload, not the metadata row; each source therefore needs one memoized
+full-playlist read. The chip is not a separate account-refresh request.
diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md
index fab3045ef..ae2494af6 100644
--- a/docs/architecture/xtream-portal-compatibility.md
+++ b/docs/architecture/xtream-portal-compatibility.md
@@ -373,3 +373,31 @@ It reuses the canonical timeshift resolver and original timestamps, preserves
playback headers and does not change playback. See
[Download Manager](download-manager.md#xtream-archive-downloads) for identity,
restart, expiry and transport-completion limits.
+
+## Store composition and catalog windowing
+
+`XtreamStore` is the public facade built with `signalStore()`, composing
+`signalStoreFeature()` features for portal, content, selection, search, EPG,
+player, favorites, recent and playback positions. Most features live under
+`libs/portal/xtream/data-access/src/lib/stores/features/`; favorites and recent
+items live directly under the data-access library’s `src/lib/`.
+Routed components consume that facade; features delegate persistence/networking
+to `IXtreamDataSource`, selected through `provideXtreamDataSource()`. Complete
+SQLite capability uses database-first cache reads and API fill; the PWA source
+uses API requests and session memory. This does not move screen orchestration
+into shared utility projects.
+
+Catalog lazy loading: catalog grids scroll infinitely instead of paging.
+`withSelection` keeps a `visibleCount` render window over the in-memory
+catalog plus bounded per-selection scroll snapshots for detail/tab
+round-trips; the shared `InfiniteScrollDirective`
+(`libs/portal/shared/ui`) measures container overflow to auto-fill tall
+viewports (terminating on lack of container growth, not on a load count)
+and fires `loadMore` near the bottom. The search layout routes its results
+container through the same directive (`nearEnd*` inputs). Stalker feeds the
+same contract from server-paged appends: portal pages accumulate into one
+deduplicated list, `hasMoreContent` derives from accumulated length vs
+`total_items`, a failed append keeps loaded pages and offers a tail retry,
+and the facade maps page 0 to the skeleton and later pages to the tail
+spinner. These catalog/search surfaces use incremental loading instead of
+page buttons.
diff --git a/docs/development/agent-workflow.md b/docs/development/agent-workflow.md
new file mode 100644
index 000000000..239feed90
--- /dev/null
+++ b/docs/development/agent-workflow.md
@@ -0,0 +1,223 @@
+# Agent development workflow
+
+Common startup rules live in [AGENTS.md](../../AGENTS.md). This document holds
+procedures and conventions to read when they apply, not extra startup imports.
+
+## Maintaining canonical knowledge
+
+After meaningful changes, assess documentation before declaring completion.
+Meaningful changes include user-visible behavior, architecture/data flow,
+maintenance/setup/debugging workflows, and subsystem contracts. Formatting,
+behavior-preserving refactors and isolated test changes need no doc update.
+Prefer the existing authoritative architecture doc or nearest module README;
+README.md owns top-level user/developer entry points. Update stale routes,
+paths, commands and contracts you encounter, or explicitly flag unresolved
+claims in the final summary. Repo docs remain canonical regardless of authorship.
+
+Keep AGENTS.md limited to repository-wide constraints and task routing. Do not
+add feature histories, method lists, schema inventories or troubleshooting
+procedures to it. CLAUDE.md imports AGENTS.md; do not mirror common prose by hand.
+Use [the context map](../maintenance/agent-context-map.md) for the owning document
+and relevant skill. Read multiple contracts for cross-domain work. Add a new
+canonical document only when no existing owner fits; link it from the map.
+
+When relocating knowledge, compare both sources and the destination, retain
+unique exceptions and rationale, and record corrections with code evidence.
+The [2026-09 migration ledger](../maintenance/agent-guidance-migration.md) records
+the initial move; it is an audit artifact, not required reading for feature work.
+Later normal edits maintain the canonical docs, not duplicate historical prose.
+
+Run `pnpm run agents:validate` after guidance changes. It checks line/byte budgets,
+root imports and local navigation links/anchors, including migration destinations.
+Line budgets count LF, CRLF and standalone CR endings consistently.
+Markdown navigation is parsed with the already-declared `marked` dependency;
+undefined explicit references (including shortcut images) are errors, and code examples are excluded.
+Backticked concrete paths in root guidance and the context map are checked from
+the repository root, including unknown top-level directories and filenames.
+Write generic filenames as prose; commands, templates, globs, URLs, package
+aliases and dotted code symbols are excluded. Bare dotted names with conventional
+file suffixes (such as .md, .json or .ts) are treated as filenames. Document formats
+share the suffix set used by package-import guards, including PDF and AsciiDoc. Use a `./`
+prefix or Markdown link for other ambiguous filenames that resemble code symbols.
+Explicit relative literals denote paths, including spaces, filesystem punctuation and hyphenated words.
+Put executable command examples in fenced code when their syntax also looks like a path.
+Multi-part dotfiles are path candidates too.
+Conventional extensionless filenames such as Dockerfile, Makefile and LICENSE
+are also path candidates; use an explicit `./` prefix for other extensionless files.
+Link paths and fragments are decoded separately so encoded filename delimiters
+stay in the filename. Fenced and indented examples
+do not count as root guidance imports or satisfy the required Claude import.
+The required Claude import must be an unformatted standalone line in a top-level
+paragraph; headings, quotes and list items do not satisfy it.
+The parsed HTML tree also verifies that this paragraph is outside HTML containers,
+including templates split across Markdown tokens. Generated HTML is inspected
+in memory only; it is never executed or emitted.
+Heading anchors decode HTML character references in text and use `github-slugger`
+for GitHub-compatible character filtering and duplicate suffixes.
+Only headings present outside inert HTML containers contribute slugs or duplicate counters.
+Explicit HTML anchors use `parse5`, excluding comments, scripts, styles and template contents.
+Rendered HTML anchor and image-map area hrefs and image sources use the same local-reference checks
+as Markdown links, including decoded attributes and fragment validation.
+URL attributes remove ASCII tabs/newlines throughout and discard surrounding
+ASCII control/space characters before resolution.
+Iframe/embed sources and object data attributes are document references and retain Markdown-target anchor checks.
+Inline iframe srcdoc documents are traversed too, with their own fragment anchors.
+The first active HTML base href sets reference resolution, including nested srcdoc bases.
+Resolved file URLs use native filesystem conversion, including Windows drive paths.
+Explicit srcset attributes must contain at least one parsed candidate.
+Image and media references require a nonempty path that resolves to a file, not a directory.
+Direct file URLs, Windows drive paths and HTML bases using either form are rejected; use portable repository-relative paths.
+Image references check file existence without interpreting image fragments as
+Markdown headings; document links keep anchor checks even when sharing a target.
+SVG image/use hrefs (including xlink), HTML image-input, video, audio, source and track `src` assets and video posters use the same
+existence checks as images. Entity decoding uses full HTML text/attribute rules,
+including references whose semicolon may be omitted.
+Inline guidance imports are rejected after punctuation as well as whitespace.
+At-signs inside external URIs (including www autolinks and explicit opaque autolinks such as mailto) are excluded
+per HTML text node, preserving adjacent imports. A colon directly before an import
+does not make that import a URI. Opaque schemes are excluded only in parsed links
+whose visible text equals their URI, so colon-labeled prose remains checked.
+A closing bracket or matching enclosing quote followed by punctuation and an at-sign terminates a bare URL exclusion.
+Extensionless inline candidates are also imports when they resolve to repository files,
+checking the full filename before prefixes at ASCII/Unicode prose separators,
+including opening parentheses, brackets and braces. Each at-sign candidate is
+checked independently, including imports nested next to a package mention.
+An at-sign inside a word (for example, foo@INSTRUCTIONS or an email address)
+is not an import boundary, including within parenthetical prose.
+Declared scoped dependencies, scope wildcards and matching TypeScript path aliases
+are recognized as package/alias mentions. Traversal and document-file imports are
+rejected before those exemptions, including document paths with fragments or queries.
+All recognized Markdown extensions share the document-import guard; reStructuredText
+and AsciiDoc, PDF, Word, OpenDocument, RTF, Org and TeX documents are also excluded
+from package exemptions. Recognized extensionless guidance names (including AGENTS,
+CLAUDE, INSTRUCTIONS, README, CONTRIBUTING and SECURITY, case-insensitively) are excluded in package subpaths too. URL-encoded
+paths do not receive package exemptions. TypeScript configuration is parsed as JSONC.
+Declared packages also permit safe subpaths; exact aliases stay exact.
+Federated handles in the @user@host form are prose, not imports; trailing closing
+ASCII/Unicode punctuation and possessive apostrophe-s suffixes are ignored. Opening
+delimiters separate adjacent prose; nested imports remain checked. Extra at-signs do not qualify for that exemption.
+Exact declared packages remain exempt after version normalization.
+Declared package mentions may include a version (including semver comparators and wildcard ranges) or dist-tag qualifier.
+Qualifier handling includes unscoped names; terminal sentence punctuation is
+removed before matching a declared package, as are straight/curly apostrophe possessives.
+Unicode punctuation and ASCII opening delimiters, commas, semicolons, colons, question/exclamation marks
+separate package mentions from adjacent prose.
+Markdown destinations decode HTML entities before URI parsing, matching rendered links.
+Heading-anchor lookup is limited to Markdown targets. Source-file line fragments,
+PDF page fragments and other non-Markdown fragments retain file-existence checks.
+The import scan separates HTML block/table elements and includes visible text and literal backticks; it excludes parsed code nodes and non-rendered containers.
+Navigation uses the parsed rendered tree too. Temporary in-memory markers retain
+definition, unresolved-reference and literal-path metadata, so Markdown inside
+inert templates is excluded consistently with raw HTML navigation.
+Image source sets use `parse-srcset` to check each candidate URL. Root-relative
+literals never suppress source-relative definition checks.
+The Nx test hash includes `marked`, `parse5`, `github-slugger`, `parse-srcset` and `typescript`
+so dependency changes invalidate parser coverage.
+It cannot prove semantic equivalence; review changed contracts as well.
+
+## Protected Markdown edits
+
+Never run whole-file `prettier --write` on AGENTS.md, CLAUDE.md or docs/**.
+Upstream formatting is not uniformly Prettier-clean: whole-file writes can
+corrupt nested list indentation or change a literal continuation into a bullet.
+Format only intended new lines. If accidental formatting occurred, reconstruct
+from the pre-edit version and reapply only intended changes; preserve unrelated
+user edits. Use the merge-base version only if it actually represents that
+pre-edit state. Review the diff rather than blindly restoring an older branch.
+
+## Plans and completion reports
+
+Save only finalized plans in `.plans/YYYY-MM-DD-short-topic.md`; use numeric
+suffixes for collisions. Do not save drafts or questions there. If an active
+mode forbids writes, save the approved plan on entering execution.
+Completion reports list changed docs, tests added/updated, commands and results,
+skipped validation with reasons, and release-note status. A docs-only task needs
+Markdown/link validation, not app unit/E2E tests. Tooling changes need their own tests.
+
+## Repository skills
+
+Repository skills live under `.codex/skills/`. Descriptions are trigger-only,
+begin with `Use when`, and each skill is at most 500 words. Frontmatter owns
+trigger descriptions; avoid copying them into navigation prose. Skills provide
+workflow and links to authoritative contracts, not a second contract copy.
+`release-cut` and `release-notes` have byte-identical `.claude/skills/` mirrors.
+Run `pnpm run skills:validate` after changing a committed skill or a literal
+path it documents. New guidance tooling belongs to the existing
+`repository-skills` Nx project; no new project is needed for another validator.
+
+## Angular conventions
+
+Use signal-based queries (`viewChild`, `viewChildren`, `contentChild`,
+`contentChildren`) and inputs/outputs (`input`, `output`). For required queries,
+use `viewChild.required`. Unwrap signals when passing values in templates:
+
+```typescript
+readonly menu = viewChild.required('menuRef');
+readonly title = input.required();
+readonly size = input(10);
+readonly clicked = output();
+readonly count = signal(0);
+readonly doubled = computed(() => this.count() * 2);
+```
+
+```html
+Open Menu
+```
+
+Use `signal`, `computed`, `effect` and `linkedSignal` for reactive state. Existing
+host bindings/listeners use `@HostBinding` and `@HostListener`; this relocation
+does not change that convention. Prefer `@if`, `@for` (with a stable `track`),
+and `@switch` over the legacy structural directives. A signal is a function;
+passing `menu` instead of `menu()` to Material supplies the wrong value.
+
+## Adding behavior across layers
+
+For IPC, define the handler in the appropriate Electron events module,
+register it in the event bootstrap, expose a typed preload method, and consume
+it through the renderer service. Keep channel contracts typed in
+`ElectronBridgeApi`; use the database worker contract for heavy database work.
+See [Electron security](../architecture/electron-security.md) and
+[DB worker ownership](../architecture/sqlite-db-worker.md).
+
+For a playlist source, extend the shared playlist type, add the backend event
+handler, add the import UI under `libs/playlist/import/feature`, and update
+state actions/effects. Preserve runtime capabilities and migration behavior.
+NgRx owns M3U global state; portal/feature state uses composed NgRx Signal Store;
+component-local state uses Angular signals. Avoid expanding large classes:
+extract components/services or `with*` store features before exceeding limits;
+shared types belong in their own contract modules. The hard production limit
+is 400 (not a variable 350–400); target under 300. See
+[Nx file-size policy](../architecture/nx-workspace-boundaries.md#typescript-file-size).
+
+## Build and serve commands
+
+Use local `pnpm nx` for underlying project targets. Package scripts are the
+supported entry points for composed tasks:
+
+| Task | Command |
+| --- | --- |
+| Web development | `pnpm run serve:frontend` |
+| PWA development | `pnpm nx serve web --configuration=pwa` |
+| Electron development | `pnpm run serve:backend` |
+| Electron frontend | `pnpm run build:frontend` |
+| PWA frontend | `pnpm run build:frontend:pwa` |
+| Electron backend | `pnpm run build:backend` |
+| Package without installers | `pnpm run package:app` |
+| Create installers | `pnpm run make:app` |
+
+Electron backend build depends on the web build; outputs live under
+`dist/apps/electron-backend` and `dist/apps/web` and packaging combines them.
+Use [the validation map](../architecture/validation-map.md) for test/lint tasks
+and [the release pipeline](../architecture/release-pipeline.md) for packaging.
+
+## Shared search text folding
+
+Use `foldSearchText` from `libs/shared/interfaces/src/lib/search-text-fold.util.ts`
+on both sides of every in-memory search comparison. Channel lists, catalog and
+category filters, command palette, sources and downloads must share this fold;
+row filtering and count/queue sources must not diverge. It lowercases without a
+locale, normalizes to NFC, then strips remaining combining marks. Composing first
+keeps canonical spellings equivalent while preserving accents; the leftover dot
+in Turkish dotted İ is stripped so it matches plain i. Do not substitute
+`toLocaleLowerCase`. Electron content search uses the same fold and adds explicit
+Turkish-locale İ variants to LIKE/GLOB queries because SQLite LIKE folds only ASCII.
diff --git a/docs/development/electron-debugging.md b/docs/development/electron-debugging.md
new file mode 100644
index 000000000..bf3a282a7
--- /dev/null
+++ b/docs/development/electron-debugging.md
@@ -0,0 +1,79 @@
+# Electron debugging and tracing
+
+Use this procedure for Electron/CDP tasks. Read the available `electron` skill
+when automating the desktop app. These commands assume a bootstrapped worktree.
+
+## Start and attach
+
+- Start the Electron development app with: `pnpm nx serve electron-backend`
+- Package-script equivalent: `pnpm run serve:backend`
+- Electron is configured to start with: `--remote-debugging-port=9222`
+- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`
+- For Electron automation/debugging tasks, use the `electron` skill
+- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.
+- If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black.
+- If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field.
+- The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue.
+
+### Trace / Debug Startup
+
+- Full startup tracing:
+
+```bash
+IPTVNATOR_TRACE_STARTUP=1 pnpm nx serve electron-backend
+```
+
+- Narrower trace flags:
+ - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls
+ - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events
+ - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker
+ - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events
+ - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr
+ - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal
+ - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
+ - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset
+
+- Settings, portal request/response, and trace payloads must use
+ `@iptvnator/shared/logging` or the redacting portal logger before reaching
+ `console.*`; never log raw credentials while debugging.
+
+- If local Nx state gets weird before a rerun:
+
+```bash
+pnpm nx reset
+```
+
+### agent-browser (global install)
+
+```bash
+agent-browser --cdp 9222 tab list
+agent-browser --cdp 9222 tab 1
+agent-browser --cdp 9222 snapshot -i -c -d 4
+agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png
+```
+
+### Fallback
+
+```bash
+npx --yes agent-browser --cdp 9222 tab list
+```
+
+### DevTools Workaround
+
+```bash
+ELECTRON_OPEN_DEVTOOLS=1 pnpm nx serve electron-backend
+curl http://127.0.0.1:9222/json/list
+agent-browser connect ws://127.0.0.1:9222/devtools/page/
+agent-browser screenshot /tmp/iptvnator-cdp.png
+```
+
+## Main-process ownership
+
+The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database,
+registers events and creates the main window. The preload is
+`apps/electron-backend/src/app/api/main.preload.ts`, with handlers under
+`apps/electron-backend/src/app/events/`. The window follows the saved startup mode
+(normal/maximized/fullscreen); `--fullscreen` overrides a single launch. Use
+[workspace shell](../architecture/workspace-shell.md) for window behavior,
+[DB worker](../architecture/sqlite-db-worker.md) for worker ownership and
+[Electron security](../architecture/electron-security.md) for bridge boundaries.
diff --git a/docs/maintenance/agent-context-map.md b/docs/maintenance/agent-context-map.md
new file mode 100644
index 000000000..e6ccb1076
--- /dev/null
+++ b/docs/maintenance/agent-context-map.md
@@ -0,0 +1,59 @@
+# Agent context map
+
+Read the row for your task before editing. For a cross-domain change, read each
+affected contract. This is navigation, not a request to load every linked file.
+Common constraints remain in [AGENTS.md](../../AGENTS.md); Claude imports it.
+Repository skill frontmatter defines triggers. If your client does not discover
+a listed skill automatically, read its SKILL.md directly. Optional global tools
+are not prerequisites for reading repository contracts.
+
+## Development and maintenance
+
+| Area / code ownership | Canonical documents | Repository skill |
+| --- | --- | --- |
+| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
+| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
+| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
+| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
+| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
+
+## Data, sources and networking
+
+| Area / code ownership | Required contracts | Repository skill |
+| --- | --- | --- |
+| SQLite schema/startup; `libs/shared/database`; Electron DB events/workers/operations | [DB worker](../architecture/sqlite-db-worker.md), [migration ownership and tests](../../libs/shared/database/README.md) | [SQLite worker](../../.codex/skills/iptvnator-sqlite-db-worker/SKILL.md) |
+| M3U import/state/player, XMLTV, source lifecycle, startup readiness and OS file opening; `libs/m3u-state`, `libs/playlist`, `libs/epg` | [M3U module](../architecture/m3u-playlist-module.md), [adding sources across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Read both contracts when adding a source |
+| Xtream API/store/data sources and routed views; `libs/portal/xtream` | [Xtream compatibility](../architecture/xtream-portal-compatibility.md), [category management](../architecture/category-management.md), [detail navigation](../architecture/portal-detail-navigation.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
+| Stalker/Ministra protocol, identity, sessions and routed views; `libs/portal/stalker` | [Stalker portal](../architecture/stalker-portal.md), [Stalker EPG](../architecture/stalker-epg.md) for EPG work, [store API baseline](../architecture/stalker-store-api-baseline.md) for store API changes | [Stalker](../../.codex/skills/stalker-portal/SKILL.md) |
+| Browser runtime, HTTP proxies, redirects and backend networking; `apps/web-backend`, `libs/shared/host-health` | [PWA/self-hosting](../architecture/pwa-self-hosted.md), [connectivity guard](../architecture/host-connectivity-guard.md), [Electron security](../architecture/electron-security.md) for desktop boundary changes | Read the affected runtime contract |
+| Source health and selective cleanup; portal shared data access | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health), [subscription expiry](../architecture/workspace-dashboard.md#source-subscription-expiry) | Read the affected provider skill |
+| Backup and restore; playlist persistence | [Backup/restore](../architecture/playlist-backup-restore.md), [database migrations](../../libs/shared/database/README.md) | Read the affected persistence skill |
+
+## Playback, navigation and UI
+
+| Area / code ownership | Required contracts | Repository skill |
+| --- | --- | --- |
+| Web engines, controls, tracks, PiP, radio and display sleep; `libs/ui/playback` | [Player controls](../architecture/player-controls-contract.md), [inline playback/diagnostics/recovery](../architecture/embedded-inline-playback.md) | Read the contract directly |
+| Embedded MPV, platform engines, addon, pinned runtime and packaging; Electron native services, `tools/embedded-mpv` | [Native MPV](../architecture/embedded-mpv-native.md), [runtime build and licensing](../../tools/embedded-mpv/README.md) | Read the contract directly |
+| Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) |
+| Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes |
+| Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes |
+| Downloads, offline details, catch-up and file availability; `libs/portal/downloads` | [Download manager](../architecture/download-manager.md), provider contract for URL resolution | Read the affected provider skill |
+| VOD source discovery, factual metadata and failover; `libs/portal/shared/data-access` | [VOD multi-source](../architecture/vod-multi-source.md) | [Xtream](../../.codex/skills/xtream-electron/SKILL.md) |
+| TMDB enrichment, artwork, actors and recommendations; `libs/services/src/lib/tmdb` | [TMDB contracts](../architecture/tmdb-metadata-enrichment.md), [dashboard](../architecture/workspace-dashboard.md) | UI skill for rendering changes |
+| Timezones, catch-up formatting and EPG display offsets | [Date handling](../architecture/date-handling.md), [Xtream compatibility](../architecture/xtream-portal-compatibility.md), [M3U EPG](../architecture/m3u-playlist-module.md) | Affected provider skill |
+| Website, blog and download pages; `apps/website` | [Website README](../../apps/website/README.md) | Use an available website skill |
+| Mock servers and fictional release fixtures; `apps/stalker-mock-server`, `apps/xtream-mock-server` | [Stalker mock](../architecture/stalker-mock-server.md), [Xtream mock](../architecture/xtream-mock-server.md), [release screenshot contract](../architecture/release-pipeline.md) | Release-cut for release captures |
+
+## Maintenance rules
+
+Keep unique behavior contracts in the authoritative document, procedures in a
+skill or development guide, and universal constraints in AGENTS.md. Update this
+map when ownership or a canonical destination changes. Preserve release skill
+mirrors; do not create extra copies of other contracts for individual agents.
+Use normal Markdown links for document destinations so `pnpm run agents:validate`
+can check them. Do not add root imports for the linked documents.
+
+The [migration ledger](agent-guidance-migration.md) explains how the original
+root instructions were accounted for. It is historical audit evidence, not
+another source of current runtime policy or required task context.
diff --git a/docs/maintenance/agent-guidance-migration.md b/docs/maintenance/agent-guidance-migration.md
new file mode 100644
index 000000000..ee93cad3d
--- /dev/null
+++ b/docs/maintenance/agent-guidance-migration.md
@@ -0,0 +1,802 @@
+# Agent guidance migration ledger
+
+Issue #1643, 2026-09-20. Immutable source commit: `d4df0fd81a71f0bf196ceac2b45efe3697feb973`.
+Original files: [AGENTS.md](https://github.com/4gray/iptvnator/blob/d4df0fd81a71f0bf196ceac2b45efe3697feb973/AGENTS.md)
+(1,217 lines, 88,565 bytes) and [CLAUDE.md](https://github.com/4gray/iptvnator/blob/d4df0fd81a71f0bf196ceac2b45efe3697feb973/CLAUDE.md)
+(2,022 lines, 231,858 bytes). Table coordinates refer to these immutable files,
+not the shortened roots. This is audit evidence; it is not a required startup document.
+
+## Method and coverage
+
+Every non-empty source block was inventoried. Headings, standalone labels,
+separator lines and Nx markers are structural; their contents are represented
+below. Lists are split at each top-level item; code fences stay intact. The
+17,847-character CLAUDE.md line 1315 is split into sentence-level entries so
+individual constraints do not disappear behind one row. Repeated entries from
+the two agents intentionally retain separate source references.
+
+716 content entries are accounted for below; there are no unassigned
+source blocks. "Existing contract" means the canonical document already carries
+the behavior and was reviewed instead of copying another summary. "Added" and
+"Moved" identify knowledge incorporated during this change. Destination sections
+are entry points into the owning contract; adjacent subheadings cover supporting
+exceptions and examples. The [context map](agent-context-map.md) is the live
+navigation surface; this ledger records the one-time relocation.
+
+## Corrections and consolidation decisions
+
+- Root growth/mirroring requirements are replaced by one source of common rules
+ and topic-specific maintenance in [agent workflow](../development/agent-workflow.md#maintaining-canonical-knowledge).
+ Root limits do not apply to canonical reference docs; those load on demand.
+- Stalker static URL wording was oversimplified. Missing flag evidence still
+ requires minting a link (with the directly playable radio exception), as
+ specified in [playback link resolution](../architecture/stalker-portal.md#playback-link-resolution)
+ and implemented by the existing Stalker link-semantics utilities. Keep the
+ authoritative decision table, not the old "otherwise static" shorthand.
+- Generic Electron detection is not an individual capability gate. Desktop also
+ has Chromium settings storage, PWA supports browser-selected uploads, and SQL
+ and IndexedDB schemas are not identical. Preserve the existing DataFactory
+ boundary with these corrections in [service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases).
+- Browser wake-lock auto-release clears the sentinel; the next media/visibility/
+ PiP sync reacquires. Preserve actual renderer behavior, not an implication of
+ immediate unconditional reacquisition; see [display sleep](../architecture/player-controls-contract.md#display-sleep-during-playback).
+- Full-file formatting recovery must preserve unrelated user edits. Replace the
+ unconditional merge-base overwrite recipe with [safe reconstruction](../development/agent-workflow.md#protected-markdown-edits).
+- The TypeScript hard limit is 400, not a variable 350–400; target under 300.
+ HostBinding/HostListener conventions are retained, not silently modernized.
+- Nx tools and global skills are optional. Retain discovery fallbacks and the
+ managed markers in [AGENTS.md](../../AGENTS.md#general-guidelines-for-working-with-nx).
+- Build/serve examples use local pnpm Nx or package scripts. Packaging uses
+ `package:app` (`make --prepackageOnly`), not the obsolete `electron-backend:package`
+ target suggested in the old alternative command.
+- Version-specific signing patch rationale was missing from canonical docs and
+ now lives in [the dependency contract](../architecture/nx-workspace-boundaries.md#electron-builder-signing-patch).
+- Exhaustive trees, diagrams and code examples are consolidated into the owning
+ architecture/maintenance sections; they do not become a second project map.
+ Existing snapshot inventories are navigation aids, not instructions to duplicate
+ every new project in the root. Plan saving respects active no-write modes.
+
+## Block inventory
+
+| Original source | Section and contract / example | Canonical destination | Disposition |
+| --- | --- | --- | --- |
+| AGENTS.md:3–3 | AGENTS.md — This file provides guidance to coding agents working in this repository. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:7–7 | Plan Mode — When an agent is in Plan Mode and produces a final <proposed_plan>, it must also save that finalized plan… | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:8–8 | Plan Mode — Save only finalized plans. Do not write interim exploration, questions, or draft revisions to .plans/. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:9–9 | Plan Mode — Use the filename pattern YYYY-MM-DD-short-topic.md such as .plans/2026-03-12-channel-filtering.md. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:10–10 | Plan Mode — If the intended filename already exists, append a numeric suffix such as -2, -3, and so on. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:14–14 | Agent Bootstrap — In a fresh worktree, run pnpm install --frozen-lockfile before relying on Nx project discovery, lint,… | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| AGENTS.md:15–15 | Agent Bootstrap — Re-run the install whenever the checkout moves — git pull, git reset --hard, a rebase, or a worktree… | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| AGENTS.md:16–16 | Agent Bootstrap — Never run prettier --write on CLAUDE.md, AGENTS.md or docs/. These files are not Prettier-clean upstream,… | [Protected Markdown edits](../development/agent-workflow.md#protected-markdown-edits) | Corrected safe restore |
+| AGENTS.md:17–17 | Agent Bootstrap — After dependencies are installed, verify workspace discovery with pnpm nx show projects. | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| AGENTS.md:18–18 | Agent Bootstrap — Use scoped path aliases from tsconfig.base.json such as @iptvnator/services, @iptvnator/shared/interfaces,… | [Import Aliases and Public APIs](../architecture/nx-workspace-boundaries.md#import-aliases-and-public-apis) | Existing contract |
+| AGENTS.md:19–19 | Agent Bootstrap — Every Nx project should keep scope:, domain:, and type: tags in project.json so… | [Project Tags](../architecture/nx-workspace-boundaries.md#project-tags) | Existing contract |
+| AGENTS.md:20–20 | Agent Bootstrap — See docs/architecture/nx-workspace-boundaries.md for the current Nx tag and alias policy. | [Import Aliases and Public APIs](../architecture/nx-workspace-boundaries.md#import-aliases-and-public-apis) | Existing contract |
+| AGENTS.md:21–22 | Agent Bootstrap — Keep nx and every official @nx/ package on the same exact version; run pnpm run deps:nx:validate after… | [Nx Dependency Updates](../architecture/nx-workspace-boundaries.md#nx-dependency-updates) | Existing contract |
+| AGENTS.md:23–24 | Agent Bootstrap — Use the Node version in .nvmrc for development and CI. Angular 22 requires Node ^22.22.3 || ^24.15.0 and… | [Angular 22 Toolchain Compatibility](../architecture/nx-workspace-boundaries.md#angular-22-toolchain-compatibility) | Existing contract |
+| AGENTS.md:25–29 | Agent Bootstrap — Vite 8.1.5, resolved through Angular's build tooling, retains upstream precise matchers and adds bounded… | [Vite Dev-Server Patch](../architecture/nx-workspace-boundaries.md#vite-dev-server-patch) | Existing contract |
+| AGENTS.md:30–40 | Agent Bootstrap — app-builder-lib 26.15.7 (electron-builder's macOS signing) is patched in… | [Electron Builder signing patch](../architecture/nx-workspace-boundaries.md#electron-builder-signing-patch) | Added missing detail |
+| AGENTS.md:41–47 | Agent Bootstrap — node-gyp is a declared root devDependency because apps/electron-backend/build-embedded-mpv.js resolves it… | [Packaging State](../architecture/embedded-mpv-native.md#packaging-state) | Existing contract |
+| AGENTS.md:48–52 | Agent Bootstrap — nx-electron@22.0.0 uses a local Nx 23 export-path patch and an explicit webpack-node-externals package… | [Angular 22 Toolchain Compatibility](../architecture/nx-workspace-boundaries.md#angular-22-toolchain-compatibility) | Existing contract |
+| AGENTS.md:53–59 | Agent Bootstrap — A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from… | [Shared Stylesheets and Cache Inputs](../architecture/nx-workspace-boundaries.md#shared-stylesheets-and-cache-inputs) | Existing contract |
+| AGENTS.md:60–63 | Agent Bootstrap — Update Nx with pnpm nx migrate nx@<target> --skipInstall, regenerate the lockfile, run generated… | [Nx Dependency Updates](../architecture/nx-workspace-boundaries.md#nx-dependency-updates) | Existing contract |
+| AGENTS.md:64–64 | Agent Bootstrap — ESLint enforces max-lines on TypeScript files: production code targets under 300 with a hard maximum of… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| AGENTS.md:65–65 | Agent Bootstrap — Project lint targets that shell out to eslint must quote the glob, e.g. eslint "apps/<project>//.ts". An… | [Command-Based Lint Targets](../architecture/nx-workspace-boundaries.md#command-based-lint-targets) | Existing contract |
+| AGENTS.md:66–66 | Agent Bootstrap — Repository-specific skills live under .codex/skills/. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:67–68 | Agent Bootstrap — Frontmatter descriptions are trigger-only and begin with Use when; keep each skill at or below 500 words. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:69–70 | Agent Bootstrap — Run pnpm run skills:validate after editing a committed skill or a literal path it documents. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:71–72 | Agent Bootstrap — Keep .codex and .claude copies of release-notes and release-cut byte-identical. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:76–76 | Documentation After Changes — After implementing a meaningful change, agents must assess whether canonical repo docs need updates before… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:77–77 | Documentation After Changes — Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes,… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:78–78 | Documentation After Changes — Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:79–82 | Documentation After Changes — Prefer updating an existing authoritative doc before creating a new one: 1. README.md for top-level… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:83–83 | Documentation After Changes — Keep the root CLAUDE.md and this file up to date. They are living documents: whenever a change touches… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:84–84 | Documentation After Changes — When adding a new feature area, check whether the Architecture or Key Features sections of CLAUDE.md… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:85–85 | Documentation After Changes — Do not let CLAUDE.md or AGENTS.md drift: a stale path or route in these files poisons the context of every… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:86–86 | Documentation After Changes — Repo docs are canonical even when they were originally drafted by an LLM. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:87–87 | Documentation After Changes — Final task summaries should state whether docs were updated and which doc changed. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| AGENTS.md:91–91 | Release Notes For User-Visible Changes — Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change… | [File](../../.changes/README.md#file) | Existing contract |
+| AGENTS.md:92–92 | Release Notes For User-Visible Changes — Name it <area>-<short-slug>.md; area matches the conventional-commit scope. There is no version field —… | [File](../../.changes/README.md#file) | Existing contract |
+| AGENTS.md:93–93 | Release Notes For User-Visible Changes — Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| AGENTS.md:94–94 | Release Notes For User-Visible Changes — type: internal records invisible maintenance. Internal notes stay collapsed in CHANGELOG.md, are omitted… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| AGENTS.md:95–95 | Release Notes For User-Visible Changes — highlight: <short headline> (max 60 characters, rejected on type: internal) marks a note as one of the… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| AGENTS.md:96–96 | Release Notes For User-Visible Changes — Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior… | [When a note is not needed](../../.changes/README.md#when-a-note-is-not-needed) | Existing contract |
+| AGENTS.md:97–97 | Release Notes For User-Visible Changes — CI enforces this: the "Release note gate" job in .github/workflows/ci.yml fails PRs that change runtime… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| AGENTS.md:98–98 | Release Notes For User-Visible Changes — The release-notes skill covers writing notes; the release-cut skill covers the full release sequence.… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| AGENTS.md:99–99 | Release Notes For User-Visible Changes — Validate before finishing: pnpm run release:notes:validate. | [Commands](../../.changes/README.md#commands) | Existing contract |
+| AGENTS.md:100–100 | Release Notes For User-Visible Changes — Announcement drafts and highlight cards are built from the same notes: pnpm --silent run… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| AGENTS.md:101–101 | Release Notes For User-Visible Changes — Pushes to master and v can publish Docker images. A v tag build creates a draft GitHub release. | [Two phases](../architecture/release-pipeline.md#two-phases) | Existing contract |
+| AGENTS.md:102–102 | Release Notes For User-Visible Changes — pnpm run release:verify:draft waits for that tag build (polling until the run is indexed, then gh run… | [Draft verification](../architecture/release-pipeline.md#draft-verification) | Existing contract |
+| AGENTS.md:103–103 | Release Notes For User-Visible Changes — Publishing the GitHub release verifies its Snap assets and automatically uploads them to edge;… | [After verification](../architecture/release-pipeline.md#after-verification) | Existing contract |
+| AGENTS.md:104–104 | Release Notes For User-Visible Changes — Release-post screenshots come only from the release capture script running against the mock servers. Never… | [Screenshots](../../.changes/README.md#screenshots) | Existing contract |
+| AGENTS.md:105–105 | Release Notes For User-Visible Changes — Final task summaries should state whether a release note was added or why it was skipped. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| AGENTS.md:109–114 | AppImage Manager Metadata — AppManager full-download discovery uses appImage.desktop.entry URL fields. Electron Builder generates the… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| AGENTS.md:118–118 | Upgrade And Migration Compatibility — Users may skip releases. The application must apply all required migrations in dependency order when… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| AGENTS.md:119–119 | Upgrade And Migration Compatibility — Preserve migration paths for existing persisted data. Do not make deleting a database/profile or… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| AGENTS.md:120–120 | Upgrade And Migration Compatibility — Create required tables first, add missing columns before dependent indexes/triggers/queries, and make… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| AGENTS.md:121–121 | Upgrade And Migration Compatibility — For persistence changes, test real SQLite initialization with representative historical schemas and data,… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| AGENTS.md:122–122 | Upgrade And Migration Compatibility — See libs/shared/database/README.md for SQLite migration ownership and validation guidance. | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| AGENTS.md:126–126 | Regression Prevention And Test Updates — Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:127–127 | Regression Prevention And Test Updates — Bug fixes must normally include regression coverage that fails on the old behavior and passes with the… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:128–128 | Regression Prevention And Test Updates — Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:129–133 | Regression Prevention And Test Updates — Default validation ladder: 1. Run targeted unit tests for directly affected projects with pnpm nx test… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:134–134 | Regression Prevention And Test Updates — Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access,… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:135–135 | Regression Prevention And Test Updates — Final task summaries must list tests added or updated, validation commands run with results, and any… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| AGENTS.md:139–146 | Legacy Desktop Profile Migration — electron-profile-bootstrap.ts selects the known v0.19 electron-backend profile before eager main-process… | [Desktop upgrades from legacy profiles](../architecture/m3u-playlist-module.md#desktop-upgrades-from-legacy-profiles) | Existing contract |
+| AGENTS.md:148–154 | Legacy Desktop Profile Migration — Startup shows AppStartupStatusComponent until the initial route and source inventory are ready, including… | [Desktop upgrades from legacy profiles](../architecture/m3u-playlist-module.md#desktop-upgrades-from-legacy-profiles) | Existing contract |
+| AGENTS.md:158–158 | Electron Debugging (CDP) — Start the Electron development app with: nx serve electron-backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:159–159 | Electron Debugging (CDP) — Package-script equivalent: pnpm run serve:backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:160–160 | Electron Debugging (CDP) — Electron is configured to start with: --remote-debugging-port=9222 | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:161–161 | Electron Debugging (CDP) — Connect Chrome DevTools Protocol tools to: 127.0.0.1:9222 | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:162–162 | Electron Debugging (CDP) — For Electron automation/debugging tasks, use the electron skill | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:163–163 | Electron Debugging (CDP) — Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:164–164 | Electron Debugging (CDP) — If DevTools is open, agent-browser --cdp 9222 ... may attach to the DevTools page instead of the IPTVnator… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:165–165 | Electron Debugging (CDP) — If that happens, inspect targets with curl http://127.0.0.1:9222/json/list and connect directly to the… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:166–166 | Electron Debugging (CDP) — The app holds a single-instance lock (acquireSingleInstanceLock in… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:170–170 | Trace / Debug Startup — Full startup tracing: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:172–174 | Trace / Debug Startup — bash IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:176–184 | Trace / Debug Startup — Narrower trace flags: - IPTVNATOR_TRACE_IPC=1 traces renderer window.electron. bridge calls -… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:186–188 | Trace / Debug Startup — Settings, portal request/response, and trace payloads must use @iptvnator/shared/logging or the redacting… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:190–190 | Trace / Debug Startup — If local Nx state gets weird before a rerun: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:192–194 | Trace / Debug Startup — bash pnpm nx reset | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:198–203 | agent-browser (global install) — bash agent-browser --cdp 9222 tab list agent-browser --cdp 9222 tab 1 agent-browser --cdp 9222 snapshot -i… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:207–209 | Fallback — bash npx --yes agent-browser --cdp 9222 tab list | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:213–218 | DevTools Workaround — bash ELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend curl http://127.0.0.1:9222/json/list agent-browser… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| AGENTS.md:222–226 | Xtream Category Management — The Electron Live TV, Movies, and Series category dialog applies Select/Deselect to search results while a… | [Behavior Notes](../architecture/category-management.md#behavior-notes) | Existing contract |
+| AGENTS.md:230–234 | XMLTV Response Compression — Electron decodes HTTP compression before the gzip file layer. For .gz/gzip metadata plus HTTP gzip, a… | [XMLTV response compression](../architecture/m3u-playlist-module.md#xmltv-response-compression) | Existing contract |
+| AGENTS.md:238–256 | XMLTV Source Removal — Saving Settings → EPG reconciles cached XMLTV with committed global URLs and all enabled M3U playlist… | [XMLTV source lifecycle](../architecture/m3u-playlist-module.md#xmltv-source-lifecycle) | Existing contract |
+| AGENTS.md:260–269 | Web Backend Provider Redirects — All four provider proxy routes use ValidatedHttpClient: automatic redirects are disabled, the initial URL… | [Web Backend](../architecture/pwa-self-hosted.md#web-backend) | Existing contract |
+| AGENTS.md:273–278 | Portal Connectivity Preference — Half-open trial slots follow the complete request lifetime with no elapsed-time expiry. All four… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| AGENTS.md:279–286 | Portal Connectivity Preference — Desktop Settings > General > Portal connections exposes default-on Settings.portalConnectivityGuard. Only… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| AGENTS.md:287–289 | Portal Connectivity Preference — Both account-info dialogs explain guard refusals with localized paused-request copy and Retry now; Stalker… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| AGENTS.md:293–299 | Live Channel Return — Xtream and Stalker (including radio) capture displayed playback order on explicit selection. Remote… | [Live channel return and playback order](../architecture/remote-control.md#live-channel-return-and-playback-order) | Existing contract |
+| AGENTS.md:303–309 | Stalker Live Search — ITV sidebar and fullscreen searches independently filter the complete selected category; only All Items… | [Full ITV Channel List Cache](../architecture/stalker-portal.md#full-itv-channel-list-cache) | Existing contract |
+| AGENTS.md:313–339 | Live TV Panel Levels — Portal live layouts (Xtream live, Stalker itv/radio) fold their panels from the outside in, in three… | [Collapsible Live Sidebar](../architecture/iptvnator-ui-guidelines.md#collapsible-live-sidebar) | Existing contract |
+| AGENTS.md:343–358 | Channel and Detail Keyboard Scrolling — Channel scroll owners use ChannelScrollFocusDirective; pointer selection focuses the viewport, native… | [Detail Scroll and Focus](../architecture/portal-detail-navigation.md#detail-scroll-and-focus) | Existing contract |
+| AGENTS.md:362–370 | Xtream Connection Test — Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before the click and can replace an… | [Explicit protocol discovery](../architecture/xtream-portal-compatibility.md#explicit-protocol-discovery) | Existing contract |
+| AGENTS.md:374–382 | Xtream Live Auto Format — The routed Xtream live host supplies liveAutoTsUrl only for Auto with explicit HLS+TS account evidence,… | [Initial Auto HLS failure](../architecture/xtream-portal-compatibility.md#initial-auto-hls-failure) | Existing contract |
+| AGENTS.md:386–402 | Xtream Catch-Up Server Timezone — The {Y-m-d:H-M} segment of a timeshift URL is read by the panel in ITS timezone (server_info.timezone),… | [Catch-Up Playback URLs](../architecture/xtream-portal-compatibility.md#catch-up-playback-urls) | Existing contract |
+| AGENTS.md:406–406 | Radio / Audio Player — M3U playlists can contain radio channels identified by the radio="true" attribute on #EXTINF lines. When a… | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:408–408 | Radio / Audio Player — The dedicated AudioPlayerComponent (libs/ui/playback/src/lib/audio-player/) renders instead of a video player | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:409–409 | Radio / Audio Player — The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:410–410 | Radio / Audio Player — The EPG panel is hidden (radio streams have no EPG data) | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:411–411 | Radio / Audio Player — The layout uses a cinematic hero pattern: the station logo is blurred as a full-area backdrop with a… | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:412–412 | Radio / Audio Player — Volume is shared with the video player via localStorage key 'volume' | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:413–413 | Radio / Audio Player — Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle) | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:414–414 | Radio / Audio Player — Radio detection in the video player template: activeChannel.radio === 'true' — this is a string… | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:416–416 | Radio / Audio Player — Key files: | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:418–418 | Radio / Audio Player — libs/ui/playback/src/lib/audio-player/audio-player.component.ts — the audio player component | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:419–419 | Radio / Audio Player — libs/ui/playback/src/lib/audio-player/audio-player.component.scss — cinematic hero styling | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:420–420 | Radio / Audio Player — libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html — template conditionals… | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:421–421 | Radio / Audio Player — libs/shared/interfaces/src/lib/channel.interface.ts — radio: string field on Channel interface | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Existing contract |
+| AGENTS.md:425–434 | M3U Playback Mode — isLikelyM3uVod in libs/shared/m3u-utils recognizes video-file extensions and exact /movie/, /movies/,… | [M3U Playback Mode](../architecture/m3u-playlist-module.md#m3u-playback-mode) | Existing contract |
+| AGENTS.md:438–440 | M3U URL User-Agent — PlaylistsService.getPlaylist() joins the per-playlist mutation queue so a route opened during refresh… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| AGENTS.md:441–445 | M3U URL User-Agent — The URL import form accepts an optional User-Agent and stores it as Playlist.userAgent. Electron sends it… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| AGENTS.md:446–448 | M3U URL User-Agent — Reuse the existing source editor and channel-over-playlist playback header precedence. Contract:… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| AGENTS.md:452–466 | Shared Player Controls — Stream info popover: an info button in the top-right corner of the shared controls overlay shows live… | [Stream info popover](../architecture/player-controls-contract.md#stream-info-popover) | Existing contract |
+| AGENTS.md:468–473 | Shared Player Controls — The Embedded MPV native-view dock follows app theme tokens as a solid app surface, including Material… | [Player And EPG Theme Boundaries](../architecture/iptvnator-ui-guidelines.md#player-and-epg-theme-boundaries) | Existing contract |
+| AGENTS.md:475–478 | Shared Player Controls — libs/ui/playback/src/lib/player-controls/ contains the additive, engine-neutral PlayerController contract,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| AGENTS.md:479–494 | Shared Player Controls — The subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file… | [Advanced subtitle support](../architecture/player-controls-contract.md#advanced-subtitle-support) | Existing contract |
+| AGENTS.md:495–501 | Shared Player Controls — In fullscreen, app-player-controls shows a pointer-transparent media-title overlay at the top while… | [Fullscreen media title](../architecture/player-controls-contract.md#fullscreen-media-title) | Existing contract |
+| AGENTS.md:502–524 | Shared Player Controls — Auto-hide pauses while the pointer is over the controls bar or keyboard focus is inside it, but only… | [Keyboard ownership](../architecture/player-controls-contract.md#keyboard-ownership) | Existing contract |
+| AGENTS.md:525–534 | Shared Player Controls — Persisted Settings.webPlayerSharedControls is default-ON (absent stored values coerce with !== false; only… | [Current status](../architecture/player-controls-contract.md#current-status) | Added missing detail |
+| AGENTS.md:535–543 | Shared Player Controls — Settings.showCaptions is deliberately outside this rollout gate: it is engine state, not controls UI.… | [Current status](../architecture/player-controls-contract.md#current-status) | Existing contract |
+| AGENTS.md:544–554 | Shared Player Controls — The modes differ in how long the preference is enforced. Shared controls are authoritative for the… | [Caption preference in both modes](../architecture/player-controls-contract.md#caption-preference-in-both-modes) | Existing contract |
+| AGENTS.md:555–563 | Shared Player Controls — Shared controls include a per-session quality menu (Auto + "1080p"-style levels via setQualityLevel;… | [Quality (bitrate/level) selection](../architecture/player-controls-contract.md#quality-bitratelevel-selection) | Existing contract |
+| AGENTS.md:564–568 | Shared Player Controls — Embedded MPV ignores the web-player preference. Frame-copy always uses shared DOM controls through its… | [Embedded MPV rendering constraints](../architecture/player-controls-contract.md#embedded-mpv-rendering-constraints) | Existing contract |
+| AGENTS.md:569–576 | Shared Player Controls — Frame-copy shared controls own DOM surface interactions, shortcuts, fullscreen, and recording feedback.… | [Embedded MPV rendering constraints](../architecture/player-controls-contract.md#embedded-mpv-rendering-constraints) | Existing contract |
+| AGENTS.md:577–660 | Shared Player Controls — WebPlayerViewComponent renders app-fullscreen-channel-panel… | [Fullscreen channel panel](../architecture/player-controls-contract.md#fullscreen-channel-panel) | Existing contract |
+| AGENTS.md:661–670 | Shared Player Controls — Embedded MPV seek steps (arrow keys, ±10 s buttons, PlayerController.seekBy) go through the relative… | [Resume And Track Handling](../architecture/embedded-mpv-native.md#resume-and-track-handling) | Existing contract |
+| AGENTS.md:671–676 | Shared Player Controls — M3U Favorites and Recently Viewed resolve Channel.drm into ResolvedPortalPlayback.drm through… | [DASH + ClearKey Playback](../architecture/m3u-playlist-module.md#dash--clearkey-playback) | Existing contract |
+| AGENTS.md:677–694 | Shared Player Controls — DASH (.mpd) sources play through a lazily imported Shaka Player source engine… | [DASH + ClearKey Playback](../architecture/m3u-playlist-module.md#dash--clearkey-playback) | Existing contract |
+| AGENTS.md:695–701 | Shared Player Controls — mpegts.js 1.8.1 errors from HTML5, Video.js, and ArtPlayer cross one version-locked structured evidence… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| AGENTS.md:702–834 | Shared Player Controls — Browser playback diagnostics and recovery policy live in libs/playback/util and are exported by… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| AGENTS.md:835–852 | Shared Player Controls — The built-in HTML5/hls.js player is the second guarded consumer. HtmlVideoPlayerComponent provides a… | [Web adapter and web-engine bridges](../architecture/player-controls-contract.md#web-adapter-and-web-engine-bridges) | Existing contract |
+| AGENTS.md:853–886 | Shared Player Controls — Video.js is the third guarded consumer. VjsPlayerComponent provides a component-scoped… | [Web adapter and web-engine bridges](../architecture/player-controls-contract.md#web-adapter-and-web-engine-bridges) | Existing contract |
+| AGENTS.md:887–904 | Shared Player Controls — ArtPlayer is the fourth guarded consumer. ArtPlayerComponent provides a component-scoped… | [Web adapter and web-engine bridges](../architecture/player-controls-contract.md#web-adapter-and-web-engine-bridges) | Existing contract |
+| AGENTS.md:905–914 | Shared Player Controls — Shared web picture-in-picture stays inside that default-on rollout. PlayerController exposes capability… | [Standard element picture-in-picture](../architecture/player-controls-contract.md#standard-element-picture-in-picture) | Existing contract |
+| AGENTS.md:915–933 | Shared Player Controls — WebVideoControlsAdapter supplies its current video and binding generation to… | [Standard element picture-in-picture](../architecture/player-controls-contract.md#standard-element-picture-in-picture) | Existing contract |
+| AGENTS.md:934–935 | Shared Player Controls — Canonical docs: docs/architecture/player-controls-contract.md and docs/architecture/embedded-mpv-native.md | [Web adapter and web-engine bridges](../architecture/player-controls-contract.md#web-adapter-and-web-engine-bridges) | Existing contract |
+| AGENTS.md:939–946 | Display Sleep During Playback — PlaybackKeepAwakeService (apps/web/src/app/services/playback-keep-awake.service.ts) watches every <video>… | [Display sleep during playback](../architecture/player-controls-contract.md#display-sleep-during-playback) | Added missing detail |
+| AGENTS.md:947–953 | Display Sleep During Playback — Electron: a main-process powerSaveBlocker behind window.electron.setPlaybackKeepAwake… | [Display sleep during playback](../architecture/player-controls-contract.md#display-sleep-during-playback) | Added missing detail |
+| AGENTS.md:954–956 | Display Sleep During Playback — Radio's <audio> deliberately never blocks display sleep. Embedded MPV holds its own blocker in… | [Display sleep during playback](../architecture/player-controls-contract.md#display-sleep-during-playback) | Added missing detail |
+| AGENTS.md:960–962 | Windows Embedded MPV Pin Maintenance — PR, master, and tag builds resolve the Windows runtime only from… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| AGENTS.md:963–964 | Windows Embedded MPV Pin Maintenance — Validate the checked-in schema and provenance with pnpm embedded-mpv:windows-runtime-pin:check. | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| AGENTS.md:965–968 | Windows Embedded MPV Pin Maintenance — Prepare a manual rotation with pnpm embedded-mpv:windows-runtime-pin:refresh -- --force. The weekly… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| AGENTS.md:969–972 | Windows Embedded MPV Pin Maintenance — The PAT-backed refresh job must keep every third-party action pinned to a full commit. Do not mirror the… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Added missing detail |
+| AGENTS.md:976–979 | Linux Embedded MPV Packaging — Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM, Pacman, Snap, and Flatpak are… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:980–986 | Linux Embedded MPV Packaging — Packaging runs three isolated profiles: - system: DEB/RPM/Pacman, no private native/lib, with package… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:987–990 | Linux Embedded MPV Packaging — Flatpak is an isolated packaging pass and keeps iptvnator as the real Electron ELF so Electron Builder's… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:991–993 | Linux Embedded MPV Packaging — The DEB system-runtime contract is Ubuntu 24.04+ (libmpv2). Ubuntu 22.04 provides libmpv1, so use the x64… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:994–997 | Linux Embedded MPV Packaging — Only iptvnator_mpv_helper may link libmpv. The Electron executable, Electron libraries, embedded_mpv.node,… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:998–1002 | Linux Embedded MPV Packaging — electron-backend/native{,//} is excluded from app.asar; afterPack exclusively writes the… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:1003–1005 | Linux Embedded MPV Packaging — Packaged addon, frame-reader, and helper discovery is package-owned app.asar.unpacked only. Writable… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:1006–1010 | Linux Embedded MPV Packaging — Pristine afterPack/unpacked layouts scan Electron libraries recursively. Extracted Snap payloads exclude… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:1011–1015 | Linux Embedded MPV Packaging — Linux frame-copy availability is fail-closed. The packaged manifest, artifact modes, declared bundled… | [Linux Support Matrix](../architecture/embedded-mpv-native.md#linux-support-matrix) | Existing contract |
+| AGENTS.md:1016–1027 | Linux Embedded MPV Packaging — Snap is core22/strict and uses an exact private shared-memory plug plus the graphics-core22 content plug… | [Frame-Copy Engine (Experimental, Apple Silicon, Linux and Windows)](../architecture/embedded-mpv-native.md#frame-copy-engine-experimental-apple-silicon-linux-and-windows) | Existing contract |
+| AGENTS.md:1028–1054 | Linux Embedded MPV Packaging — The probe and playback helper share one sanitized loader environment: ambient audit, preload, library,… | [Frame-Copy Engine (Experimental, Apple Silicon, Linux and Windows)](../architecture/embedded-mpv-native.md#frame-copy-engine-experimental-apple-silicon-linux-and-windows) | Existing contract |
+| AGENTS.md:1055–1059 | Linux Embedded MPV Packaging — In the exact packaged Flatpak /app context, reconstruct only Freedesktop Platform 24.08's immutable… | [Frame-Copy Engine (Experimental, Apple Silicon, Linux and Windows)](../architecture/embedded-mpv-native.md#frame-copy-engine-experimental-apple-silicon-linux-and-windows) | Existing contract |
+| AGENTS.md:1060–1063 | Linux Embedded MPV Packaging — The packaged x64 Playwright smoke runs its fixture-contract target first and passes Chromium… | [Same-Version Desktop Release Gate](../architecture/embedded-mpv-native.md#same-version-desktop-release-gate) | Existing contract |
+| AGENTS.md:1064–1116 | Linux Embedded MPV Packaging — Bundled Linux releases must publish the exact source archives/git records, checksums, licenses, flags,… | [Same-Version Desktop Release Gate](../architecture/embedded-mpv-native.md#same-version-desktop-release-gate) | Existing contract |
+| AGENTS.md:1120–1120 | Repo Skills — .codex/skills/iptvnator-nx-architecture/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1121–1121 | Repo Skills — .codex/skills/iptvnator-sqlite-db-worker/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1122–1122 | Repo Skills — .codex/skills/iptvnator-theme-style/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1123–1123 | Repo Skills — .codex/skills/iptvnator-ui-design/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1124–1124 | Repo Skills — .codex/skills/release-cut/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1125–1125 | Repo Skills — .codex/skills/release-notes/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1126–1126 | Repo Skills — .codex/skills/stalker-portal/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1127–1127 | Repo Skills — .codex/skills/xtream-electron/SKILL.md | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1129–1130 | Repo Skills — Descriptions and trigger conditions are canonical in each skill's frontmatter; do not duplicate them here. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| AGENTS.md:1137–1137 | General Guidelines for working with Nx — For navigating/exploring the workspace, invoke the nx-workspace skill first when it is available - it has… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1138–1138 | General Guidelines for working with Nx — When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through nx… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1139–1139 | General Guidelines for working with Nx — Prefix nx commands with the workspace's package manager (e.g., pnpm nx build, npm exec nx test) - avoids… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1140–1140 | General Guidelines for working with Nx — You have access to the Nx MCP server and its tools, use them to help the user | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1141–1141 | General Guidelines for working with Nx — For Nx plugin best practices, check node_modules/@nx/<plugin>/PLUGIN.md. Not all plugins have this file -… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1142–1142 | General Guidelines for working with Nx — NEVER guess CLI flags - always check nx_docs or --help first when unsure | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1146–1146 | Scaffolding & Generators — For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the nx-generate skill… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1150–1150 | When to use nx_docs — USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1151–1151 | When to use nx_docs — DON'T USE for: basic generator syntax (nx g @nx/react:app), standard commands, things you already know | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1152–1152 | When to use nx_docs — The nx-generate skill handles generator discovery internally - don't call nx_docs just to look up… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| AGENTS.md:1158–1162 | Catch-Up URL Copying — EPG timeline/list programme details expose Copy archive URL for supported Xtream/M3U archives, including… | [Copy archive URL](../architecture/m3u-playlist-module.md#copy-archive-url) | Existing contract |
+| AGENTS.md:1166–1196 | Xtream Archive Downloads — Desktop Xtream Live TV programme details can enqueue completed catch-up as contentType: catchup. The queue… | [Xtream archive downloads](../architecture/download-manager.md#xtream-archive-downloads) | Existing contract |
+| AGENTS.md:1200–1205 | Desktop Source Health — Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL checks through… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract |
+| AGENTS.md:1207–1213 | Desktop Source Health — Desktop Sources also offers library-wide selective cleanup through dialog-scoped SourceCleanupService.… | [Desktop inactive-source cleanup](../architecture/m3u-playlist-module.md#desktop-inactive-source-cleanup) | Existing contract |
+| AGENTS.md:1215–1217 | Desktop Source Health — Startup source auto-refresh uses SourceActivityService to protect busy IDs from cleanup. Late batch… | [Desktop inactive-source cleanup](../architecture/m3u-playlist-module.md#desktop-inactive-source-cleanup) | Existing contract |
+| CLAUDE.md:3–3 | CLAUDE.md — This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:5–5 | CLAUDE.md — > The process sections below (Plan Mode, Documentation After Changes, Upgrade And Migration Compatibility,… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:9–9 | Plan Mode — When Claude Code is in Plan Mode and produces a final <proposed_plan>, it must also save that finalized… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:10–10 | Plan Mode — Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to .plans/. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:11–11 | Plan Mode — Use the filename pattern YYYY-MM-DD-short-topic.md such as .plans/2026-03-12-channel-filtering.md. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:12–12 | Plan Mode — If the intended filename already exists, append a numeric suffix such as -2, -3, and so on. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:16–16 | Documentation After Changes — After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:17–17 | Documentation After Changes — Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes,… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:18–18 | Documentation After Changes — Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:19–22 | Documentation After Changes — Prefer updating an existing authoritative doc before creating a new one: 1. README.md for top-level… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:23–23 | Documentation After Changes — Keep this file (CLAUDE.md) itself up to date. It is a living document: whenever a change touches something… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:24–24 | Documentation After Changes — When adding a new feature area, check whether the Architecture or Key Features sections of CLAUDE.md… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:25–25 | Documentation After Changes — Do not let CLAUDE.md drift: a stale path or route in this file poisons the context of every future agent… | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:26–26 | Documentation After Changes — Repo docs are canonical even when they were originally drafted by an LLM. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:27–27 | Documentation After Changes — Final task summaries should state whether docs were updated and which doc changed. | [Maintaining canonical knowledge](../development/agent-workflow.md#maintaining-canonical-knowledge) | Moved / consolidated |
+| CLAUDE.md:31–31 | Release Notes For User-Visible Changes — Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change… | [File](../../.changes/README.md#file) | Existing contract |
+| CLAUDE.md:32–32 | Release Notes For User-Visible Changes — Name it <area>-<short-slug>.md; area matches the conventional-commit scope. There is no version field —… | [File](../../.changes/README.md#file) | Existing contract |
+| CLAUDE.md:33–33 | Release Notes For User-Visible Changes — Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| CLAUDE.md:34–34 | Release Notes For User-Visible Changes — type: internal records invisible maintenance. Internal notes stay collapsed in CHANGELOG.md, are omitted… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| CLAUDE.md:35–35 | Release Notes For User-Visible Changes — highlight: <short headline> (max 60 characters, rejected on type: internal) marks a note as one of the… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| CLAUDE.md:36–36 | Release Notes For User-Visible Changes — Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior… | [When a note is not needed](../../.changes/README.md#when-a-note-is-not-needed) | Existing contract |
+| CLAUDE.md:37–37 | Release Notes For User-Visible Changes — CI enforces this: the "Release note gate" job in .github/workflows/ci.yml fails PRs that change runtime… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| CLAUDE.md:38–38 | Release Notes For User-Visible Changes — The release-notes skill covers writing notes; the release-cut skill covers the full release sequence.… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| CLAUDE.md:39–39 | Release Notes For User-Visible Changes — Validate before finishing: pnpm run release:notes:validate. | [Commands](../../.changes/README.md#commands) | Existing contract |
+| CLAUDE.md:40–40 | Release Notes For User-Visible Changes — Announcement drafts and highlight cards are built from the same notes: pnpm --silent run… | [Writing the body](../../.changes/README.md#writing-the-body) | Existing contract |
+| CLAUDE.md:41–41 | Release Notes For User-Visible Changes — Pushes to master and v can publish Docker images. A v tag build creates a draft GitHub release. | [Two phases](../architecture/release-pipeline.md#two-phases) | Existing contract |
+| CLAUDE.md:42–42 | Release Notes For User-Visible Changes — pnpm run release:verify:draft waits for that tag build (polling until the run is indexed, then gh run… | [Draft verification](../architecture/release-pipeline.md#draft-verification) | Existing contract |
+| CLAUDE.md:43–43 | Release Notes For User-Visible Changes — Publishing the GitHub release verifies its Snap assets and automatically uploads them to edge;… | [After verification](../architecture/release-pipeline.md#after-verification) | Existing contract |
+| CLAUDE.md:44–44 | Release Notes For User-Visible Changes — Release-post screenshots come only from the release capture script running against the mock servers. Never… | [Screenshots](../../.changes/README.md#screenshots) | Existing contract |
+| CLAUDE.md:45–45 | Release Notes For User-Visible Changes — Final task summaries should state whether a release note was added or why it was skipped. | [Plans and completion reports](../development/agent-workflow.md#plans-and-completion-reports) | Moved / consolidated |
+| CLAUDE.md:49–54 | AppImage Manager Metadata — AppManager full-download discovery uses appImage.desktop.entry URL fields. Electron Builder generates the… | [Surfaces built from one set of notes](../architecture/release-pipeline.md#surfaces-built-from-one-set-of-notes) | Existing contract |
+| CLAUDE.md:58–58 | Upgrade And Migration Compatibility — Users may skip releases. The application must apply all required migrations in dependency order when… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:59–59 | Upgrade And Migration Compatibility — Preserve migration paths for existing persisted data. Do not make deleting a database/profile or… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:60–60 | Upgrade And Migration Compatibility — Create required tables first, add missing columns before dependent indexes/triggers/queries, and make… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:61–61 | Upgrade And Migration Compatibility — For persistence changes, test real SQLite initialization with representative historical schemas and data,… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:62–62 | Upgrade And Migration Compatibility — See libs/shared/database/README.md for SQLite migration ownership and validation guidance. | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:66–66 | Regression Prevention And Test Updates — Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:67–67 | Regression Prevention And Test Updates — Bug fixes must normally include regression coverage that fails on the old behavior and passes with the… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:68–68 | Regression Prevention And Test Updates — Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:69–73 | Regression Prevention And Test Updates — Default validation ladder: 1. Run targeted unit tests for directly affected projects with pnpm nx test… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:74–74 | Regression Prevention And Test Updates — Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access,… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:75–75 | Regression Prevention And Test Updates — Final task summaries must list tests added or updated, validation commands run with results, and any… | [Test impact and completion](../architecture/validation-map.md#test-impact-and-completion) | Existing contract |
+| CLAUDE.md:79–79 | Project Overview — IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Existing contract |
+| CLAUDE.md:81–81 | Project Overview — Dual Environment Support: The application is designed to work in both Electron and as a Progressive Web… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Existing contract |
+| CLAUDE.md:87–90 | Agent Bootstrap — bash pnpm install --frozen-lockfile pnpm nx show projects | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| CLAUDE.md:92–92 | Agent Bootstrap — Run the install step in a fresh worktree before relying on Nx discovery, lint, test, or build commands.… | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| CLAUDE.md:93–93 | Agent Bootstrap — Re-run the install whenever the checkout moves — git pull, git reset --hard, a rebase, or a worktree… | [Fresh Worktree Bootstrap and Discovery](../architecture/nx-workspace-boundaries.md#fresh-worktree-bootstrap-and-discovery) | Existing contract |
+| CLAUDE.md:94–94 | Agent Bootstrap — Never run prettier --write on CLAUDE.md, AGENTS.md or docs/. These files are not Prettier-clean upstream,… | [Protected Markdown edits](../development/agent-workflow.md#protected-markdown-edits) | Corrected safe restore |
+| CLAUDE.md:95–95 | Agent Bootstrap — Use scoped path aliases from tsconfig.base.json such as @iptvnator/services, @iptvnator/shared/interfaces,… | [Import Aliases and Public APIs](../architecture/nx-workspace-boundaries.md#import-aliases-and-public-apis) | Existing contract |
+| CLAUDE.md:96–96 | Agent Bootstrap — Do not add new imports from legacy bare aliases such as services, shared-interfaces, components,… | [Import Aliases and Public APIs](../architecture/nx-workspace-boundaries.md#import-aliases-and-public-apis) | Existing contract |
+| CLAUDE.md:97–97 | Agent Bootstrap — Every Nx project should keep scope:, domain:, and type: tags in project.json. | [Project Tags](../architecture/nx-workspace-boundaries.md#project-tags) | Existing contract |
+| CLAUDE.md:98–98 | Agent Bootstrap — See docs/architecture/nx-workspace-boundaries.md for the current Nx tag and alias policy. | [Import Aliases and Public APIs](../architecture/nx-workspace-boundaries.md#import-aliases-and-public-apis) | Existing contract |
+| CLAUDE.md:99–100 | Agent Bootstrap — Keep nx and every official @nx/ package on the same exact version; run pnpm run deps:nx:validate after… | [Nx Dependency Updates](../architecture/nx-workspace-boundaries.md#nx-dependency-updates) | Existing contract |
+| CLAUDE.md:101–102 | Agent Bootstrap — Use the Node version in .nvmrc for development and CI. Angular 22 requires Node ^22.22.3 || ^24.15.0 and… | [Angular 22 Toolchain Compatibility](../architecture/nx-workspace-boundaries.md#angular-22-toolchain-compatibility) | Existing contract |
+| CLAUDE.md:103–107 | Agent Bootstrap — Vite 8.1.5, resolved through Angular's build tooling, retains upstream precise matchers and adds bounded… | [Vite Dev-Server Patch](../architecture/nx-workspace-boundaries.md#vite-dev-server-patch) | Existing contract |
+| CLAUDE.md:108–118 | Agent Bootstrap — app-builder-lib 26.15.7 (electron-builder's macOS signing) is patched in… | [Electron Builder signing patch](../architecture/nx-workspace-boundaries.md#electron-builder-signing-patch) | Added missing detail |
+| CLAUDE.md:119–125 | Agent Bootstrap — node-gyp is a declared root devDependency because apps/electron-backend/build-embedded-mpv.js resolves it… | [Packaging State](../architecture/embedded-mpv-native.md#packaging-state) | Existing contract |
+| CLAUDE.md:126–130 | Agent Bootstrap — nx-electron@22.0.0 uses a local Nx 23 export-path patch and an explicit webpack-node-externals package… | [Angular 22 Toolchain Compatibility](../architecture/nx-workspace-boundaries.md#angular-22-toolchain-compatibility) | Existing contract |
+| CLAUDE.md:131–137 | Agent Bootstrap — A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from… | [Shared Stylesheets and Cache Inputs](../architecture/nx-workspace-boundaries.md#shared-stylesheets-and-cache-inputs) | Existing contract |
+| CLAUDE.md:138–141 | Agent Bootstrap — Update Nx with pnpm nx migrate nx@<target> --skipInstall, regenerate the lockfile, run generated… | [Nx Dependency Updates](../architecture/nx-workspace-boundaries.md#nx-dependency-updates) | Existing contract |
+| CLAUDE.md:142–142 | Agent Bootstrap — Repository-specific skills live under .codex/skills/. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| CLAUDE.md:143–144 | Agent Bootstrap — Frontmatter descriptions are trigger-only and begin with Use when; keep each skill at or below 500 words. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| CLAUDE.md:145–146 | Agent Bootstrap — Run pnpm run skills:validate after editing a committed skill or a literal path it documents. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| CLAUDE.md:147–148 | Agent Bootstrap — Keep .codex and .claude copies of release-notes and release-cut byte-identical. | [Repository skills](../development/agent-workflow.md#repository-skills) | Moved / consolidated |
+| CLAUDE.md:152–192 | Building and Serving — bash # Serve the Angular web app only (development mode, baseHref="/") pnpm run serve:frontend # or nx… | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:196–198 | Windows Embedded MPV Pin Maintenance — PR, master, and tag builds resolve the Windows runtime only from… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| CLAUDE.md:199–200 | Windows Embedded MPV Pin Maintenance — Validate the checked-in schema and provenance with pnpm embedded-mpv:windows-runtime-pin:check. | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| CLAUDE.md:201–204 | Windows Embedded MPV Pin Maintenance — Prepare a manual rotation with pnpm embedded-mpv:windows-runtime-pin:refresh -- --force. The weekly… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| CLAUDE.md:205–208 | Windows Embedded MPV Pin Maintenance — The PAT-backed refresh job must keep every third-party action pinned to a full commit. Do not mirror the… | [Windows CI pin lifecycle](../../tools/embedded-mpv/README.md#windows-ci-pin-lifecycle) | Existing contract |
+| CLAUDE.md:212–212 | Electron CDP Debugging — Start Electron in dev mode with: nx serve electron-backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:213–213 | Electron CDP Debugging — Package-script equivalent: pnpm run serve:backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:214–214 | Electron CDP Debugging — The workspace is configured to always launch Electron with: --remote-debugging-port=9222 | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:215–215 | Electron CDP Debugging — Use CDP clients (Chrome DevTools Protocol tools) against: 127.0.0.1:9222 | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:216–216 | Electron CDP Debugging — When the task is Electron automation/debugging, use the electron skill | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:217–217 | Electron CDP Debugging — Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:218–218 | Electron CDP Debugging — If DevTools is open, agent-browser --cdp 9222 ... may attach to the DevTools page instead of the IPTVnator… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:219–219 | Electron CDP Debugging — The app holds a single-instance lock (acquireSingleInstanceLock in… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:221–221 | Electron CDP Debugging — For startup tracing or white-screen debugging: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:223–225 | Electron CDP Debugging — bash IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:227–227 | Electron CDP Debugging — Useful narrower flags: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:229–229 | Electron CDP Debugging — IPTVNATOR_TRACE_IPC=1 traces renderer window.electron. bridge calls | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:230–230 | Electron CDP Debugging — IPTVNATOR_TRACE_DB=1 traces DB worker requests and DB progress events | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:231–231 | Electron CDP Debugging — IPTVNATOR_TRACE_SQL=1 traces SQLite statements in both main and worker connections | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:232–232 | Electron CDP Debugging — IPTVNATOR_TRACE_WINDOW=1 traces BrowserWindow navigation/load lifecycle | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:233–233 | Electron CDP Debugging — IPTVNATOR_TRACE_PLAYER=1 traces external-player activity, bounded Embedded MPV runtime-probe stderr, and… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:234–234 | Electron CDP Debugging — IPTVNATOR_TRACE_RENDERER_CONSOLE=1 mirrors renderer console logs into the Electron terminal | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:235–235 | Electron CDP Debugging — IPTVNATOR_PERF_CAPTURE=1 enables development/test-only, redacted M3U and Xtream preload IPC… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:236–236 | Electron CDP Debugging — IPTVNATOR_PERF_WORKER_PROFILING=1 enables development/test-only, request-scoped worker… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:238–240 | Electron CDP Debugging — Settings, portal request/response, and trace payloads must use @iptvnator/shared/logging or the redacting… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:242–242 | Electron CDP Debugging — If the Nx daemon gets into a bad state before rerunning Electron: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:244–246 | Electron CDP Debugging — bash pnpm nx reset | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:248–248 | Electron CDP Debugging — Use global agent-browser (preferred): | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:250–263 | Electron CDP Debugging — bash # Verify CDP targets agent-browser --cdp 9222 tab list # Switch to the app tab and inspect… | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:265–265 | Electron CDP Debugging — If agent-browser is not in PATH, use: | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:267–269 | Electron CDP Debugging — bash npx --yes agent-browser --cdp 9222 tab list | [Start and attach](../development/electron-debugging.md#start-and-attach) | Moved / consolidated |
+| CLAUDE.md:273–294 | Testing — bash # Run frontend tests pnpm run test:frontend # or pnpm nx test web # Run backend tests pnpm run… | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:296–296 | Testing — Before finishing behavior changes or bug fixes, follow Regression Prevention And Test Updates above and… | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:300–307 | Linting — bash # Lint all projects (CI runs this on master; PRs lint affected projects) pnpm run lint # Lint a… | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:309–315 | Linting — CI lints affected projects on PRs (nx affected) and every project on master pushes… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:317–317 | Linting — Production TypeScript: hard maximum 400 lines. | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:318–324 | Linting — Tests: 1200. /.spec.ts, /.spec-data.ts, /.e2e.ts and everything under apps/-e2e/ — a spec is a flat list… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:325–326 | Linting — Blank lines and comments are not counted (skipBlankLines, skipComments), so a docblock is never the reason… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:328–340 | Linting — Pre-existing oversized files are baselined in tools/eslint/max-lines-baseline.mjs; regenerate the baseline… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:342–349 | Linting — Project lint targets that shell out to eslint must quote the glob, e.g. eslint "apps/<project>//.ts". An… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:353–360 | Legacy Desktop Profile Migration — electron-profile-bootstrap.ts selects the known v0.19 electron-backend profile before eager main-process… | [Desktop upgrades from legacy profiles](../architecture/m3u-playlist-module.md#desktop-upgrades-from-legacy-profiles) | Existing contract |
+| CLAUDE.md:362–368 | Legacy Desktop Profile Migration — Startup shows AppStartupStatusComponent until the initial route and source inventory are ready, including… | [Desktop upgrades from legacy profiles](../architecture/m3u-playlist-module.md#desktop-upgrades-from-legacy-profiles) | Existing contract |
+| CLAUDE.md:374–374 | Monorepo Structure (Nx Workspace) — This is an Nx monorepo with the following structure: | [Placement Decision](../architecture/nx-workspace-boundaries.md#placement-decision) | Existing contract |
+| CLAUDE.md:376–376 | Monorepo Structure (Nx Workspace) — apps/web - Angular application (frontend, shared by Electron and PWA) | [Placement Decision](../architecture/nx-workspace-boundaries.md#placement-decision) | Existing contract |
+| CLAUDE.md:377–377 | Monorepo Structure (Nx Workspace) — apps/electron-backend - Electron main process | [Main-process ownership](../development/electron-debugging.md#main-process-ownership) | Moved / consolidated |
+| CLAUDE.md:378–378 | Monorepo Structure (Nx Workspace) — apps/web-backend - HTTP backend for the self-hosted PWA (/parse, /parse-xml, /xtream, /stalker CORS proxy… | [Web Backend](../architecture/pwa-self-hosted.md#web-backend) | Existing contract |
+| CLAUDE.md:379–379 | Monorepo Structure (Nx Workspace) — apps/remote-control-web - Mobile remote-control web app served by the Electron backend | [Remote Web App](../architecture/remote-control.md#remote-web-app) | Existing contract |
+| CLAUDE.md:380–380 | Monorepo Structure (Nx Workspace) — apps/web-e2e - Playwright E2E tests against the web app | [E2E](../architecture/validation-map.md#e2e) | Existing contract |
+| CLAUDE.md:381–381 | Monorepo Structure (Nx Workspace) — apps/electron-backend-e2e - Playwright E2E tests against the Electron app | [E2E](../architecture/validation-map.md#e2e) | Existing contract |
+| CLAUDE.md:382–382 | Monorepo Structure (Nx Workspace) — apps/stalker-mock-server - Mock Stalker/Ministra portal for dev and E2E | [Stalker Mock Server Architecture](../architecture/stalker-mock-server.md#stalker-mock-server-architecture) | Existing contract |
+| CLAUDE.md:383–383 | Monorepo Structure (Nx Workspace) — apps/xtream-mock-server - Mock Xtream Codes API for dev and E2E | [Xtream Mock Server — Architecture](../architecture/xtream-mock-server.md#xtream-mock-server--architecture) | Existing contract |
+| CLAUDE.md:384–384 | Monorepo Structure (Nx Workspace) — apps/website - Astro + Tailwind landing page, blog (guides carry faq: frontmatter → FAQPage JSON-LD and… | [IPTVnator Website](../../apps/website/README.md#iptvnator-website) | Existing contract |
+| CLAUDE.md:385–411 | Monorepo Structure (Nx Workspace) — libs/ - Shared libraries: - epg/data-access - EPG services, runtime bridge, program normalization -… | [Placement Decision](../architecture/nx-workspace-boundaries.md#placement-decision) | Existing contract |
+| CLAUDE.md:415–415 | Frontend Architecture (Angular) — State Management: Uses NgRx for playlist state management: | [State Management (libs/m3u-state/)](../architecture/m3u-playlist-module.md#state-management-libsm3u-state) | Existing contract |
+| CLAUDE.md:417–417 | Frontend Architecture (Angular) — Store configuration in apps/web/src/app/app.config.ts | [State Management (libs/m3u-state/)](../architecture/m3u-playlist-module.md#state-management-libsm3u-state) | Existing contract |
+| CLAUDE.md:418–418 | Frontend Architecture (Angular) — Playlist state, actions, effects, and reducers in libs/m3u-state/ | [State Management (libs/m3u-state/)](../architecture/m3u-playlist-module.md#state-management-libsm3u-state) | Existing contract |
+| CLAUDE.md:419–419 | Frontend Architecture (Angular) — Entity adapter pattern for managing playlists collection | [State Management (libs/m3u-state/)](../architecture/m3u-playlist-module.md#state-management-libsm3u-state) | Existing contract |
+| CLAUDE.md:420–420 | Frontend Architecture (Angular) — Router store integration for route-based state | [State Management (libs/m3u-state/)](../architecture/m3u-playlist-module.md#state-management-libsm3u-state) | Existing contract |
+| CLAUDE.md:422–422 | Frontend Architecture (Angular) — XtreamStore Architecture (Signal Store with Feature Composition): | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:424–424 | Frontend Architecture (Angular) — The Xtream Codes module uses NgRx Signal Store with a layered architecture: | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:426–455 | Frontend Architecture (Angular) — ┌─────────────────────────────────────────────────────────────────┐ │ PRESENTATION LAYER │ │ Components… | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:457–457 | Frontend Architecture (Angular) — File structure: | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:459–492 | Frontend Architecture (Angular) — libs/portal/xtream/ ├── data-access/src/lib/ │ ├── stores/ │ │ ├── features/ │ │ │ ├──… | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:494–494 | Frontend Architecture (Angular) — Key patterns: | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:496–496 | Frontend Architecture (Angular) — Feature stores: Each with.feature.ts uses signalStoreFeature() for focused functionality | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:497–497 | Frontend Architecture (Angular) — Facade pattern: XtreamStore composes all features, maintaining backward compatibility | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:498–499 | Frontend Architecture (Angular) — Data source abstraction: IXtreamDataSource has SQLite-backed and API/in-memory implementations | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:500–503 | Frontend Architecture (Angular) — Factory injection: provideXtreamDataSource() selects ElectronXtreamDataSource only when… | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:504–516 | Frontend Architecture (Angular) — Catalog lazy loading: catalog grids scroll infinitely instead of paging. withSelection keeps a… | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:518–518 | Frontend Architecture (Angular) — Xtream data strategies by runtime capability: | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:520–523 | Frontend Architecture (Angular) — | Capability | Strategy | | --------------------------------- |… | [Store composition and catalog windowing](../architecture/xtream-portal-compatibility.md#store-composition-and-catalog-windowing) | Added / consolidated |
+| CLAUDE.md:527–527 | M3U Playlist Module Architecture: — The M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels. | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:529–543 | M3U Playlist Module Architecture: — ┌─────────────────────────────────────────────────────────────────────┐ │ VIDEO PLAYER PAGE │ │… | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:545–545 | M3U Playlist Module Architecture: — The live EPG panel is a horizontal timeline ribbon under the player (app-epg-timeline,… | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:547–547 | M3U Playlist Module Architecture: — Collapsible live channel rail (M3U player, Xtream/Stalker live layouts, unified favorites/recent live… | [Collapsible Live Sidebar](../architecture/iptvnator-ui-guidelines.md#collapsible-live-sidebar) | Existing contract |
+| CLAUDE.md:549–549 | M3U Playlist Module Architecture: — Cover grids (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by… | [Cover Grids](../architecture/iptvnator-ui-guidelines.md#cover-grids) | Existing contract |
+| CLAUDE.md:551–551 | M3U Playlist Module Architecture: — Radio Channel Layout (when channel.radio === 'true'): | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:553–567 | M3U Playlist Module Architecture: — ┌─────────────────────────────────────────────────────────────────────┐ │ ┌─────────────┐… | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:569–569 | M3U Playlist Module Architecture: — Key radio behavior: | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:571–571 | M3U Playlist Module Architecture: — Detection: channel.radio === 'true' (string from M3U radio attribute) | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:572–572 | M3U Playlist Module Architecture: — The audio player always renders inline — shouldShowInlinePlayer is bypassed for radio | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:573–573 | M3U Playlist Module Architecture: — EPG panel is conditionally hidden in the template when radio is active | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:574–574 | M3U Playlist Module Architecture: — Volume is shared with video player via localStorage key 'volume' | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:575–575 | M3U Playlist Module Architecture: — Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:576–576 | M3U Playlist Module Architecture: — Component: libs/ui/playback/src/lib/audio-player/audio-player.component.ts | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:578–591 | M3U Playlist Module Architecture: — M3U Movie Recognition (VOD detail instead of the EPG zone): an M3U entry recognized as a movie FILE swaps… | [Movie Recognition (VOD Detail View)](../architecture/m3u-playlist-module.md#movie-recognition-vod-detail-view) | Existing contract |
+| CLAUDE.md:593–601 | M3U Playlist Module Architecture: — M3U playback mode is independent of this metadata gate: isLikelyM3uVod recognizes video-file extensions… | [M3U Playback Mode](../architecture/m3u-playlist-module.md#m3u-playback-mode) | Existing contract |
+| CLAUDE.md:603–603 | M3U Playlist Module Architecture: — Channel List Component Structure (parent coordinator pattern): | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:605–613 | M3U Playlist Module Architecture: — libs/ui/components/src/lib/channel-list-container/ ├── channel-list-container.component.ts # Parent -… | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:615–615 | M3U Playlist Module Architecture: — Key patterns: | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:617–617 | M3U Playlist Module Architecture: — EnrichedChannel: Pre-computed EPG data attached to channels for performance | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:618–618 | M3U Playlist Module Architecture: — Parent coordinator: Manages shared signals (channelEpgMap, progressTick, favoriteIds) | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:619–619 | M3U Playlist Module Architecture: — Virtual scrolling: CDK virtual scroll for 90,000+ channel lists | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:620–620 | M3U Playlist Module Architecture: — Infinite scroll: IntersectionObserver in groups view loads 50 items at a time | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:621–621 | M3U Playlist Module Architecture: — Global progress tick: Single 30s interval instead of per-item intervals | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:623–623 | M3U Playlist Module Architecture: — State management via NgRx (libs/m3u-state/): | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:625–625 | M3U Playlist Module Architecture: — PlaylistActions: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:626–626 | M3U Playlist Module Architecture: — ChannelActions: setChannels, setActiveChannel, setAdjacentChannelAsActive | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:627–627 | M3U Playlist Module Architecture: — EpgActions: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:628–628 | M3U Playlist Module Architecture: — FavoritesActions: updateFavorites, setFavorites, hydrateFavorites | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:630–630 | M3U Playlist Module Architecture: — See docs/architecture/m3u-playlist-module.md for complete documentation. | [Channel List Container](../architecture/m3u-playlist-module.md#channel-list-container) | Existing contract |
+| CLAUDE.md:632–632 | M3U Playlist Module Architecture: — Routing: Lazy-loaded routes in apps/web/src/app/app.routes.ts. All user-facing routes are nested under the… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:634–634 | M3U Playlist Module Architecture: — Dashboard: /workspace/dashboard; sources overview: /workspace/sources | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:635–635 | M3U Playlist Module Architecture: — M3U player: /workspace/playlists/:id (children: favorites, recent, :view) — routes in… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:636–636 | M3U Playlist Module Architecture: — Xtream Codes: /workspace/xtreams/:id (children: live, vod, series, search, actor/:personId, discover,… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:637–637 | M3U Playlist Module Architecture: — Stalker portal: /workspace/stalker/:id (children: itv, vod, radio, series, favorites, recent, search,… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:638–638 | M3U Playlist Module Architecture: — Global collections: /workspace/global-favorites, /workspace/global-recent | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:639–639 | M3U Playlist Module Architecture: — Global search: /workspace/search (Electron-only; a guard redirects the PWA to /workspace/sources) | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:640–644 | M3U Playlist Module Architecture: — Downloads: /workspace/downloads with focused /workspace/downloads/:downloadId; source-scoped equivalents… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:645–645 | M3U Playlist Module Architecture: — Settings: /workspace/settings/:section — one page per section (general, playback, epg, dashboard,… | [Route Contract](../architecture/workspace-shell.md#route-contract) | Existing contract |
+| CLAUDE.md:647–647 | M3U Playlist Module Architecture: — Service Architecture (Factory Pattern): | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:649–649 | M3U Playlist Module Architecture: — Abstract DataService class in libs/services/src/lib/data.service.ts defines the contract | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:650–652 | M3U Playlist Module Architecture: — Two environment-specific implementations: - ElectronService… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:653–659 | M3U Playlist Module Architecture: — Factory function DataFactory() in apps/web/src/app/app.config.ts determines which implementation to… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:663–666 | Data Storage (Environment-Specific): — Electron: SQLite database via Drizzle ORM (better-sqlite3 driver) - Location:… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:667–670 | Data Storage (Environment-Specific): — PWA (Web): IndexedDB via ngx-indexed-db - Browser-based NoSQL storage - Same schema structure but… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:674–678 | TypeScript File Size Rule: — Keep production TypeScript files under 300 lines. Hard maximum is 350–400 lines, and CI enforces the 400.… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:680–680 | TypeScript File Size Rule: — When creating new files, design them to stay within this limit from the start. | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:681–681 | TypeScript File Size Rule: — When adding a feature to an existing file that would push it past 350 lines, refactor first: extract… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:682–682 | TypeScript File Size Rule: — When you notice a file already exceeds 350 lines, proactively suggest a refactoring (or perform it if the… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:684–684 | TypeScript File Size Rule: — Typical split strategies: | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:686–686 | TypeScript File Size Rule: — Angular components: extract child components, move logic to a dedicated service or store feature | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:687–687 | TypeScript File Size Rule: — Signal store features: split into smaller with feature functions in separate files | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:688–688 | TypeScript File Size Rule: — Services: split by responsibility (e.g. separate API, transformation, and state concerns) | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:689–689 | TypeScript File Size Rule: — Utility files: group by domain and export from a barrel index.ts | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:691–691 | TypeScript File Size Rule: — This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a… | [TypeScript File Size](../architecture/nx-workspace-boundaries.md#typescript-file-size) | Existing contract |
+| CLAUDE.md:697–697 | Angular Coding Standards: — This project uses modern Angular signal-based APIs and patterns. ALWAYS use the following: | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:699–699 | Angular Coding Standards: — Component Queries: Use viewChild(), viewChildren(), contentChild(), contentChildren() instead of… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:701–709 | Angular Coding Standards: — typescript // ✅ Correct - Signal-based readonly menu = viewChild.required<MatMenu>('menuRef'); readonly… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:711–711 | Angular Coding Standards: — Important: When using signals in templates with properties that expect non-signal values, unwrap the… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:713–719 | Angular Coding Standards: — html <!-- ✅ Correct - Unwrap the signal --> <button [matMenuTriggerFor]="menu()">Open Menu</button> <!-- ❌… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:721–721 | Angular Coding Standards: — Component Inputs/Outputs: Use input() and output() functions instead of @Input() and @Output() decorators | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:723–733 | Angular Coding Standards: — typescript // ✅ Correct - Signal-based readonly title = input.required<string>(); readonly size =… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:735–735 | Angular Coding Standards: — Reactive State: Use signal primitives for reactive state management | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:737–747 | Angular Coding Standards: — typescript // ✅ Use signal(), computed(), effect(), linkedSignal() readonly count = signal(0); readonly… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:749–749 | Angular Coding Standards: — Host Bindings: Use @HostBinding() and @HostListener() decorators (these don't have signal equivalents yet) | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:751–754 | Angular Coding Standards: — typescript @HostBinding('class.active') get isActive() { return this.active(); } @HostListener('click')… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:756–756 | Angular Coding Standards: — Control Flow: Use @if, @for, @switch instead of ngIf, ngFor, ngSwitch | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:758–771 | Angular Coding Standards: — typescript // ✅ Correct - Modern syntax @if (isLoggedIn()) { <p>Welcome!</p> } @for (item of items();… | [Angular conventions](../development/agent-workflow.md#angular-conventions) | Moved / consolidated |
+| CLAUDE.md:775–775 | Backend Architecture (Electron) — Main Entry: apps/electron-backend/src/main.ts | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:777–777 | Backend Architecture (Electron) — Bootstraps Electron app and initializes database | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:778–778 | Backend Architecture (Electron) — Registers event handlers for IPC communication | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:779–779 | Backend Architecture (Electron) — Creates the main window per the startup window mode (app/app.ts initMainWindow, resolver in… | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:780–780 | Backend Architecture (Electron) — Persists the app zoom level (issue #1109): the preload restores it with webFrame.setZoomLevel (temporary,… | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:781–781 | Backend Architecture (Electron) — Recovers a renderer reload on an in-app route: the packaged renderer is index.html over file:// with path… | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:782–782 | Backend Architecture (Electron) — Holds a single-instance lock (app/services/single-instance.ts), requested after the userData override so… | [Window Chrome And Custom Title Bar](../architecture/workspace-shell.md#window-chrome-and-custom-title-bar) | Existing contract |
+| CLAUDE.md:786–786 | Database: — ORM: Drizzle ORM with better-sqlite3 (local SQLite file) | [Exports](../../libs/shared/database/README.md#exports) | Existing contract |
+| CLAUDE.md:787–787 | Database: — Location: ~/.iptvnator/databases/iptvnator.db (avoids spaces in path) | [Exports](../../libs/shared/database/README.md#exports) | Existing contract |
+| CLAUDE.md:788–801 | Database: — Schema (libs/shared/database/src/lib/schema.ts — canonical;… | [Exports](../../libs/shared/database/README.md#exports) | Existing contract |
+| CLAUDE.md:802–805 | Database: — Connection: libs/shared/database/src/lib/connection.ts - createTables() auto-creates tables on init… | [Exports](../../libs/shared/database/README.md#exports) | Existing contract |
+| CLAUDE.md:809–812 | IPC Communication: — Preload script: apps/electron-backend/src/app/api/main.preload.ts - Exposes window.electron API via… | [Preload API Type Contract](../architecture/electron-security.md#preload-api-type-contract) | Existing contract |
+| CLAUDE.md:813–823 | IPC Communication: — Event handlers: apps/electron-backend/src/app/events/ - database.events.ts - Database CRUD operations -… | [Preload API Type Contract](../architecture/electron-security.md#preload-api-type-contract) | Existing contract |
+| CLAUDE.md:825–825 | IPC Communication: — Workers (apps/electron-backend/src/app/workers/): | [Current Ownership](../architecture/sqlite-db-worker.md#current-ownership) | Existing contract |
+| CLAUDE.md:827–827 | IPC Communication: — EPG parsing: epg-parser.worker.ts; main-process worker lifecycle is coordinated from… | [Current Ownership](../architecture/sqlite-db-worker.md#current-ownership) | Existing contract |
+| CLAUDE.md:828–828 | IPC Communication: — Non-EPG SQLite work: database.worker.ts (see docs/architecture/sqlite-db-worker.md). Catalog deletes and… | [Current Ownership](../architecture/sqlite-db-worker.md#current-ownership) | Existing contract |
+| CLAUDE.md:829–829 | IPC Communication: — Playlist refresh: playlist-refresh.worker.ts; explicit cancellation is main-process-owned and terminates… | [Current Ownership](../architecture/sqlite-db-worker.md#current-ownership) | Existing contract |
+| CLAUDE.md:833–837 | Xtream Category Management — The Electron Live TV, Movies, and Series category dialog applies Select/Deselect to search results while a… | [Behavior Notes](../architecture/category-management.md#behavior-notes) | Existing contract |
+| CLAUDE.md:843–851 | Xtream Connection Test — Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before the click and can replace an… | [Connection Input](../architecture/xtream-portal-compatibility.md#connection-input) | Existing contract |
+| CLAUDE.md:855–863 | Xtream Live Auto Format — The routed Xtream live host supplies liveAutoTsUrl only for Auto with explicit HLS+TS account evidence,… | [Initial Auto HLS failure](../architecture/xtream-portal-compatibility.md#initial-auto-hls-failure) | Existing contract |
+| CLAUDE.md:867–883 | Xtream Catch-Up Server Timezone — The {Y-m-d:H-M} segment of a timeshift URL is read by the panel in ITS timezone (server_info.timezone),… | [Catch-Up Playback URLs](../architecture/xtream-portal-compatibility.md#catch-up-playback-urls) | Existing contract |
+| CLAUDE.md:887–889 | M3U URL User-Agent — PlaylistsService.getPlaylist() joins the per-playlist mutation queue so a route opened during refresh… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:890–894 | M3U URL User-Agent — The URL import form accepts an optional User-Agent and stores it as Playlist.userAgent. Electron sends it… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:895–897 | M3U URL User-Agent — Reuse the existing source editor and channel-over-playlist playback header precedence. Contract:… | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:901–901 | Playlist Support: — M3U/M3U8 files (local or URL) | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:902–902 | Playlist Support: — Xtream Codes API (username, password, serverUrl) | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:903–903 | Playlist Support: — Stalker portal (macAddress, url) | [User-Agent for URL sources](../architecture/m3u-playlist-module.md#user-agent-for-url-sources) | Existing contract |
+| CLAUDE.md:905–920 | Playlist Support: — Stalker playback links: create_link runs only when the catalog row sets use_http_tmp_link or… | [Playback Link Resolution](../architecture/stalker-portal.md#playback-link-resolution) | Corrected; see decisions |
+| CLAUDE.md:922–941 | Playlist Support: — Opening a playlist from the OS (Electron only): a .m3u/.m3u8 path passed on the command line, opened… | [Opening playlists from the operating system](../architecture/m3u-playlist-module.md#opening-playlists-from-the-operating-system) | Added / consolidated |
+| CLAUDE.md:943–959 | Playlist Support: — The OS-level registration that makes those paths reachable is fileAssociations in electron-builder.json —… | [Opening playlists from the operating system](../architecture/m3u-playlist-module.md#opening-playlists-from-the-operating-system) | Added / consolidated |
+| CLAUDE.md:963–968 | Video Players: — The Embedded MPV native-view dock follows app theme tokens as a solid app surface, including Material… | [Player And EPG Theme Boundaries](../architecture/iptvnator-ui-guidelines.md#player-and-epg-theme-boundaries) | Existing contract |
+| CLAUDE.md:970–977 | Video Players: — Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5 player and ArtPlayer pick their… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:978–986 | Video Players: — mpegts.js 1.8.1 errors from all three built-in players cross one version-locked structured evidence… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:987–1119 | Video Players: — Browser playback diagnostics and recovery policy live in libs/playback/util and are exported by… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1120–1125 | Video Players: — M3U Favorites and Recently Viewed resolve Channel.drm into ResolvedPortalPlayback.drm through… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1126–1150 | Video Players: — DASH + ClearKey (M3U module): .mpd channels play through a lazily loaded Shaka Player source engine inside… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1151–1165 | Video Players: — Stream info popover: an info button in the top-right corner of the shared controls overlay shows live… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1166–1166 | Video Players: — External players: MPV, VLC (via IPC to Electron backend) | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1167–1181 | Video Players: — Display sleep during playback: PlaybackKeepAwakeService… | [Codec And Container Diagnostics](../architecture/embedded-inline-playback.md#codec-and-container-diagnostics) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Two per-session knobs are captured at session creation from the main-process settings mirror… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Contract: docs/architecture/embedded-mpv-native.md ("Session Options", "Network Auto-Reconnect"). macOS… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Windows uses in-process libmpv with --wid against an app-owned child HWND; | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Linux spawns an out-of-process mpv --wid=<x11-window> controlled over a JSON IPC socket (X11/XWayland… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Renderer bounds are CSS pixels; the service converts them to native units in the main process… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Arrow-key and ±10 s button steps go through the relative seekEmbeddedMpvBy IPC (mpv seek <delta>… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1182–1182 | Video Players: — Service: apps/electron-backend/src/app/services/embedded-mpv-native.service.ts; full architecture:… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1183–1314 | Video Players: — Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux x64 + Windows; enabled via… | [How It Is Embedded](../architecture/embedded-mpv-native.md#how-it-is-embedded) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Shared player-controls layer: libs/ui/playback/src/lib/player-controls/ exports the engine-neutral… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Its subtitle menu carries capability-gated advanced subtitle support (#1408): external subtitle file… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — HTML5/ArtPlayer implement it through the neutral source bridge (.srt/.vtt via a DOM file picker with… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Embedded MPV frame-copy implements it through new helper protocol commands… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Video.js shared mode, vendor-chrome paths, native-view, and the Linux out-of-process path advertise no… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Contract details: docs/architecture/player-controls-contract.md ("Advanced subtitle support"). | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Shared controls include a per-session quality menu (Auto + “1080p”-style levels via setQualityLevel; | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — AUTO_QUALITY_LEVEL_ID restores ABR): the capability derives from the manifest — advertised only when the… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — In fullscreen, app-player-controls shows a pointer-transparent media-title overlay at the top while… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Persisted Settings.webPlayerSharedControls is default-ON (absent stored values coerce with !== false in… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The shared surface has explicit touch semantics (ControlsSurface.wasTouchInteraction): viewport taps… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Only keyboard-originated focus pins the bar open: Chromium also focuses a clicked <button>, so… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — A completed pointer click then releases the focus it left on the control (onBarClick →… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Keyboard activation (empty pointerType) keeps focus, only buttons and range sliders are released, Chromium… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Deliberately dropped vs. vendor chrome (opt-out retains them): Video.js spatial navigation, ArtPlayer… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — WebPlayerViewComponent snapshots the preference into the immutable token for each new player host. | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The parent /workspace route awaits the initial SettingsStore load, including cold-start direct links,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Saving applies to the next host without an application restart; an existing session never changes controls… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The Embedded MPV host selects exactly one controls UI for its reported engine. | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — showControls=false detaches the shared surface, modal overlays gate frame-copy playback shortcuts,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The built-in HTML5/hls.js player is the second guarded consumer: HtmlVideoPlayerComponent provides a… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — HtmlVideoElementSession owns native video-event lifecycle, persisted volume, and start-time/time/ended… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Video.js is the third guarded consumer: VjsPlayerComponent provides a component-scoped… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — ArtPlayer is the fourth guarded consumer: ArtPlayerComponent provides a component-scoped… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — ArtPlayerSourceSession owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — WebPlayerViewComponent.resolvedIsLive supplies authoritative metadata; visible playback diagnostics… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The view also renders app-fullscreen-channel-panel beside the engine, staged on fullscreenSurface and… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Providers: M3U VideoPlayerComponent (app-m3u-fullscreen-channel-list, a local icon-only… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Xtream's two PortalChannelsListComponent instances relay favorite toggles through… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — CDK overlays follow the fullscreen element via FullscreenOverlayContainer in app.config.ts. | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Series playback gets the same panel as an episode list: PortalInlinePlayerComponent — the component both… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Movies never get it (contentType !== 'episode' → null), external MPV/VLC never mount the inline player,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — M3U also zaps with PageUp/PageDown, yielding to already-handled events and menu/dialog or scrollable-list… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — While the live web-player host owns fullscreen (itself, or through the nested surface a legacy player… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Contract section "Fullscreen channel panel" in docs/architecture/player-controls-contract.md. | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — On the preference-off path, all three web players retain their existing controls, source behavior, and… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The legacy Video.js chrome also releases the focus a pointer interaction leaves on a control… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — It is driven mainly by focusin, not the click, because choosing a menu item moves focus to the menu button… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The release is scoped to .vjs-control-bar so the caption-settings dialog (a modal sibling of the bar)… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Settings.showCaptions is deliberately outside this rollout gate: it is engine state, so the preference-off… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — The two modes differ in how long it is enforced: shared controls are authoritative for the session (user… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Mode selection is the optional playbackStarted probe the legacy owners pass to all three helpers (HLS,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — WebPlayerViewComponent reads it from SettingsStore instead of a host input so every host (M3U,… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1315–1315 | Video Players: — Contract: docs/architecture/player-controls-contract.md. | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1316–1325 | Video Players: — Shared web picture-in-picture stays inside that default-on rollout. PlayerController exposes capability… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1326–1344 | Video Players: — WebVideoControlsAdapter supplies its current video and binding generation to… | [Landed architecture](../architecture/player-controls-contract.md#landed-architecture) | Existing contract |
+| CLAUDE.md:1348–1376 | Download Manager: — Fresh Xtream movie and series-episode downloads propagate the playlist's User-Agent, Referer, and Origin,… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1377–1380 | Download Manager: — The desktop-only manager shares one global download store across the global, Xtream-scoped, and… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1381–1420 | Download Manager: — Series details route individual and selected-season episode downloads through the provider-neutral… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1421–1438 | Download Manager: — Episode ownership uses normalized episode.id as the canonical xtreamId for both providers; Stalker… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1439–1444 | Download Manager: — Ready cards (movies, grouped series, and standalone episodes) open a focused local detail; local file… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1445–1449 | Download Manager: — Downloads capture a versioned metadata snapshot from the rendered Xtream or Stalker movie/episode detail… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1450–1459 | Download Manager: — View in portal resolves a concrete Xtream category/item route. Stalker accepts a recently-viewed shape… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1460–1462 | Download Manager: — Download rows and local files survive source deletion. The global offline library remains visible with no… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1463–1465 | Download Manager: — If a finalized file disappears while a focused detail is open, the authoritative download list refreshes… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1466–1497 | Download Manager: — Live-TV recordings (Embedded MPV stream-record) are tracked beside downloads in their own recordings table… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1498–1499 | Download Manager: — Canonical contract: docs/architecture/download-manager.md; provider handoff:… | [Queuing, persistence, and UX notes](../architecture/download-manager.md#queuing-persistence-and-ux-notes) | Existing contract |
+| CLAUDE.md:1501–1501 | Download Manager: — Collection Detail Portal Handoff (View in portal for inline details): | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1503–1507 | Download Manager: — Details opened outside portal category context — /workspace/global-favorites, /workspace/global-recent… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1508–1515 | Download Manager: — Visibility is DI-gated, never URL-sniffed: app-view-in-portal-action… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1516–1521 | Download Manager: — Targets come from getUnifiedCollectionDetailNavigation()… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1522–1558 | Download Manager: — Stalker section resolution mirrors resolveStalkerCollectionDetailMode()… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1559–1561 | Download Manager: — Unlike the download handoff this bridge does NOT pass detailPresentation: 'provider-only' — the item… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1562–1562 | Download Manager: — Contract: docs/architecture/portal-detail-navigation.md. | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1566–1566 | VOD/Series Detail Pages (two-state layout): — Xtream and Stalker detail pages use the shared PortalDetailShellComponent… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1567–1567 | VOD/Series Detail Pages (two-state layout): — The inline player (PortalInlinePlayerComponent) renders a full-width theater stage… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1568–1568 | VOD/Series Detail Pages (two-state layout): — For inline series playback on wide windows the stage instead docks the player left and shows an "Up Next"… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1569–1569 | VOD/Series Detail Pages (two-state layout): — Watch state derives from inlinePlayback() !== null only; external MPV/VLC playback keeps the browse… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1570–1570 | VOD/Series Detail Pages (two-state layout): — Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1571–1571 | VOD/Series Detail Pages (two-state layout): — A successful external MPV/VLC episode launch immediately persists the selected episode as the latest… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1572–1572 | VOD/Series Detail Pages (two-state layout): — Stalker preserves this contract for regular /series, embedded VOD series[], and lazy Ministra VOD… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1573–1573 | VOD/Series Detail Pages (two-state layout): — Hosts pass hero chips/meta/actions as appDetailTags/appDetailMeta/appDetailActions templates; the shell… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1574–1574 | VOD/Series Detail Pages (two-state layout): — Seasons are tabs (SeasonTabsComponent, dropdown beyond 6 seasons; the dropdown's menu rows and closed… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1575–1575 | VOD/Series Detail Pages (two-state layout): — The season header hosts a bulk watched toggle next to "Download season" (both portals): marking writes… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1576–1576 | VOD/Series Detail Pages (two-state layout): — Movies get the same manual toggle in the detail action row (Xtream: icon square after Favorite,… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1577–1577 | VOD/Series Detail Pages (two-state layout): — The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for an Xtream… | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1578–1578 | VOD/Series Detail Pages (two-state layout): — See docs/architecture/embedded-inline-playback.md ("Two-State Detail Layout") | [Two-State Detail Layout (Browse ↔ Watch)](../architecture/embedded-inline-playback.md#two-state-detail-layout-browse--watch) | Existing contract |
+| CLAUDE.md:1580–1580 | VOD/Series Detail Pages (two-state layout): — VOD Multi-Source (alternative sources for a movie): | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — The chip opens a 660px anchored CDK-overlay popover (libs/ui/components/src/lib/vod-sources/; not MatMenu,… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — A row's language is vodSourceLanguage (libs/shared/interfaces/src/lib/vod-source-language.util.ts): the… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Unicode lookalikes, bracketed, or ALL-CAPS spaced-dash form; | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Latin/Cyrillic 2–4 letters + MULTI; only the legacy pipe form is permissive — bracket/dash matches must… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Recognition alone is not enough — normalizeTitleKeys must STRIP the same tag or the copy is never… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — It goes no further on purpose: a wrong guess costs a filter option, a wrong strip corrupts identity, and… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — The one shape that cannot decide itself is a strip leaving NO real word behind — decided by running the… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Every vocabulary entry is one the catalog proves prefixes hundreds of ordinary titles — never one that… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Verify such widenings against the real catalog before shipping them, over movies AND series: a movie-only… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — Both chips are handed the same matchKind and vodAutoFailover and both write the setting back. | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — The details-page chip badge counts TOTAL copies across all playlists (the in-player chip still counts… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1582–1582 | VOD/Series Detail Pages (two-state layout): — The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1583–1583 | VOD/Series Detail Pages (two-state layout): — Scope v1 is Xtream ↔ Xtream, movies only, Electron only. Stalker never reaches the content table and M3U… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1584–1584 | VOD/Series Detail Pages (two-state layout): — Metadata provenance is the core contract. Every field is {value, provenance} where api/probe are facts… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — Discovery (DB_FIND_TITLE_SOURCES, trigram FTS over content_title_fts) is lazy and returns only what the… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — A source that is never read looks exactly like one that does not exist, so: the current playlist is… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — The year gate covers BOTH match tiers: normalizeTitleKeys strips bracketed segments, so "Dune (1984)"… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — A non-ASCII token cannot be folded by LOWER() (ASCII-only) but CAN be by a GLOB character class (UTF-8… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — The movie's own year comes from releaseTagYear (bracketed or trailing only), never extractYear: a year… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — One row inside the excluded playlist is kept when the caller names it (keepContentId), because a pin can… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1585–1585 | VOD/Series Detail Pages (two-state layout): — Resolution is deferred to click/pin/check because content stores no container_extension and… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1586–1586 | VOD/Series Detail Pages (two-state layout): — Switching = one inlinePlayback.set({...next, startTime}), never null-then-set, so the player and engine… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1587–1587 | VOD/Series Detail Pages (two-state layout): — Pins are keyed portal-agnostically (tmdb:{id} else title:{base}:{year} else the yearless title:{base}:,… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1588–1588 | VOD/Series Detail Pages (two-state layout): — Claims in the present tense (the "Playing from" caption and the source row's Playing badge) are gated on… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1589–1589 | VOD/Series Detail Pages (two-state layout): — Pins are included in playlist backup as the optional sourcePins collection, carried under the playlist… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1590–1590 | VOD/Series Detail Pages (two-state layout): — Auto-failover is Settings.vodAutoFailover, opt-in and off by default, web engines only — the toggle is… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1591–1591 | VOD/Series Detail Pages (two-state layout): — HEAD probe reuses the main-process handler extracted to… | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1592–1592 | VOD/Series Detail Pages (two-state layout): — See docs/architecture/vod-multi-source.md | [VOD Multi-Source](../architecture/vod-multi-source.md#vod-multi-source) | Existing contract |
+| CLAUDE.md:1596–1596 | Radio Player: — Dedicated audio player for channels with radio="true" M3U attribute | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1597–1597 | Radio Player: — Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1598–1598 | Radio Player: — Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1599–1599 | Radio Player: — EPG panel is hidden for radio channels (radio streams have no EPG data) | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1600–1600 | Radio Player: — Volume synced with video player via shared localStorage key 'volume' | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1601–1601 | Radio Player: — Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute) | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1602–1602 | Radio Player: — Component: libs/ui/playback/src/lib/audio-player/audio-player.component.ts | [Radio audio player](../architecture/player-controls-contract.md#radio-audio-player) | Added / consolidated |
+| CLAUDE.md:1606–1606 | EPG (Electronic Program Guide): — XMLTV format support, from http(s) links or local files (Electron only): a file: URL, an absolute POSIX… | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1607–1607 | EPG (Electronic Program Guide): — Background parsing in worker thread; HTTP/file gzip compatibility follows… | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1608–1608 | EPG (Electronic Program Guide): — Stored in database for quick lookup | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1609–1609 | EPG (Electronic Program Guide): — Global display-time offset (Settings.epgOffsetMinutes, Settings → EPG, ±720 min, Electron only):… | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1610–1610 | EPG (Electronic Program Guide): — Programme guide (Electron, M3U): app-epg-guide in libs/ui/epg fed by the host-provided EPG_GUIDE_SOURCE;… | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1611–1611 | EPG (Electronic Program Guide): — Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list,… | [EPG Integration](../architecture/m3u-playlist-module.md#epg-integration) | Existing contract |
+| CLAUDE.md:1613–1613 | EPG (Electronic Program Guide): — TMDB Metadata Enrichment (opt-in): | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1615–1615 | EPG (Electronic Program Guide): — Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips,… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1616–1616 | EPG (Electronic Program Guide): — The M3U player consumes it too: entries recognized as movie files open in the VOD detail shell fed purely… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1617–1617 | EPG (Electronic Program Guide): — "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1618–1618 | EPG (Electronic Program Guide): — Season/episode enrichment: opening a season lazily fetches /tv/{id}/season/{n} and overlays real episode… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Electron-only, dashboardRails.tmdbTrending toggle), a "Because you watched" recommendations rail… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — TMDB has no account-free "for you" endpoint, so DashboardRecommendationsService seeds per-title… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — The hero lookup must carry the same identity the detail view used, not just the display title —… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Stalker items never reach the content table, so their backdrop rides in the stored entry… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Xtream rows carry the same identity on the content row: the detail views back-fill… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Writes are per-column and never overwrite (enrichment supplies the pieces at different times, so a… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — release_year is the year the PROVIDER stated, never one read out of the title (readers still apply that… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1619–1619 | EPG (Electronic Program Guide): — Both sides validate through normalizeContentMetadataPatch (libs/shared/interfaces), so legacy rows,… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1620–1620 | EPG (Electronic Program Guide): — Series detail views show a TMDB production-status chip (tmdb_status, e.g. Ended / Returning) — TMDB sends… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1621–1621 | EPG (Electronic Program Guide): — Actor pages: cast avatar chips are clickable (TMDB person id) and open actor/:personId inside the current… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1622–1622 | EPG (Electronic Program Guide): — Discover pages (clickable metadata chips, issue #1449): year, genre, and country chips on all four detail… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1623–1623 | EPG (Electronic Program Guide): — Actor page "All portals" scope (Electron only): batched DB_MATCH_TITLES worker op (trigram FTS over all… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1624–1624 | EPG (Electronic Program Guide): — All DB_MATCH_TITLES consumers (Trending rail, "Because you watched" recommendations rail, cross-portal… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1625–1625 | EPG (Electronic Program Guide): — Opt-in via Settings > Metadata (TMDB) (sends titles to TMDB); the section also has a "check key" button… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1626–1626 | EPG (Electronic Program Guide): — Match confidence: a provider tmdb_id is a strong hint, not gospel — its payload is weighed against the… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1627–1627 | EPG (Electronic Program Guide): — Detail views render provider data immediately; enrichment patches the selection asynchronously… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1628–1628 | EPG (Electronic Program Guide): — Cached in SQLite tmdb_metadata (Electron, via DB worker ops DB_GET/SET_TMDB_METADATA, plus… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1629–1629 | EPG (Electronic Program Guide): — Service layer: libs/services/src/lib/tmdb/; store glue:… | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1630–1630 | EPG (Electronic Program Guide): — TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1631–1631 | EPG (Electronic Program Guide): — See docs/architecture/tmdb-metadata-enrichment.md | [TMDB Metadata Enrichment](../architecture/tmdb-metadata-enrichment.md#tmdb-metadata-enrichment) | Existing contract |
+| CLAUDE.md:1635–1635 | Portal Account Info: — Both portal types expose an account-info dialog through the same entry points: header playlist switcher… | [Account Info Dialog](../architecture/stalker-portal.md#account-info-dialog) | Existing contract |
+| CLAUDE.md:1636–1636 | Portal Account Info: — Xtream: AccountInfoComponent (libs/portal/xtream/feature/src/lib/account-info/), queries get_account_info… | [Account Info Dialog](../architecture/stalker-portal.md#account-info-dialog) | Existing contract |
+| CLAUDE.md:1637–1637 | Portal Account Info: — Stalker: StalkerAccountInfoComponent (libs/portal/stalker/feature/src/lib/stalker-account-info/),… | [Account Info Dialog](../architecture/stalker-portal.md#account-info-dialog) | Existing contract |
+| CLAUDE.md:1638–1638 | Portal Account Info: — Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once… | [Source subscription expiry](../architecture/workspace-dashboard.md#source-subscription-expiry) | Added / consolidated |
+| CLAUDE.md:1642–1642 | Stalker Portal Mode and Endpoint Discovery: — Every resolved Edit commit is guarded by the source connection authority captured when Edit began.… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1643–1643 | Stalker Portal Mode and Endpoint Discovery: — Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1644–1644 | Stalker Portal Mode and Endpoint Discovery: — Import requires an explicit HTTP(S) scheme but accepts a bare host, /c, or a concrete .php address. It… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — A metadata-only Save omits connection/mode fields from its queued update, so the stored connection stays… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — A persisted portalUrl keeps the row on the Stalker save path even if legacy Xtream fields remain. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Before discovery, PWA acquires a shared playlist-authority barrier plus an exclusive origin-wide… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Add/delete, backup restore, and bulk replacement take the same row lock, while Delete All takes the… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — A concurrent Edit or stale dialog fails before remote discovery; a replacement waits for the current owner. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Same-tab Save first publishes its local authentication owner, drains an existing lazy repair through… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — PWA fails closed if Web Locks are unavailable, while Electron relies on its single-instance local owner. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair,… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — If discovery returns after its bounded drain while an abandoned authentication is still on the wire, that… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Once Save starts, navigation or dialog destruction does not discard a later successful result: get_profile… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — That late commit uses transformPlaylistMeta() inside the per-playlist write queue to merge only… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — This preserves playback headers and other metadata absent from the form. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint,… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — A changed authority may rebase only when the persisted row proves that it owns the same playlist ID,… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1645–1645 | Stalker Portal Mode and Endpoint Discovery: — The transient PlaylistMetaUpdate.stalkerSessionPatch preserves on absence, clears on null, and fully… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — executeStalkerRequest() (stores/utils/stalker-request.utils.ts) is the choke point for catalog, content… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Four callers are deliberately outside it because they run below or before the thing it routes on —… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — They are exempt from the routing, not from the repair it hooks, but only fetchViaProfile() wires… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Anything new that is not auth or discovery belongs on executeStalkerRequest(). | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Existing playlists are repaired LAZILY (StalkerPortalRepairService) — only after a request fails with a… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Before an unrecorded repair reads the persisted source or calls discovery, PWA takes the same… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — This prevents repair in another tab from authenticating alongside Edit or crossing delete/restore. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — The persisted-row preflight still verifies that the caller owns the failing source, so a late pre-Edit… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Its in-session override is bound to source endpoint, mode, device identity, and credentials; an Edit or… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — Each repair installs a session-level authentication fence synchronously, drains the existing token slot… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1646–1646 | Stalker Portal Mode and Endpoint Discovery: — There is deliberately no eager one-shot migration: a portal that works is never re-probed. | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1647–1647 | Stalker Portal Mode and Endpoint Discovery: — Explicit Edit advances the repair generation before installing its resolved session. Lazy repair captures… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1648–1648 | Stalker Portal Mode and Endpoint Discovery: — Both transports build the wire format from the same shared builders in @iptvnator/shared/interfaces —… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1649–1649 | Stalker Portal Mode and Endpoint Discovery: — Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1650–1650 | Stalker Portal Mode and Endpoint Discovery: — Contract: docs/architecture/stalker-portal.md ("Portal Mode and Endpoint Discovery", "Request Transport… | [Portal Mode and Endpoint Discovery](../architecture/stalker-portal.md#portal-mode-and-endpoint-discovery) | Existing contract |
+| CLAUDE.md:1654–1654 | Stalker Session Authentication: — Full portals authenticate through StalkerSessionService… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1655–1655 | Stalker Session Authentication: — get_profile's js.status decodes as: full profile/0 = OK, 1 = refused (device-conflict when the message… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1656–1656 | Stalker Session Authentication: — Refusals throw StalkerPortalError (login-required / login-rejected / device-conflict / blocked /… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1657–1657 | Stalker Session Authentication: — Auth failures are HTTP 200 + plain text (Authorization failed. / Access denied. / Unauthorized request.),… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1658–1658 | Stalker Session Authentication: — The handshake is idempotent, so Playlist.stalkerToken is re-presented and get_profile is skipped when it… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1659–1659 | Stalker Session Authentication: — Watchdog: get_events immediately (init=1), then every watchdog_timeout s (default 120, clamped 30–3600)… | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1660–1660 | Stalker Session Authentication: — Full contract: docs/architecture/stalker-portal.md ("Session Authentication Lifecycle"). | [Session Authentication Lifecycle](../architecture/stalker-portal.md#session-authentication-lifecycle) | Existing contract |
+| CLAUDE.md:1664–1664 | Stalker Identity Hardening: — The MAC is canonicalized to 00:1A:79:XX:XX:XX by normalizeStalkerMacAddress (@iptvnator/shared/interfaces)… | [Stalker Identity Policy](../architecture/stalker-portal.md#stalker-identity-policy) | Existing contract |
+| CLAUDE.md:1665–1665 | Stalker Identity Hardening: — Format is enforced, the Infomir OUI is advisory only: hasInfomirMacOui drives a hint, never a rejection.… | [Stalker Identity Policy](../architecture/stalker-portal.md#stalker-identity-policy) | Existing contract |
+| CLAUDE.md:1666–1666 | Stalker Identity Hardening: — deriveStalkerDeviceIdsFromMac returns the StbEmu / stalker-to-m3u PAIR: SHA256(MAC) for device_id and… | [Stalker Identity Policy](../architecture/stalker-portal.md#stalker-identity-policy) | Existing contract |
+| CLAUDE.md:1667–1667 | Stalker Identity Hardening: — get_profile reports one coherent MAG250 via STALKER_STB_PROFILE_PARAMS (ver, stb_type — previously empty… | [Stalker Identity Policy](../architecture/stalker-portal.md#stalker-identity-policy) | Existing contract |
+| CLAUDE.md:1668–1668 | Stalker Identity Hardening: — Contract: docs/architecture/stalker-portal.md ("Stalker Identity Policy"). | [Stalker Identity Policy](../architecture/stalker-portal.md#stalker-identity-policy) | Existing contract |
+| CLAUDE.md:1672–1672 | Favorites and Recently Viewed: — Per-playlist favorites and global favorites | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1673–1673 | Favorites and Recently Viewed: — Recently viewed tracks watch history | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1674–1690 | Favorites and Recently Viewed: — Live channels in the unified favorites/recent live tab (global collections and a portal's own tabs) carry… | [Summary](../architecture/portal-detail-navigation.md#summary) | Existing contract |
+| CLAUDE.md:1694–1694 | Internationalization: — Uses @ngx-translate with 19 language files in apps/web/src/assets/i18n/ | [Features](../../README.md#features) | Existing contract |
+| CLAUDE.md:1700–1700 | Environment Detection and Dual-Mode Architecture — The app determines whether it's running in Electron or as a PWA by checking: | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1702–1704 | Environment Detection and Dual-Mode Architecture — typescript window.electron; // truthy in Electron, undefined in browser | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1707–1707 | Why Dual Mode? — IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility: | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1709–1709 | Why Dual Mode? — Electron: Full-featured desktop experience with local database, external player support (MPV/VLC), and… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1710–1710 | Why Dual Mode? — PWA: Lightweight web version that runs in any browser without installation | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1714–1714 | Environment-Specific Behavior: — app.config.ts - DataFactory() selects DataService implementation based on environment | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1715–1715 | Environment-Specific Behavior: — app.routes.ts - Same /workspace/... route tree in both environments; guards keep Electron-only routes… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1716–1718 | Environment-Specific Behavior: — Storage layer switches automatically: - Electron → SQLite/Drizzle ORM →… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1719–1719 | Environment-Specific Behavior: — External player support (MPV/VLC) only available in Electron | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1720–1720 | Environment-Specific Behavior: — File system operations only available in Electron (uploading playlists from disk) | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1723–1723 | Base Href Configuration: — The app uses different base href values depending on the build target: | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1725–1727 | Base Href Configuration: — Development & PWA: baseHref="/" (from index.html) - Used by: pnpm run serve:frontend, pnpm run… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1728–1730 | Base Href Configuration: — Electron Production: baseHref="./" (overridden in build config) - Used by: pnpm run build:backend, pnpm… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1732–1732 | Base Href Configuration: — Build configurations in apps/web/project.json: | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1734–1734 | Base Href Configuration: — production: Electron build with baseHref="./" | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1735–1735 | Base Href Configuration: — pwa: Web deployment with baseHref="/" | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1736–1736 | Base Href Configuration: — development: Dev mode with baseHref="/" from index.html | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1739–1739 | Factory Pattern Implementation: — The factory pattern ensures a single codebase works in both environments without conditional checks… | [Service factory and build bases](../architecture/pwa-self-hosted.md#service-factory-and-build-bases) | Corrected; see decisions |
+| CLAUDE.md:1742–1742 | Build Commit In About: — CI injects the git commit into apps/web/src/environments/build-commit.ts via… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — Master pushes are the nightly channel. | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — The leading nightly-version job computes one <patch>-nightly.<commit date>.<run number> version per run… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — Settings.updateChannel (Settings → About, Electron only, default stable) is mirrored into the main-process… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — AppUpdateService applies the channel to electron-updater before every check (app-update-feed.ts: feed… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — Switching back to stable is forward-only: the nightly stays until a newer stable release exists, because a… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — The About section's status badge names the channel the verdict describes (status.verdictChannel, stamped… | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — setChannel re-checks on its own) — a check against an unsaved channel is deliberately not offered. | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1745–1745 | Nightly Builds And Update Channel: — Contract: docs/architecture/release-pipeline.md ("Nightly channel"). | [Nightly channel](../architecture/release-pipeline.md#nightly-channel) | Existing contract |
+| CLAUDE.md:1749–1749 | Testing Strategy — Unit tests: Jest with jest-preset-angular and ng-mocks | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:1750–1750 | Testing Strategy — E2E tests: Playwright testing the web app and Electron app | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:1751–1751 | Testing Strategy — Backend tests use standard Jest | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:1752–1752 | Testing Strategy — Bug fixes should add focused regression coverage unless there is a documented reason not to. | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:1753–1753 | Testing Strategy — Use the impact-based validation policy in Regression Prevention And Test Updates to choose targeted unit… | [Unit And Type Checks](../architecture/validation-map.md#unit-and-type-checks) | Existing contract |
+| CLAUDE.md:1757–1757 | Nx Commands — Use nx CLI for better performance: | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1759–1763 | Nx Commands — bash pnpm nx run <project>:<target> # Example: pnpm nx run web:build # Example: pnpm nx run… | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1765–1765 | Nx Commands — To run multiple projects: | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1767–1769 | Nx Commands — bash pnpm nx run-many --target=test --all | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1773–1773 | Electron Build Process — The Electron backend depends on the web app being built first: | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1775–1775 | Electron Build Process — electron-backend:build depends on web:build | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1776–1776 | Electron Build Process — Output goes to dist/apps/electron-backend (backend) and dist/apps/web (frontend) | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1777–1777 | Electron Build Process — Packaging combines both into distributable | [Build and serve commands](../development/agent-workflow.md#build-and-serve-commands) | Moved / consolidated |
+| CLAUDE.md:1781–1781 | Database Migrations — Database initialization is owned by libs/shared/database/src/lib/connection.ts. createTables() creates… | [Upgrade Compatibility And Migrations](../../libs/shared/database/README.md#upgrade-compatibility-and-migrations) | Existing contract |
+| CLAUDE.md:1787–1790 | IPC Communication: — 1. Define handler in appropriate events file (e.g., database.events.ts) 2. Register with ipcMain.handle()… | [Adding behavior across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Moved / consolidated |
+| CLAUDE.md:1794–1797 | Adding New Playlist Source: — 1. Add type to libs/shared/interfaces/src/lib/playlist.interface.ts 2. Create event handler in… | [Adding behavior across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Moved / consolidated |
+| CLAUDE.md:1801–1801 | State Management: — Use NgRx for global application state (M3U playlists, libs/m3u-state) | [Adding behavior across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Moved / consolidated |
+| CLAUDE.md:1802–1802 | State Management: — Use NgRx Signal Store with signalStoreFeature() composition for portal/feature state (XtreamStore,… | [Adding behavior across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Moved / consolidated |
+| CLAUDE.md:1803–1803 | State Management: — Use NgRx signals for reactive data streams | [Adding behavior across layers](../development/agent-workflow.md#adding-behavior-across-layers) | Moved / consolidated |
+| CLAUDE.md:1810–1810 | General Guidelines for working with Nx — For navigating/exploring the workspace, invoke the nx-workspace skill first when it is available - it has… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1811–1811 | General Guidelines for working with Nx — When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through nx… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1812–1812 | General Guidelines for working with Nx — Prefix nx commands with the workspace's package manager (e.g., pnpm nx build, npm exec nx test) - avoids… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1813–1813 | General Guidelines for working with Nx — You have access to the Nx MCP server and its tools, use them to help the user | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1814–1814 | General Guidelines for working with Nx — For Nx plugin best practices, check node_modules/@nx/<plugin>/PLUGIN.md. Not all plugins have this file -… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1815–1815 | General Guidelines for working with Nx — NEVER guess CLI flags - always check nx_docs or --help first when unsure | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1819–1819 | Scaffolding & Generators — For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the nx-generate skill… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1823–1823 | When to use nx_docs — USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1824–1824 | When to use nx_docs — DON'T USE for: basic generator syntax (nx g @nx/react:app), standard commands, things you already know | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1825–1825 | When to use nx_docs — The nx-generate skill handles generator discovery internally - don't call nx_docs just to look up… | [General Guidelines for working with Nx](../../AGENTS.md#general-guidelines-for-working-with-nx) | Retained; tools conditional |
+| CLAUDE.md:1831–1835 | XMLTV Response Compression — Electron decodes HTTP compression before the gzip file layer. For .gz/gzip metadata plus HTTP gzip, a… | [XMLTV response compression](../architecture/m3u-playlist-module.md#xmltv-response-compression) | Existing contract |
+| CLAUDE.md:1839–1857 | XMLTV Source Removal — Saving Settings → EPG reconciles cached XMLTV with committed global URLs and all enabled M3U playlist… | [XMLTV source lifecycle](../architecture/m3u-playlist-module.md#xmltv-source-lifecycle) | Existing contract |
+| CLAUDE.md:1861–1870 | Web Backend Provider Redirects — All four provider proxy routes use ValidatedHttpClient: automatic redirects are disabled, the initial URL… | [Web Backend](../architecture/pwa-self-hosted.md#web-backend) | Existing contract |
+| CLAUDE.md:1874–1879 | Portal Connectivity Preference — Half-open trial slots follow the complete request lifetime with no elapsed-time expiry. All four… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| CLAUDE.md:1880–1887 | Portal Connectivity Preference — Desktop Settings > General > Portal connections exposes default-on Settings.portalConnectivityGuard. Only… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| CLAUDE.md:1888–1890 | Portal Connectivity Preference — Both account-info dialogs explain guard refusals with localized paused-request copy and Retry now; Stalker… | [Desktop preference and account feedback](../architecture/host-connectivity-guard.md#desktop-preference-and-account-feedback) | Existing contract |
+| CLAUDE.md:1894–1920 | Live TV Panel Levels — Portal live layouts (Xtream live, Stalker itv/radio) fold their panels from the outside in, in three… | [Collapsible Live Sidebar](../architecture/iptvnator-ui-guidelines.md#collapsible-live-sidebar) | Existing contract |
+| CLAUDE.md:1924–1930 | Live Channel Return — Xtream and Stalker (including radio) capture displayed playback order on explicit selection. Remote… | [Live channel return and playback order](../architecture/remote-control.md#live-channel-return-and-playback-order) | Existing contract |
+| CLAUDE.md:1934–1940 | Stalker Live Search — ITV sidebar and fullscreen searches independently filter the complete selected category; only All Items… | [Full ITV Channel List Cache](../architecture/stalker-portal.md#full-itv-channel-list-cache) | Existing contract |
+| CLAUDE.md:1944–1959 | Channel and Detail Keyboard Scrolling — Channel scroll owners use ChannelScrollFocusDirective; pointer selection focuses the viewport, native… | [Detail Scroll and Focus](../architecture/portal-detail-navigation.md#detail-scroll-and-focus) | Existing contract |
+| CLAUDE.md:1963–1967 | Catch-Up URL Copying — EPG timeline/list programme details expose Copy archive URL for supported Xtream/M3U archives, including… | [Copy archive URL](../architecture/m3u-playlist-module.md#copy-archive-url) | Existing contract |
+| CLAUDE.md:1971–2001 | Xtream Archive Downloads — Desktop Xtream Live TV programme details can enqueue completed catch-up as contentType: catchup. The queue… | [Xtream archive downloads](../architecture/download-manager.md#xtream-archive-downloads) | Existing contract |
+| CLAUDE.md:2005–2010 | Desktop Source Health — Electron switcher/source rows share bounded, cached Xtream/Stalker/M3U URL checks through… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract |
+| CLAUDE.md:2012–2018 | Desktop Source Health — Desktop Sources also offers library-wide selective cleanup through dialog-scoped SourceCleanupService.… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract |
+| CLAUDE.md:2020–2022 | Desktop Source Health — Startup source auto-refresh uses SourceActivityService to protect busy IDs from cleanup. Late batch… | [Desktop source health](../architecture/m3u-playlist-module.md#desktop-source-health) | Existing contract |
+
+## Master integration follow-up
+
+During conflict resolution with master on 2026-09-21, the compact root guidance
+was retained and the new upstream knowledge was checked against canonical docs:
+
+- Live channel playlist handoff, including Stalker arrival and fallback behavior:
+ [portal navigation](../architecture/portal-detail-navigation.md).
+- Shared locale-independent search folding and SQLite search variants:
+ [agent workflow](../development/agent-workflow.md#shared-search-text-folding).
+- Stalker series resume, watch kind versus routing kind, and dashboard labels:
+ [portal navigation](../architecture/portal-detail-navigation.md) and
+ [workspace dashboard](../architecture/workspace-dashboard.md).
+- Scoped XMLTV fallback and Xtream programme refresh helpers:
+ [M3U contracts](../architecture/m3u-playlist-module.md).
+- Lazy portal EPG queues, revision handling and dashboard fallback:
+ [workspace dashboard](../architecture/workspace-dashboard.md).
+
+The original 716-entry inventory above remains tied to its immutable source.
+
+A subsequent master integration on 2026-09-21 also preserved TMDB year-evidence
+ranking, its accepted older-season ambiguity, and v4 lookup-cache migration in
+the updated [TMDB contract](../architecture/tmdb-metadata-enrichment.md). These
+upstream additions stay in that canonical document rather than CLAUDE.md.
diff --git a/package.json b/package.json
index 73b637850..534972f64 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"serve:website": "nx serve website",
"build:website": "nx build website",
"i18n:check": "node tools/i18n/check-drift.mjs",
+ "agents:validate": "node tools/skills/validate-agent-guidance.mjs",
"skills:validate": "node tools/skills/validate-repository-skills.mjs",
"release:artwork:dry-run": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --dry-run",
"release:artwork:manifest": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --manifest",
@@ -214,6 +215,7 @@
"eslint-plugin-import": "2.32.0",
"eslint-plugin-playwright": "^1.6.2",
"express": "5.2.1",
+ "github-slugger": "2.0.0",
"globals": "15.9.0",
"html-escaper": "3.0.3",
"istanbul-lib-coverage": "3.2.2",
@@ -231,6 +233,8 @@
"node-gyp": "12.4.0",
"nx": "23.2.1",
"nx-electron": "22.0.0",
+ "parse-srcset": "1.0.2",
+ "parse5": "8.0.1",
"prettier": "^3.9.6",
"sharp": "0.35.4",
"tailwindcss": "^3.4.19",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8e0501001..7508d824f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -404,6 +404,9 @@ importers:
express:
specifier: 5.2.1
version: 5.2.1
+ github-slugger:
+ specifier: 2.0.0
+ version: 2.0.0
globals:
specifier: 15.9.0
version: 15.9.0
@@ -455,6 +458,12 @@ importers:
nx-electron:
specifier: 22.0.0
version: 22.0.0(patch_hash=4d5ac9c5b10268dcc40998d7a2b166d004105ae7875fa182130d6810871a1e84)(@nx/devkit@23.2.1(nx@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23))))(@nx/workspace@23.2.1(@swc-node/register@1.12.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@swc/core@1.16.1(@swc/helpers@0.5.23))(@swc/types@0.1.28)(typescript@6.0.3))(@swc/core@1.16.1(@swc/helpers@0.5.23)))(@swc/core@1.16.1(@swc/helpers@0.5.23))(electron-builder-squirrel-windows@26.15.7)(electron@43.3.0)(esbuild@0.28.2)(rxjs@7.8.2)(typescript@6.0.3)
+ parse-srcset:
+ specifier: 1.0.2
+ version: 1.0.2
+ parse5:
+ specifier: 8.0.1
+ version: 8.0.1
prettier:
specifier: ^3.9.6
version: 3.9.6
@@ -8024,6 +8033,9 @@ packages:
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
engines: {node: '>= 0.10'}
+ parse-srcset@1.0.2:
+ resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
+
parse5-html-rewriting-stream@8.0.1:
resolution: {integrity: sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==}
@@ -18615,6 +18627,8 @@ snapshots:
parse-node-version@1.0.1:
optional: true
+ parse-srcset@1.0.2: {}
+
parse5-html-rewriting-stream@8.0.1:
dependencies:
entities: 8.0.0
diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md
index 033b0c661..fc4c423ae 100644
--- a/tools/embedded-mpv/README.md
+++ b/tools/embedded-mpv/README.md
@@ -84,6 +84,8 @@ pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/linux-prefix
### Windows CI pin lifecycle
+The PAT-backed refresh job must pin every third-party action to a full commit.
+
Windows package builds consume the one validated record in
`windows-runtime-pin.json`; URL and checksum repository variables are not build
inputs. Check it locally with:
diff --git a/tools/skills/agent-guidance-markdown.mjs b/tools/skills/agent-guidance-markdown.mjs
new file mode 100644
index 000000000..d858be21c
--- /dev/null
+++ b/tools/skills/agent-guidance-markdown.mjs
@@ -0,0 +1,392 @@
+import { randomUUID } from 'node:crypto';
+import parseSrcset from 'parse-srcset';
+import GithubSlugger from 'github-slugger';
+import { Marked, Tokenizer } from 'marked';
+import { parseFragment } from 'parse5';
+
+export const DOCUMENT_EXTENSION =
+ /\.(?:md|markdown|mdown|mkd|mdx|txt|json|ya?ml|html?|rst|rest|adoc|asciidoc|pdf|doc[xm]?|dot[xm]?|od[tspgfbm]|ot[tspg]|fod[tspg]|rtf|org|tex|latex)$/iu;
+
+// Inspection only: generated HTML is parsed in memory, never executed or emitted.
+const markdownLexer = new Marked({
+ tokenizer: {
+ reflink(source, links) {
+ const token = Tokenizer.prototype.reflink.call(this, source, links);
+ if (token?.type !== 'text') return token;
+
+ // Marked otherwise turns unresolved references into ordinary text.
+ // Retain explicit full/collapsed forms and shortcut images;
+ // a bare [word] without a definition remains ordinary prose.
+ const full = this.rules.inline.reflink.exec(source);
+ const collapsed = this.rules.inline.nolink.exec(source);
+ const match =
+ full ??
+ (collapsed?.[0].endsWith('[]') ||
+ collapsed?.[0].startsWith('![')
+ ? collapsed
+ : undefined);
+ if (!match) return token;
+ return {
+ type: 'unresolved-reference',
+ raw: match[0],
+ text: match[0],
+ label: match[2] || match[1],
+ };
+ },
+ },
+});
+
+function inlineText(tokens) {
+ return tokens
+ .map((token) => {
+ if (token.type === 'html') return '';
+ if (token.tokens) return inlineText(token.tokens);
+ return token.type === 'text'
+ ? decodeEntities(token.text ?? '')
+ : (token.text ?? '');
+ })
+ .join('');
+}
+
+function decodeEntities(text, attribute = false) {
+ if (attribute) {
+ const html = ` `;
+ return parseFragment(html).childNodes[0].attrs[0].value;
+ }
+ // RCDATA decodes the full HTML character-reference grammar without
+ // interpreting literal tags. The prefix preserves an initial newline.
+ const html = `
Go \">",
+ }),
+ []
+ );
+});
+
+test('autolink does not hide adjacent import', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md':
+ '@AGENTS.md\n\n).@docs/guide.md',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+test('srcdoc base resolves local document paths', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md':
+ "",
+ }),
+ []
+ );
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'AGENTS.md':
+ "",
+ })
+ ).length > 0
+ );
+});
+
+test('srcdoc bases retain remote and repository containment rules', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md':
+ "",
+ }),
+ []
+ );
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'AGENTS.md':
+ "",
+ })
+ ).some((message) => message.includes('escapes repository'))
+ );
+});
+
+test('local HTML base uses native filesystem paths', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md':
+ 'Guide ',
+ 'docs/native #%.md': '# Heading',
+ }),
+ []
+ );
+});
+
+for (const boundary of [').', '];', '}']) {
+ test(`bare URL preserves adjacent import after ${boundary}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md':
+ '@AGENTS.md\n\nhttps://example.com/path' +
+ boundary +
+ '@docs/guide.md',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+
+for (const uri of [
+ '',
+ '',
+]) {
+ test(`opaque URI is not a guidance import: ${uri}`, async (t) => {
+ assert.deepEqual(await diagnostics(t, { 'AGENTS.md': uri }), []);
+ });
+}
+test('colon directly before an import remains checked', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md': '@AGENTS.md\n\nRead:@docs/guide.md',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+
+test('colon-labeled prose cannot hide imports', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md': '@AGENTS.md\n\nFallback:then;@docs/guide.md',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+
+for (const opening of ['(', '[', '{']) {
+ test(`opening delimiter separates package and import prose: ${opening}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'package.json': JSON.stringify({
+ dependencies: { '@angular/core': '*' },
+ }),
+ 'AGENTS.md': 'Use @angular/core' + opening + 'test helpers)',
+ }),
+ []
+ );
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md':
+ '@AGENTS.md\n\nRead @INSTRUCTIONS' +
+ opening +
+ 'then continue)',
+ INSTRUCTIONS: 'Guidance',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+
+test('package prose cannot hide nested guidance import', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'package.json': JSON.stringify({
+ dependencies: { '@angular/core': '*' },
+ }),
+ 'CLAUDE.md': '@AGENTS.md\n\nUse @angular/core(@INSTRUCTIONS)',
+ INSTRUCTIONS: 'Guidance',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+for (const extension of ['pdf', 'rst', 'adoc', 'markdown', 'docm', 'latex']) {
+ test(`bare document literal is checked: ${extension}`, async (t) => {
+ const name = 'manual.' + extension;
+ assert.ok(
+ (await diagnostics(t, { 'AGENTS.md': '`' + name + '`' })).length > 0
+ );
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': '`' + name + '`',
+ [name]: 'Document',
+ }),
+ []
+ );
+ });
+}
+
+for (const markup of [
+ '',
+ ' ',
+ '[Guide](file:///tmp/missing.md)',
+ 'Guide ',
+]) {
+ test(`nonportable or pathless target is rejected: ${markup}`, async (t) => {
+ assert.ok((await diagnostics(t, { 'AGENTS.md': markup })).length > 0);
+ });
+}
+
+for (const quote of ['"', "'", '”']) {
+ test(`quoted URL preserves adjacent import: ${quote}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md':
+ '@AGENTS.md\n\nVisit ' +
+ quote +
+ 'https://example.com/path' +
+ quote +
+ '.@docs/guide.md',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+for (const embedded of [false, true]) {
+ test(`file URL base is rejected: embedded=${embedded}`, async (t) => {
+ const rootDir = await fixture(t);
+ const { pathToFileURL } = await import('node:url');
+ const { realpath } = await import('node:fs/promises');
+ const base = pathToFileURL((await realpath(rootDir)) + '/').href;
+ const html = `Guide `;
+ await writeFile(
+ join(rootDir, 'AGENTS.md'),
+ embedded ? `` : html
+ );
+ assert.ok(
+ (await validateAgentGuidance({ rootDir })).diagnostics.length > 0
+ );
+ });
+}
+
+for (const url of [
+ "https://example.com/don't@docs/guide.md",
+ "https://example.com/path'@docs/guide.md",
+]) {
+ test(`internal URL apostrophe remains URL prose: ${url}`, async (t) => {
+ assert.deepEqual(await diagnostics(t, { 'AGENTS.md': url }), []);
+ });
+}
+for (const name of ['readme', 'instructions', 'Agents']) {
+ test(`guidance basename matching ignores case: ${name}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'package.json': JSON.stringify({
+ dependencies: { '@angular/core': '*' },
+ }),
+ 'CLAUDE.md':
+ '@AGENTS.md\n\nRead @angular/core/docs/' + name,
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+test('image input source is validated', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'AGENTS.md': ' ',
+ })
+ ).length > 0
+ );
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': ' ',
+ }),
+ []
+ );
+});
+
+for (const attribute of ['href', 'xlink:href']) {
+ test(`SVG image reference is checked: ${attribute}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'AGENTS.md': ` `,
+ })
+ ).length > 0
+ );
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': ` `,
+ }),
+ []
+ );
+ });
+}
+for (const name of ['CONTRIBUTING', 'SECURITY', 'code_of_conduct', 'SUPPORT']) {
+ test(`conventional guidance basename is not a package: ${name}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'package.json': JSON.stringify({
+ dependencies: { '@angular/core': '*' },
+ }),
+ 'CLAUDE.md':
+ '@AGENTS.md\n\nRead @angular/core/docs/' + name,
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+
+test('versioned declared package may share a guidance basename', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'package.json': JSON.stringify({
+ dependencies: { '@scope/support': '*' },
+ }),
+ 'AGENTS.md': 'Use @scope/support@^2',
+ }),
+ []
+ );
+});
+test('federated handle is not an import', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, { 'AGENTS.md': 'Contact @alice@example.social' }),
+ []
+ );
+});
+for (const attribute of ['href', 'xlink:href']) {
+ test(`SVG use references are checked: ${attribute}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'AGENTS.md': ` `,
+ })
+ ).length > 0
+ );
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': ` `,
+ }),
+ []
+ );
+ });
+}
+
+test('srcdoc SVG use keeps its own anchors', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md':
+ '',
+ }),
+ []
+ );
+});
+
+for (const suffix of [').', '];', '”']) {
+ test(`federated handle accepts closing punctuation: ${suffix}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': 'Contact (@alice@example.social' + suffix,
+ }),
+ []
+ );
+ });
+}
+test('multiple at-signs cannot disguise an adjacent document import', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md': '@AGENTS.md\n\n@guide.md@alice@example.social',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+
+for (const suffix of ['…', '。', '!', '—']) {
+ test(`federated handle accepts Unicode sentence ending: ${suffix}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': 'Contact @alice@example.social' + suffix,
+ }),
+ []
+ );
+ });
+}
+
+for (const suffix of ["'s", '’s']) {
+ test(`federated handle may be possessive: ${suffix}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md':
+ 'Contact @alice@example.social' + suffix + ' administrator',
+ }),
+ []
+ );
+ });
+}
+
+for (const suffix of ['(admin)', '[admin]', '{admin}']) {
+ test(`federated handle permits parenthetical prose: ${suffix}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': 'Contact @alice@example.social' + suffix,
+ }),
+ []
+ );
+ });
+}
+test('federated handle cannot hide a nested import', async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md':
+ '@AGENTS.md\n\n@alice@example.social(@INSTRUCTIONS)',
+ INSTRUCTIONS: 'Guidance',
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+});
+
+test('www autolinks do not introduce guidance imports', async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': 'See www.example.com/@docs/guide',
+ }),
+ []
+ );
+});
+for (const prose of [
+ 'www.example.com/path).@docs/guide.md',
+ '"www.example.com/path".@docs/guide.md',
+]) {
+ test(`www URL preserves adjacent import: ${prose}`, async (t) => {
+ assert.ok(
+ (
+ await diagnostics(t, {
+ 'CLAUDE.md': '@AGENTS.md\n\n' + prose,
+ })
+ ).some((message) => message.includes('additional or inline'))
+ );
+ });
+}
+for (const prose of [
+ 'foo@INSTRUCTIONS',
+ '@alice@example.social(foo@INSTRUCTIONS)',
+ '@alice@example.social(email@example.com)',
+]) {
+ test(`embedded at-sign is not an import boundary: ${prose}`, async (t) => {
+ assert.deepEqual(
+ await diagnostics(t, {
+ 'AGENTS.md': prose,
+ INSTRUCTIONS: 'Guidance',
+ }),
+ []
+ );
+ });
+}
+
+for (const reference of [
+ '[Guide](C:/workspace/docs/missing.md)',
+ 'Guide ',
+ 'Guide ',
+ 'Guide ',
+ 'Guide ',
+]) {
+ test(`Windows drive paths require portable references: ${reference}`, async (t) => {
+ assert.ok(
+ (await diagnostics(t, { 'AGENTS.md': reference })).some((message) =>
+ message.includes('repository-relative')
+ )
+ );
+ });
+}