{"owner":"4gray","repo":"iptvnator","hasSkills":true,"hasMcp":false,"mcpConfig":null,"found":["CLAUDE.md","AGENTS.md"],"skills":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n> The process sections below (Plan Mode, Documentation After Changes, 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.\n\n## Plan Mode\n\n- When Claude Code is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Project Overview\n\nIPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.\n\n**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.\n\n## Development Commands\n\n### Agent Bootstrap\n\n```bash\npnpm install --frozen-lockfile\npnpm nx show projects\n```\n\n- 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.\n- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`.\n- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n### Building and Serving\n\n```bash\n# Serve the Angular web app only (development mode, baseHref=\"/\")\npnpm run serve:frontend\n# or\nnx serve web\n\n# Serve with PWA configuration (optimized, baseHref=\"/\")\npnpm run serve:frontend:pwa\n# or\nnx serve web --configuration=pwa\n\n# Serve the Electron app (starts both frontend and backend)\npnpm run serve:backend\n# or\nnx serve electron-backend\n\n# Build frontend for Electron (baseHref=\"./\")\npnpm run build:frontend\n# or\nnx build web\n\n# Build frontend for PWA deployment (baseHref=\"/\")\npnpm run build:frontend:pwa\n# or\nnx build web --configuration=pwa\n\n# Build backend (Electron)\npnpm run build:backend\n# or\nnx build electron-backend\n\n# Package the app (creates distributable without installers)\npnpm run package:app\n# or\nnx run electron-backend:package\n\n# Create installers/executables\npnpm run make:app\n# or\nnx run electron-backend:make\n```\n\n### Electron CDP Debugging\n\n- Start Electron in dev mode with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- The workspace is configured to always launch Electron with: `--remote-debugging-port=9222`\n- Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222`\n- When the task is Electron automation/debugging, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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`.\n- 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.\n\nFor startup tracing or white-screen debugging:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\nUseful narrower flags:\n\n- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events\n- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections\n- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle\n- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal\n- `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\n- `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\n\nSettings, portal request/response, and trace payloads must use\n`@iptvnator/shared/logging` or the redacting portal logger before reaching\n`console.*`; never log raw credentials while debugging.\n\nIf the Nx daemon gets into a bad state before rerunning Electron:\n\n```bash\npnpm nx reset\n```\n\nUse global `agent-browser` (preferred):\n\n```bash\n# Verify CDP targets\nagent-browser --cdp 9222 tab list\n\n# Switch to the app tab and inspect interactive elements\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\n\n# Capture debug artifacts\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\nagent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip\nagent-browser --cdp 9222 wait 1500\nagent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip\n```\n\nIf `agent-browser` is not in PATH, use:\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### Testing\n\n```bash\n# Run frontend tests\npnpm run test:frontend\n# or\npnpm nx test web\n\n# Run backend tests\npnpm run test:backend\n# or\npnpm nx test electron-backend\n\n# Run targeted E2E tests (Playwright)\npnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts\npnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts\n\n# Run broad E2E suites only when the impact justifies it\npnpm nx e2e web-e2e\npnpm nx e2e electron-backend-e2e\n\n# Run tests with coverage when needed\npnpm nx test web --configuration=ci\n```\n\nBefore finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.\n\n### Linting\n\n```bash\n# Lint all projects (CI runs this on master; PRs lint affected projects)\npnpm run lint\n\n# Lint a single project\nnx lint web\nnx lint electron-backend\n```\n\nCI lints affected projects on PRs (`nx affected`) and every project on master\npushes (`.github/workflows/ci.yml`). This enforces the\nNx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint\nrule. The limits and their rationale live in one place,\n`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the\nbaseline generator import so the enforced rule and the generated list cannot\ndrift:\n\n- **Production TypeScript: hard maximum 400 lines.**\n- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under\n  `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one\n  at the production limit yields arbitrary `-2.spec.ts` files, and length there\n  signals coverage rather than the design debt the production limit catches.\n- **Blank lines and comments are not counted** (`skipBlankLines`,\n  `skipComments`), so a docblock is never the reason a file must be split.\n\nPre-existing oversized files are baselined in\n`tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with\n`node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The\ngenerator decides who belongs on the list by running ESLint's own `max-lines`\nrule, not by counting lines itself — a private reimplementation would silently\ndisagree with the rule and produce a baseline that turns CI red while looking\ncorrect. Never add new files to the baseline — the list must only shrink. A new\nfile that genuinely cannot be split (for example a function serialized into\nanother process) instead carries its own file-wide\n`/* eslint-disable max-lines -- <why> */`; the generator skips those files, so\na justified exemption never lands in the baseline. If such a directive later\nbecomes unnecessary, ESLint reports it as an unused disable directive — remove\nit rather than leaving a stale justification behind.\n\nProject `lint` targets that shell out to eslint must quote the glob, e.g.\n`eslint \"apps/<project>/**/*.ts\"`. An unquoted `**` is expanded by the POSIX\nshell on Linux and macOS (which has no `globstar`, so it matches only a\nshallow subset of files) while Windows passes the literal pattern to ESLint,\nwhich expands it recursively — the two hosts then lint different file sets.\nThe target still reports success either way, so a broken glob hides missing\ncoverage instead of failing. After changing such a target, compare the linted\nfile count against `find <project> -name '*.ts' | wc -l`.\n\n## Architecture\n\n### Monorepo Structure (Nx Workspace)\n\nThis is an Nx monorepo with the following structure:\n\n- **apps/web** - Angular application (frontend, shared by Electron and PWA)\n- **apps/electron-backend** - Electron main process\n- **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 (`network-family-autoselection.ts`) 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\n- **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend\n- **apps/web-e2e** - Playwright E2E tests against the web app\n- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app\n- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E\n- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E\n- **apps/website** - Astro + Tailwind landing page and blog\n- **libs/** - Shared libraries:\n    - **epg/data-access** - EPG services, runtime bridge, program normalization\n    - **m3u-state** - NgRx state management for M3U playlists\n    - **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs)\n    - **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes\n    - **playlist/shared/{ui,util}** - Shared playlist UI and utilities\n    - **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components\n    - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components\n    - **portal/catalog/feature** - Portal catalog UI\n    - **portal/downloads/feature** - Download manager UI\n    - **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\n    - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)\n    - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)\n    - **shared/logging** - Dependency-free structured redaction for diagnostic logs\n    - **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\n    - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)\n    - **shared/m3u-utils** - M3U playlist utilities\n    - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks\n    - **shared/testing** - Shared test helpers\n    - **ui/components** - Reusable UI components (incl. channel list)\n    - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)\n    - **ui/playback** - Player UI (video/audio players)\n    - **ui/pipes** - Angular pipes\n    - **ui/remote-control** - Remote-control UI pieces\n    - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`)\n    - **ui/styles** - Shared styles/theme\n    - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard\n\n### Frontend Architecture (Angular)\n\n**State Management**: Uses NgRx for playlist state management:\n\n- Store configuration in `apps/web/src/app/app.config.ts`\n- Playlist state, actions, effects, and reducers in `libs/m3u-state/`\n- Entity adapter pattern for managing playlists collection\n- Router store integration for route-based state\n\n**XtreamStore Architecture** (Signal Store with Feature Composition):\n\nThe Xtream Codes module uses NgRx Signal Store with a layered architecture:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        PRESENTATION LAYER                        │\n│              Components use XtreamStore (facade)                 │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                         FACADE LAYER                             │\n│                         XtreamStore                              │\n│            (Composes feature stores, unified API)                │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ withPortal · withContent · withSelection · withSearch · withEpg │\n│ withPlayer · withFavorites · withRecentItems                     │\n│ withPlaybackPositions                                           │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                    DATA SOURCE LAYER                             │\n│                   IXtreamDataSource                              │\n│         ┌───────────────────┬───────────────────┐               │\n│         ▼                   ▼                                    │\n│  ElectronDataSource    PwaDataSource                            │\n│  (DB-first + API)      (API-only)                               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nFile structure:\n\n```\nlibs/portal/xtream/\n├── data-access/src/lib/\n│   ├── stores/\n│   │   ├── features/\n│   │   │   ├── with-portal.feature.ts             # Playlist & portal status\n│   │   │   ├── with-content.feature.ts            # Categories & streams\n│   │   │   ├── with-selection.feature.ts          # UI selection & infinite-scroll window\n│   │   │   ├── with-search.feature.ts             # Search functionality\n│   │   │   ├── with-epg.feature.ts                # EPG data\n│   │   │   ├── with-player.feature.ts             # Stream URLs & player\n│   │   │   ├── with-playback-positions.feature.ts # Resume/playback positions\n│   │   │   └── index.ts\n│   │   ├── xtream.store.ts                        # Facade composing all features\n│   │   └── index.ts\n│   ├── services/\n│   │   ├── xtream-api.service.ts                  # Xtream Codes API calls\n│   │   ├── xtream-url.service.ts                  # Stream URL construction\n│   │   ├── favorites.service.ts                   # Favorites persistence\n│   │   ├── epg-queue.service.ts                   # EPG fetch queueing\n│   │   ├── xtream-xmltv-fallback.service.ts       # XMLTV fallback EPG\n│   │   └── index.ts\n│   ├── data-sources/\n│   │   ├── xtream-data-source.interface.ts        # Abstract interface + types\n│   │   ├── electron-xtream-data-source.ts         # DB-first implementation\n│   │   ├── pwa-xtream-data-source.ts              # API-only implementation\n│   │   └── index.ts                               # provideXtreamDataSource() factory\n│   ├── with-favorites.feature.ts                  # Favorites feature\n│   └── with-recent-items.ts                       # Recently viewed feature\n└── feature/src/lib/                               # Routed components\n    ├── xtream-feature.routes.ts                   # createXtreamRoutes(): /workspace/xtreams/:id tree\n    ├── live-stream-layout/, vod-details/, serial-details/, ...\n    └── global-search-results/                     # Global search (Electron-only route)\n```\n\nKey patterns:\n\n- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality\n- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility\n- **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and\n  API/in-memory implementations\n- **Factory injection**: `provideXtreamDataSource()` selects\n  `ElectronXtreamDataSource` only when\n  `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it\n  selects `PwaXtreamDataSource`\n- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging.\n  `withSelection` keeps a `visibleCount` render window over the in-memory\n  catalog plus bounded per-selection scroll snapshots for detail/tab\n  round-trips; the shared `InfiniteScrollDirective`\n  (`libs/portal/shared/ui`) measures container overflow to auto-fill tall\n  viewports (terminating on lack of container growth, not on a load count)\n  and fires `loadMore` near the bottom. The search layout routes its results\n  container through the same directive (`nearEnd*` inputs). Stalker feeds the\n  same contract from server-paged appends: portal pages accumulate into one\n  deduplicated list, `hasMoreContent` derives from accumulated length vs\n  `total_items`, a failed append keeps loaded pages and offers a tail retry,\n  and the facade maps page 0 to the skeleton and later pages to the tail\n  spinner. No paginator remains anywhere in the app\n\nXtream data strategies by runtime capability:\n\n| Capability                        | Strategy                                                 |\n| --------------------------------- | -------------------------------------------------------- |\n| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB  |\n| **Bridge unavailable**            | API-only: fetch from API and keep session data in memory |\n\n**M3U Playlist Module Architecture**:\n\nThe M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│                         VIDEO PLAYER PAGE                            │\n│        libs/playlist/m3u/feature-player/src/lib/video-player/       │\n├─────────────────────────────────────────────────────────────────────┤\n│  ┌─────────────┐  ┌───────────────────────────────────────────────┐│\n│  │   Sidebar   │  │        Video Player (ArtPlayer/Video.js)      ││\n│  │ ┌─────────┐ │  │                                               ││\n│  │ │Channel  │ │  ├───────────────────────────────────────────────┤│\n│  │ │List     │ │  │  EPG timeline ribbon (app-epg-timeline)       ││\n│  │ │Container│ │  │  horizontal, under the player                 ││\n│  │ └─────────┘ │  └───────────────────────────────────────────────┘│\n│  └─────────────┘                                                    │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nThe 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.\n\n**Radio Channel Layout** (when `channel.radio === 'true'`):\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│  ┌─────────────┐  ┌────────────────────────────────────────────────┐│\n│  │   Sidebar   │  │  Blurred backdrop (station logo)              ││\n│  │             │  │  ┌──────────┐                                 ││\n│  │             │  │  │ Artwork  │  ← cinematic hero layout        ││\n│  │             │  │  └──────────┘                                 ││\n│  │             │  │  Station Name                                 ││\n│  │             │  │  [LIVE] badge                                 ││\n│  │             │  │  ⏮  ▶/⏸  ⏭   ← transport controls          ││\n│  │             │  │  🔊 ━━━━━━━━━  ← volume slider               ││\n│  │             │  │  (no EPG panel)                               ││\n│  └─────────────┘  └────────────────────────────────────────────────┘│\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nKey radio behavior:\n\n- Detection: `channel.radio === 'true'` (string from M3U `radio` attribute)\n- The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio\n- EPG panel is conditionally hidden in the template when radio is active\n- Volume is shared with video player via `localStorage` key `'volume'`\n- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry\nrecognized as a movie FILE swaps the player + EPG area for the portals'\ntwo-state VOD detail shell fed by TMDB, watch-first (activation still plays\nimmediately; Esc reveals the Browse hero). Detection is synchronous URL-shape\nheuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`)\nor an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/`\npaths and episode-marker names (`S01E02`, \"2 серия\") fail toward the live\nlayout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB\nenrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in\nSettings → Metadata (TMDB)). Host: `m3u-vod-detail/` in\n`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`,\nparent's `embeddedPlayback()` with `isLive: false`); external MPV/VLC users\nkeep Browse. See \"Movie Recognition (VOD Detail View)\" in\n`docs/architecture/m3u-playlist-module.md`.\n\nChannel List Component Structure (parent coordinator pattern):\n\n```\nlibs/ui/components/src/lib/channel-list-container/\n├── channel-list-container.component.ts   # Parent - shared state coordinator\n├── all-channels-view/                     # Virtual scroll + debounced search\n├── groups-view/                           # Expansion panels + infinite scroll\n├── favorites-view/                        # CDK drag-drop reordering\n├── recent-view/                           # Recently viewed channels\n└── channel-list-item/                     # Individual channel display\n```\n\nKey patterns:\n\n- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance\n- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)\n- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists\n- **Infinite scroll**: IntersectionObserver in groups view loads 50 items at a time\n- **Global progress tick**: Single 30s interval instead of per-item intervals\n\nState management via NgRx (`libs/m3u-state/`):\n\n- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist\n- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive\n- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag\n- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites\n\nSee `docs/architecture/m3u-playlist-module.md` for complete documentation.\n\n**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.\n\n- Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources`\n- M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player`\n- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`\n- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`\n- Global collections: `/workspace/global-favorites`, `/workspace/global-recent`\n- Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`)\n- Downloads: `/workspace/downloads` with focused\n  `/workspace/downloads/:downloadId`; source-scoped equivalents are\n  `/workspace/xtreams/:id/downloads/:downloadId` and\n  `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide\n  the workspace context panel.\n- 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 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\n\n**Service Architecture** (Factory Pattern):\n\n- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract\n- Two environment-specific implementations:\n    - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend\n    - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version\n- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject:\n    ```typescript\n    if (window.electron) {\n        return inject(ElectronService);\n    }\n    return inject(PwaService);\n    ```\n\n**Data Storage (Environment-Specific)**:\n\n- **Electron**: SQLite database via Drizzle ORM (`better-sqlite3` driver)\n    - Location: `~/.iptvnator/databases/iptvnator.db`\n    - Full-featured relational database with foreign keys and indexes\n    - Canonical schema and connection live in `libs/shared/database`\n- **PWA (Web)**: IndexedDB via `ngx-indexed-db`\n    - Browser-based NoSQL storage\n    - Same schema structure but implemented in IndexedDB\n    - Limited by browser storage quotas\n\n**TypeScript File Size Rule**:\n\nKeep production TypeScript files under **300 lines**. Hard maximum is\n**350–400 lines**, and CI enforces the 400. Blank lines and comments do not\ncount toward it, so documenting a file never costs you headroom. Tests\n(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the\nguidance below is about production code.\n\n- When creating new files, design them to stay within this limit from the start.\n- 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.\n- 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.\n\nTypical split strategies:\n\n- Angular components: extract child components, move logic to a dedicated service or store feature\n- Signal store features: split into smaller `with*` feature functions in separate files\n- Services: split by responsibility (e.g. separate API, transformation, and state concerns)\n- Utility files: group by domain and export from a barrel `index.ts`\n\nThis rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.\n\n---\n\n**Angular Coding Standards**:\n\nThis project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:\n\n- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly menu = viewChild.required<MatMenu>('menuRef');\n    readonly items = viewChildren<ElementRef>('item');\n\n    // ❌ Incorrect - Old decorator syntax\n    @ViewChild('menuRef') menu!: MatMenu;\n    @ViewChildren('item') items!: QueryList<ElementRef>;\n    ```\n\n    **Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:\n\n    ```html\n    <!-- ✅ Correct - Unwrap the signal -->\n    <button [matMenuTriggerFor]=\"menu()\">Open Menu</button>\n\n    <!-- ❌ Incorrect - Signal not unwrapped -->\n    <button [matMenuTriggerFor]=\"menu\">Open Menu</button>\n    ```\n\n- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly title = input.required<string>();\n    readonly size = input<number>(10); // with default value\n    readonly clicked = output<string>();\n\n    // ❌ Incorrect - Old decorator syntax\n    @Input({ required: true }) title!: string;\n    @Input() size = 10;\n    @Output() clicked = new EventEmitter<string>();\n    ```\n\n- **Reactive State**: Use signal primitives for reactive state management\n\n    ```typescript\n    // ✅ Use signal(), computed(), effect(), linkedSignal()\n    readonly count = signal(0);\n    readonly doubled = computed(() => this.count() * 2);\n\n    constructor() {\n        effect(() => {\n            console.log('Count changed:', this.count());\n        });\n    }\n    ```\n\n- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)\n\n    ```typescript\n    @HostBinding('class.active') get isActive() { return this.active(); }\n    @HostListener('click') onClick() { /* ... */ }\n    ```\n\n- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`\n\n    ```typescript\n    // ✅ Correct - Modern syntax\n    @if (isLoggedIn()) {\n        <p>Welcome!</p>\n    }\n\n    @for (item of items(); track item.id) {\n        <li>{{ item.name }}</li>\n    }\n\n    // ❌ Incorrect - Old syntax\n    <p *ngIf=\"isLoggedIn\">Welcome!</p>\n    <li *ngFor=\"let item of items; trackBy: trackById\">{{ item.name }}</li>\n    ```\n\n### Backend Architecture (Electron)\n\n**Main Entry**: `apps/electron-backend/src/main.ts`\n\n- Bootstraps Electron app and initializes database\n- Registers event handlers for IPC communication\n- 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.\n\n**Database**:\n\n- **ORM**: Drizzle ORM with `better-sqlite3` (local SQLite file)\n- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)\n- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):\n    - `playlists` - Playlist metadata (M3U, Xtream, Stalker)\n    - `categories` - Content categories (live, movies, series)\n    - `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\n    - `favorites` - User favorites\n    - `recentlyViewed` - Watch history\n    - `epgChannels`, `epgPrograms` - Persisted EPG data\n    - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)\n    - `playbackPositions` - Resume positions\n    - `downloads` - Download manager state\n    - `appState` - Key-value app state (also tracks one-off data migrations)\n    - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)\n    - `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`)\n- **Connection**: `libs/shared/database/src/lib/connection.ts`\n    - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)\n    - Provides full read-write access for `electron-backend` and a read-only mode\n    - A root `drizzle.config.ts` configures Drizzle Kit tooling (points at the schema via the compat shim)\n\n**IPC Communication**:\n\n- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts`\n    - Exposes `window.electron` API via `contextBridge`\n    - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)\n    - 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.\n- **Event handlers**: `apps/electron-backend/src/app/events/`\n    - `database.events.ts` - Database CRUD operations\n    - `playlist.events.ts` - Playlist import/update\n    - `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`\n    - `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`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`\n    - `xtream.events.ts` - Xtream Codes API\n    - `stalker.events.ts` - Stalker portal API\n    - `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`) 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 <code>`, 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`\n    - `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\n    - `settings.events.ts` - App settings\n    - `electron.events.ts` - App version, etc.\n\n**Workers** (`apps/electron-backend/src/app/workers/`):\n\n- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`\n- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)\n- 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`)\n\n### Key Features\n\n**Playlist Support**:\n\n- M3U/M3U8 files (local or URL)\n- Xtream Codes API (`username`, `password`, `serverUrl`)\n- Stalker portal (`macAddress`, `url`)\n\n**Stalker playback links**: `create_link` runs only when the catalog row sets\n`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays\ndirectly. One helper decides\n(`resolveStalkerStaticPlaybackUrl` in\n`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied\nby `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by\n`StreamResolverService` for Favorites/Recently Viewed. It falls back to\n`create_link` for anything it cannot resolve alone: no row to read flags from,\na relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,\nor a loopback host; an episode (`series` set) always mints, since the parameter\nselects the episode server-side. Temporary links live ~5 s, so no resolved URL\nis persisted or replayed — favorites and recently-viewed store the `cmd`,\nplayback positions store ids, and the main-process context map stores headers\nkeyed by origin+path. Downloads are the one exception (they must retry a URL).\n`forced_storage`/`play_token` are deliberately unwired. Contract:\n`docs/architecture/stalker-portal.md` (\"Playback Link Resolution\").\n\n**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed\non the command line, opened through a file association, or delivered by macOS'\n`open-file` event is normalized to an absolute path in the main process\n(`services/playlist-open-request.ts`) and queued there. The renderer\n(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the\n`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is\nwhat makes the main process flush. `OPEN_FILE` is the only way out of the\nqueue, and a request stays there until the renderer confirms receipt via\n`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the\nlistener runs, and a reload or dead render process keeps the `WebContents`\nalive, so a successful push is not proof of delivery. Anything unacknowledged\nis replayed to the next renderer that announces itself. The renderer\nimports them on a single promise chain so a burst arrives in a deterministic\norder. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)\nfor the same reason: each action carries a different playlist, so a newer add\nmust never cancel an older one's write, EPG fetch and navigation. The import\nitself reuses the normal file path\n(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,\nplaylist-scoped EPG, and the navigation to the new playlist all behave exactly\nlike a dialog import.\n\nThe OS-level registration that makes those paths reachable is\n`fileAssociations` in `electron-builder.json` — one entry per extension, each\nwith its own `mimeType`. Electron Builder derives all three platform\nregistrations from it: macOS `CFBundleDocumentTypes` (which is what makes\n`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the\ndesktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for\ndeb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading\n`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and\nmust not be used; and it appends `%U` to `Exec`, so Linux file managers hand\nover percent-encoded `file://` URIs rather than paths —\n`createPlaylistOpenRequest` decodes them before the extension check. `%U` is\nalso the _plural_ exec code, so a multi-file selection arrives as one launch\nwith one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all\nof them and `enqueueAll` queues the batch, because stopping at the first match\nwould silently drop the rest of the selection. Adding an exec code to\n`linux.executableArgs` would suppress the `%U` but also pass that code to the\napp as a real argument, so it is not an option.\n\n**Video Players**:\n\n- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer\n- mpegts.js `1.8.1` errors from all three built-in players cross one\n  version-locked structured evidence boundary in `libs/playback/util`. It\n  retains only exact public type/detail pairs, pair-derived stage/failure,\n  terminal disposition, and a\n  validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach\n  stored or rendered diagnostics. HTTP/network failures avoid false decoder\n  recommendations, while exact format, codec, truncated-stream, and\n  MediaSource failures retain actionable recovery guidance. This diagnostic\n  layer remains separate from the shared `PlayerController` controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded\n  Shaka Player source engine inside the HTML5 and ArtPlayer components (no new\n  player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`\n  lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in\n  `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all\n  import paths). DASH channels always play inline: `isDashChannel()` bypasses\n  the external-player setting (radio precedent) and routes Video.js/MPV/VLC/\n  embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps\n  ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,\n  need the castLabs Electron fork) surface a DRM playback diagnostic instead\n  of crashing. ClearKey EME works in stock Electron. Engine:\n  `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.4`\n  diagnostic boundary lives in `libs/playback/util`; it version-locks public\n  severity/category/code evidence, ignores\n  recoverable error events, treats rejected loads as terminal lifecycle\n  outcomes, preserves exact public DASH text-parser category/code evidence with\n  unknown stage/failure, and never retains or renders raw messages or\n  `error.data`. A failed browser-support preflight stays generic-unknown but\n  carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. Details in\n  `docs/architecture/m3u-playlist-module.md` (\"DASH + ClearKey Playback\").\n- External players: MPV, VLC (via IPC to Electron backend)\n- Display sleep during playback: `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock: in\n  Electron a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`;\n  auto-cleared on renderer reload/crash), in the PWA the Screen Wake Lock\n  API. Radio's `<audio>` deliberately never blocks display sleep. Embedded\n  MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. 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=<x11-window>` 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. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.\n- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux\n  x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy\nengine` (restart required) or\n  `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV\n  experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,\n  EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the\n  preload frame pump uploads them to\n  `<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM\n  UI; native-view retains the legacy dock. On Linux, only\n  `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the\n  addon, and frame reader must not. Pristine afterPack/unpacked layouts scan\n  Electron libraries recursively; extracted Snap payloads exclude only the\n  package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.\n  Every other directory remains recursive, and Electron-library symlinks still\n  fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;\n  `afterPack` alone owns the profile-normalized unpacked native tree, and\n  package checks reject every archived `/electron-backend/native/**` entry.\n  Packaged addon, frame-reader, and helper discovery uses only package-owned\n  `app.asar.unpacked` paths; cwd/dist candidates remain development-only.\n  Official x64 packages use three separate profiles:\n  DEB/RPM/Pacman depend on system libmpv plus the helper's direct\n  EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and\n  Flatpak bundles the same closure. Flatpak is an isolated packaging pass and\n  keeps `iptvnator` as the real Electron ELF so Electron Builder's\n  `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the\n  conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed\n  Flatpak/non-Flatpak target sets fail before mutation. Exact system\n  dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n  RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n  Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;\n  Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.\n  ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the\n  fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;\n  any failure keeps the sandbox enabled, records a stable reason, and falls\n  back to native-view without crashing. Snap is `core22`/strict and uses an\n  exact private `shared-memory` plug plus the `graphics-core22` content plug at\n  a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the\n  default provider. Its only provider-data layouts bind `/usr/share/libdrm`\n  from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to\n  `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable\n  status after disconnect, then reconnects and requires success. Static\n  artifact verification requires regular `desktop-init.sh`,\n  `desktop-common.sh`, and `desktop-gnome-specific.sh` files at the Snap root,\n  with `desktop-init.sh` executable. The helper links `libGL.so.1`, and\n  probe/playback share a sanitized loader environment\n  in which ambient audit, preload, library, graphics-driver, and shell-startup\n  overrides are removed; the validated private closure plus trusted host GL,\n  graphics-content, core22 base x64, and exact GNOME-platform roots have\n  explicit precedence. The core22 base stays ahead of GNOME so the older\n  `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The\n  extracted-artifact verifier removes the identical unsafe loader/graphics/\n  shell set before direct helper smoke while preserving selectors such as\n  `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,\n  removes exported `BASH_FUNC_*` functions, and\n  launches probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only\n  `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate\n  before BrowserWindow startup and emits one availability JSON line. A nonzero\n  helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is\n  present only for an exact protocol-v1 line carrying a fixed allowlisted\n  reason, and its optional `helperDetail` must be 1–1024 printable ASCII\n  characters. Invalid detail suppresses both helper fields. Every probe uses\n  an explicit 16 MiB aggregate captured-output ceiling independent of tracing.\n  With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately\n  as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an\n  explicit `truncated` field; trace-write failure cannot change availability.\n  Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded\n  channel. The exact packaged Flatpak `/app` context reconstructs only\n  Freedesktop Platform 24.08's immutable\n  `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that\n  application-level probe instead of the helper directly. The packaged x64\n  Playwright smoke runs its fixture-contract target first and passes Chromium\n  `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass\n  the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux\n  packages carry hash-validated\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.\n  CI caches the staged runtime plus immutable source inputs, never finished\n  notices or the compliance tarball; it regenerates those notices and the\n  VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current\n  checkout while preserving the exact pinned six recursive libplacebo\n  submodule records. Each record is canonical `full-commit safe/path`;\n  clone-depth dependent `git describe` annotations are discarded and never\n  form part of the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. Every\n  bundled x64 package manifest binds the final archive's SHA-256 and repository\n  revision; system and marker-only packages do not carry that binding. Snap\n  Store\n  publication runs only from a public `v*` GitHub release that already\n  contains the Snap assets and exactly one source archive. Before any upload,\n  the workflow hashes and checks the archive's exact member/type set and size\n  bounds, verifies its clean tag revision, pinned sources including the six\n  recursive submodule records and exact libplacebo tree digest, legal payload,\n  and exact released tooling, then performs bounded extraction and static\n  validation for every Snap. That public-release boundary independently\n  revalidates the exact strict `meta/snap.yaml` graphics/shared-memory\n  contract and enumerates `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`. Exactly one x64 Snap\n  must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.\n  Checkout and artifact-transfer actions are pinned to full commits; checkout\n  does not persist credentials, and repository credentials are scoped to\n  download steps. A secretless verification job copies assets through\n  no-follow descriptors, checks them before and after inspection, writes an\n  exact receipt, fully reverifies a root-owned read-only snapshot, and\n  transfers only that data through the pinned artifact service while passing\n  the receipt digest separately through a job output. The dependent publish\n  job uses a bounded `ubuntu-latest` runner with no checkout or release-tag\n  code, verifies that digest plus the exact receipt, asset hashes, and\n  file-only layout, root-seals the data again, and installs Snapcraft directly.\n  Its final fixed shell step alone receives the Store credential, resolves no\n  PATH command, executes no released code, and exposes that credential only to\n  each exact\n  `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable\n  promotion is manual after installed-Snap frame-copy and missing-runtime\n  fallback smoke; GitHub Actions never promotes automatically. On Windows,\n  package validation requires the exact MPV DLL named by the helper's PE import\n  table beside the executable.\n  Backend adapter:\n  `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;\n  shared-controls adapter:\n  `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;\n  helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime\n  contracts: `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n- 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. 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-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `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, 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 active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. 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). `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`.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n\n**Download Manager**:\n\n- Fresh Xtream movie and series-episode downloads propagate the playlist's\n  User-Agent, Referer, and Origin, defaulting User-Agent to the same\n  provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and\n  stream probes. Retry, resume, and missing-file\n  recovery also add the fallback to legacy Xtream rows that have no stored\n  User-Agent. Because download rows survive source deletion, a headerless\n  legacy row whose playlist is already absent receives the same IPTV-player\n  fallback; a known Stalker row remains unchanged. Allowlisted connection\n  resets after bytes reach disk retain the partial and show a credential-safe\n  `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong\n  ETag or Last-Modified validator. Retry then continues with Range/If-Range;\n  without a validator it starts from byte zero and overwrites the unverified\n  partial instead of risking mixed-representation corruption.\n- The desktop-only manager shares one global download store across the global,\n  Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series\n  cards use the global Small/Medium/Large cover-grid tokens; missing completed\n  files move to Needs attention instead of remaining in Ready to watch.\n- Series details route individual and selected-season episode downloads through\n  the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode\n  pending identities synchronously, submits season candidates sequentially and\n  best-effort through the existing `DOWNLOADS_START` path, performs one final\n  authoritative refresh after added or stable duplicate submissions, and\n  reports added, skipped, and failed counts. Xtream and Stalker adapters remain\n  responsible for provider URLs, headers, and metadata; the backend still runs\n  one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole\n  start IPC. A reserved completed-missing match triggers one authoritative\n  preflight refresh before provider preparation. Download-list loads are\n  serialized as one active IPC plus one coalesced trailing refresh; a preflight\n  assigned to that trailing refresh cannot be starved by later progress\n  broadcasts. A restored Stalker file can therefore become a stable skip\n  without a portal request. The IPC's stable\n  `reason: 'already-in-progress'` and `reason: 'already-downloaded'`\n  results are counted as skipped, and no batch IPC is introduced. The latter\n  comes from an asynchronous main-process filesystem recheck before a\n  completed-missing row can be reset, so a file restored after the renderer\n  snapshot is not orphaned or downloaded again. The recheck has a one-second\n  caller deadline that starts before shared-slot acquisition; timeout or probe\n  failure leaves the row untouched and reports a failed submission so the\n  season loop can continue. Completed-file list callers use the same deadline\n  and report a timeout as missing for that snapshot. The underlying filesystem\n  operation remains coalesced and charged against the four-probe cap until it\n  settles, so later callers have independent bounded waits without duplicating\n  stalled native work. Only `ENOENT` and `ENOTDIR` prove absence; permission,\n  I/O, and other filesystem errors remain unknown and cannot clear a completed\n  row. Before a completed-missing, failed, or canceled row clears its retained\n  path, the start IPC asynchronously removes any `.part` through a separate,\n  same-path-coalesced, four-operation cap. A one-second admission deadline\n  rejects queued work before unlink starts; started work is awaited so it cannot\n  mutate after a failure response. Non-absence errors keep the row's ownership\n  intact; `ENOENT` and `ENOTDIR` safely proceed. Episode and season download\n  actions require an authoritative global list. A\n  successful snapshot remains authoritative while a later background refresh\n  is in flight; a latest refresh failure leaves\n  loading/empty-state resolution intact but disables starts until another\n  snapshot succeeds. Overlapping download-list callers join one serialized\n  trailing refresh, so responses commit in request order and frequent progress\n  events cannot perpetually postpone a waiting series action.\n- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`\n  for both providers; Stalker playback identifiers only resolve the URL. Exact\n  `(playlistId, contentType, xtreamId)` matches are authoritative, while\n  complete playlist/series/season/episode coordinates are a fail-closed legacy\n  fallback that migrates reusable rows to the canonical id. Numeric season\n  zero, including fallback key `\"0\"`, remains a valid Specials coordinate for\n  both providers. Stalker persists\n  `episode_identity_scope` separately for regular `/series`, embedded VOD\n  `series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not\n  match; a pre-scope coordinate row is ambiguous and blocked, while an exact\n  canonical legacy row remains authoritative. Renderer lookup preserves that\n  ambiguity or conflicting ownership as a distinct ineligible state, so\n  neither the episode action nor the season count treats it as a row-less\n  download. SQLite `null` and optional `undefined` coordinates both mean an\n  incomplete canonical legacy row, matching the backend resolver. Pending and\n  active rows plus completed available/unknown rows are skipped; failed,\n  canceled, completed-missing, and unambiguous row-less episodes remain\n  eligible.\n- Ready cards (movies, grouped series, and standalone episodes) open a focused\n  local detail; local file actions (Play, Show in folder, Copy URL, Remove)\n  live in the poster's overflow menu. Movies play the finalized local file;\n  series list only locally available episode rows and every episode action\n  targets its own downloaded file. Focused routes disable route search and use\n  `contextPanel: 'none'`.\n- Downloads capture a versioned metadata snapshot from the rendered Xtream or\n  Stalker movie/episode detail at start time, including already-merged TMDB\n  fields. Legacy, sparse, stale, or wrong-language snapshots are safely\n  backfilled from row/provider metadata and optional TMDB enrichment when the\n  focused detail opens.\n- `View in portal` resolves a concrete Xtream category/item route. Stalker\n  accepts a recently-viewed shape only when its raw movie/series mode matches\n  the download, and prefers an exact numeric category from the download\n  snapshot. Without that shape, only a movie carrying an exact category can\n  form a metadata-only target; unproven episode and legacy-movie handoffs stay\n  unavailable. The normal detail uses one-shot `provider-only` presentation:\n  it exposes provider content/playback it can resolve while hiding\n  Offline/local/download actions. A second, independent `View in portal`\n  bridge exists for inline collection details — see **Collection Detail\n  Portal Handoff** below; it deliberately does NOT use `provider-only`.\n- Download rows and local files survive source deletion. The global offline\n  library remains visible with no playlists; only provider handoff is disabled\n  until the source exists again.\n- If a finalized file disappears while a focused detail is open, the\n  authoritative download list refreshes and returns to the manager. A failed\n  redirect leaves an actionable missing-file state with Back and Retry.\n- Canonical contract: `docs/architecture/download-manager.md`; provider handoff:\n  `docs/architecture/portal-detail-navigation.md`.\n\n**Collection Detail Portal Handoff** (`View in portal` for inline details):\n\n- Details opened outside portal category context — `/workspace/global-favorites`,\n  `/workspace/global-recent` (which also receive the dashboard hero, Continue\n  Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent`\n  tabs — render full-width with no category sidebar. They expose a separate-row\n  hero action that jumps to the item inside its owning portal.\n- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action`\n  (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host\n  provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are\n  `XtreamCollectionDetailComponent` (through its dynamic detail injector) and\n  `StalkerCollectionDetailComponent` (component providers), which exist only in\n  collection contexts — so router-mounted category details need no opt-out. When\n  hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim\n  a phantom row in the hero action container.\n- Targets come from `getUnifiedCollectionDetailNavigation()`\n  (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike\n  `getUnifiedCollectionNavigation` it NEVER degrades to a category- or\n  section-only route: an Xtream item without a resolvable category and positive\n  item id keeps the action hidden rather than promising a jump to the title and\n  landing in a list.\n- Stalker section resolution mirrors `resolveStalkerCollectionDetailMode()`\n  (`libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`) and\n  must not be\n  simplified to `item.contentType`: `extractStalkerItemType()` reports `series`\n  for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but\n  both belong in the VOD catalog — the lazy season/episode fetch in\n  `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so\n  a `/series` route leaves the detail unable to load episodes. The virtual\n  `series` category is normalized to `vod` the same way\n  `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries\n  `stalkerReturnTo` plus\n  `stalkerReturnByHistory`, and the portal detail's back affordance\n  (`StalkerCatalogDetailComponent.onVodBack()`,\n  `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back\n  one history entry instead of calling `navigateByUrl()`. The collection's\n  active tab, scope and open inline detail live only in `window.history.state`\n  (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would\n  reopen it on the default `live` tab and leave the portal page one browser\n  Back away. The marker carries the handed-off item's identity, not a bare\n  `true`: `openStalkerItem` is consumed on arrival while the return keys stay\n  on the entry, and a Stalker detail opens in place without pushing one — so\n  after Back + browser Forward the same entry can host a different title, whose\n  back affordance must just close it. A stale marker suppresses the whole\n  return contract, and honouring it retires both keys from the entry so a\n  browser Forward cannot replay them for a reopened title. Leaving with the\n  browser's own Back runs no affordance, so `CategoryContentViewComponent`\n  also retires the contract whenever it lands on the entry with no handoff\n  item and no open detail. That retirement is gated on the marker, so a plain\n  `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is\n  restricted to what `buildStalkerSelectedVodItem()` preserves (`id ??\nstream_id`); it drops `series_id`/`movie_id`, so the builder pins the\n  resolved id onto the handoff state item when the raw row carries neither —\n  those rows then get the same history return instead of degrading to a\n  re-navigation that resets the collection's tab.\n  Only this builder sets the marker, so the\n  dashboard handoff and any other `stalkerReturnTo` caller keeps\n  re-navigating.\n- Unlike the download handoff this bridge does NOT pass\n  `detailPresentation: 'provider-only'` — the item exists in the provider\n  catalog, so the full normal detail (downloads included) is wanted.\n- Contract: `docs/architecture/portal-detail-navigation.md`.\n\n**VOD/Series Detail Pages (two-state layout)**:\n\n- 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)\n- 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)\n- 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\n- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and \"Close player\" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)\n- 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.\n- 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.\n- 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. Lazy VOD episode tracking IDs scope the parent series, provider episode, 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 any stored season/episode coordinates. 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.\n- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block\n- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) 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)\n- Dashboard hero/Continue Watching clicks for an Xtream 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. Ordinary global-recent grid clicks remain detail-only.\n- See `docs/architecture/embedded-inline-playback.md` (\"Two-State Detail Layout\")\n\n**VOD Multi-Source** (alternative sources for a movie):\n\n- 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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.\n- 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`.\n- 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).\n- 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.\n- 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.\n- See `docs/architecture/vod-multi-source.md`\n\n**Radio Player**:\n\n- Dedicated audio player for channels with `radio=\"true\"` M3U attribute\n- Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls\n- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio\n- EPG panel is hidden for radio channels (radio streams have no EPG data)\n- Volume synced with video player via shared `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**EPG (Electronic Program Guide)**:\n\n- XMLTV format support\n- Background parsing in worker thread\n- Stored in database for quick lookup\n- 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/`\n\n**TMDB Metadata Enrichment** (opt-in):\n\n- 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\n- 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\n- \"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\n- 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`)\n- 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\n- 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`\n- 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`)\n- 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`\n- 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\n- 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); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)\n- 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:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment\n- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)\n- 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, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.\n- 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`)\n- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About\n- See `docs/architecture/tmdb-metadata-enrichment.md`\n\n**Portal Account Info**:\n\n- 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.\n- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.\n- 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\").\n- 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.\n\n**Stalker Portal Mode and Endpoint Discovery**:\n\n- 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.\n- 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.\n- 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 `<base>/portal.php` → `<base>/server/load.php` → `<base>/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 `<base>/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.\n- 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.\n- `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.\n- 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.\n- 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.\n- 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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Portal Mode and Endpoint Discovery\", \"Request Transport and `cmd` Encoding\").\n\n**Stalker Session Authentication**:\n\n- 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`.\n- `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.\n- 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.\n- 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.\n- 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.\n- 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.\n- Full contract: `docs/architecture/stalker-portal.md` (\"Session Authentication Lifecycle\").\n\n**Stalker Identity Hardening**:\n\n- 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.\n- 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.\n- `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.\n- `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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Stalker Identity Policy\").\n\n**Favorites and Recently Viewed**:\n\n- Per-playlist favorites and global favorites\n- Recently viewed tracks watch history\n\n**Internationalization**:\n\n- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/`\n\n## Development Notes\n\n### Environment Detection and Dual-Mode Architecture\n\nThe app determines whether it's running in Electron or as a PWA by checking:\n\n```typescript\nwindow.electron; // truthy in Electron, undefined in browser\n```\n\n**Why Dual Mode?**\nIPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:\n\n- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access\n- **PWA**: Lightweight web version that runs in any browser without installation\n\n**Environment-Specific Behavior**:\n\n- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment\n- `app.routes.ts` - Same `/workspace/...` route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA\n- Storage layer switches automatically:\n    - Electron → SQLite/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db`\n    - PWA → IndexedDB → Browser storage\n- External player support (MPV/VLC) only available in Electron\n- File system operations only available in Electron (uploading playlists from disk)\n\n**Base Href Configuration**:\nThe app uses different base href values depending on the build target:\n\n- **Development & PWA**: `baseHref=\"/\"` (from `index.html`)\n    - Used by: `pnpm run serve:frontend`, `pnpm run build:frontend:pwa`\n    - For web servers with proper routing\n- **Electron Production**: `baseHref=\"./\"` (overridden in build config)\n    - Used by: `pnpm run build:backend`, `pnpm run make:app`\n    - Required for `file://` protocol in Electron\n\nBuild configurations in `apps/web/project.json`:\n\n- `production`: Electron build with `baseHref=\"./\"`\n- `pwa`: Web deployment with `baseHref=\"/\"`\n- `development`: Dev mode with `baseHref=\"/\"` from index.html\n\n**Factory Pattern Implementation**:\nThe 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.\n\n**Build Commit In About**:\nCI 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 `\"<version> (<short-sha>)\"`. The semver version itself deliberately stays untouched — 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.\n\n### Testing Strategy\n\n- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`\n- **E2E tests**: Playwright testing the web app and Electron app\n- Backend tests use standard Jest\n- Bug fixes should add focused regression coverage unless there is a documented reason not to.\n- 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.\n\n### Nx Commands\n\nUse `nx` CLI for better performance:\n\n```bash\npnpm nx run <project>:<target>\n# Example: pnpm nx run web:build\n# Example: pnpm nx run electron-backend:serve\n```\n\nTo run multiple projects:\n\n```bash\npnpm nx run-many --target=test --all\n```\n\n### Electron Build Process\n\nThe Electron backend depends on the web app being built first:\n\n- `electron-backend:build` depends on `web:build`\n- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend)\n- Packaging combines both into distributable\n\n### Database Migrations\n\nNo formal migration system yet. Schema changes are applied via raw SQL in the `createTables()` function in `libs/shared/database/src/lib/connection.ts` using `CREATE TABLE IF NOT EXISTS`. One-off data migrations run guarded by keys stored in the `appState` table.\n\n### Common Patterns\n\n**IPC Communication**:\n\n1. Define handler in appropriate events file (e.g., `database.events.ts`)\n2. Register with `ipcMain.handle()` in the event bootstrap function\n3. Expose in preload script via `contextBridge.exposeInMainWorld()`\n4. Call from Angular via `window.electron.<methodName>()`\n\n**Adding New Playlist Source**:\n\n1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts`\n2. Create event handler in `apps/electron-backend/src/app/events/`\n3. 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\n4. Update database schema if needed\n\n**State Management**:\n\n- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)\n- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)\n- Use NgRx signals for reactive data streams\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n","AGENTS.md":"# AGENTS.md\n\nThis file provides guidance to coding agents working in this repository.\n\n## Plan Mode\n\n- When an agent is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Agent Bootstrap\n\n- 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.\n- After dependencies are installed, verify workspace discovery with `pnpm nx show projects`.\n- 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`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.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 -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused.\n- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint \"apps/<project>/**/*.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 <project> -name '*.ts' | wc -l`.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Electron Debugging (CDP)\n\n- Start the Electron development app with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- Electron is configured to start with: `--remote-debugging-port=9222`\n- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`\n- For Electron automation/debugging tasks, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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.\n- 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.\n- 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.\n\n### Trace / Debug Startup\n\n- Full startup tracing:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\n- Narrower trace flags:\n    - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n    - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events\n    - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker\n    - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events\n    - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n    - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal\n    - `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\n    - `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\n\n- Settings, portal request/response, and trace payloads must use\n  `@iptvnator/shared/logging` or the redacting portal logger before reaching\n  `console.*`; never log raw credentials while debugging.\n\n- If local Nx state gets weird before a rerun:\n\n```bash\npnpm nx reset\n```\n\n### agent-browser (global install)\n\n```bash\nagent-browser --cdp 9222 tab list\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\n```\n\n### Fallback\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### DevTools Workaround\n\n```bash\nELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend\ncurl http://127.0.0.1:9222/json/list\nagent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>\nagent-browser screenshot /tmp/iptvnator-cdp.png\n```\n\n## Radio / Audio Player\n\nM3U playlists can contain radio channels identified by the `radio=\"true\"` attribute on `#EXTINF` lines. When a radio channel is selected:\n\n- The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player\n- The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored\n- The EPG panel is hidden (radio streams have no EPG data)\n- 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\n- Volume is shared with the video player via `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle)\n- Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean\n\nKey files:\n\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling\n- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video\n- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface\n\n## Shared Player Controls\n\n- `libs/ui/playback/src/lib/player-controls/` contains the additive,\n  engine-neutral `PlayerController` contract, standalone\n  `app-player-controls`, generic web-video adapter/helper, and component-scoped\n  `WEB_PLAYER_SHARED_CONTROLS` rollout token.\n- In fullscreen, `app-player-controls` shows a pointer-transparent media-title\n  overlay at the top while controls are revealed (`mediaTitle` input:\n  movie/channel/series name, plus an `S01E03` second line for episodes). Series\n  names flow from the Xtream/Stalker detail views through\n  `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;\n  movie and live hosts fall back to `playback.title`, skipping raw stream-URL\n  fallbacks. Outside fullscreen the overlay stays hidden.\n- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox\n  appears only when HTML5, Video.js, or ArtPlayer is selected.\n  `WebPlayerViewComponent` snapshots the preference into\n  `WEB_PLAYER_SHARED_CONTROLS` for each new player host. The parent `/workspace`\n  route awaits the initial `SettingsStore` load, including cold-start direct\n  links, before this snapshot can occur. Saving applies to the next host without\n  an application restart; an existing session never changes controls mode in\n  place.\n- `Settings.showCaptions` is deliberately outside this rollout gate: it is\n  engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both\n  modes — shared controls through their controls bridge, the preference-off\n  paths through the same helpers without an adapter (`WebVideoSourceTracks` for\n  HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference\n  as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it\n  from `SettingsStore` rather than a host input, so the M3U player, the\n  Xtream/Stalker live layouts, and the portal detail inline player all inherit\n  it (#1155).\n- The modes differ in how long the preference is enforced. Shared controls are\n  authoritative for the session; user intent arrives through `setSubtitleTrack`\n  and wins until the source changes. Vendor chrome is source-default: the\n  preference seeds each new source and is released once the media element\n  reports `playing`, so the engine's own caption menu keeps working. The mode is\n  selected by the optional `playbackStarted` probe the legacy owners pass to all\n  three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper\n  deselects the track (`subtitleTrack = -1`) instead of hiding it, because\n  `subtitleDisplay` would silently override whatever the vendor menu picks. For\n  DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,\n  so the helper only stops re-suppressing afterwards.\n- Embedded MPV ignores the web-player preference. Frame-copy always uses shared\n  DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while\n  native-view retains the legacy compositor-safe dock and external MPV/VLC\n  retain their own UI. The host must render exactly one controls system for the\n  reported Embedded MPV engine.\n- Frame-copy shared controls own DOM surface interactions, shortcuts,\n  fullscreen, and recording feedback. `showControls=false` detaches the shared\n  surface, modal overlays gate playback shortcuts, fullscreen still triggers\n  bounds sync, and a playback/session transition key prevents engine or session\n  handoff from presenting stale recording feedback while timers and pending\n  commands are cancelled. Same-session IPC replies also yield to a broadcast\n  snapshot received while the command was pending, preventing a successful\n  recording acknowledgement from being rolled back by a stale reply.\n- DASH (`.mpd`) sources play through a lazily imported Shaka Player source\n  engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and\n  ArtPlayer components; ClearKey keys come from KODIPROP-derived\n  `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via\n  source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in\n  `libs/playback/util`; it version-locks public severity/category/code evidence,\n  ignores recoverable error events,\n  treats rejected loads as terminal lifecycle outcomes, preserves exact public\n  DASH text-parser category/code evidence with unknown stage/failure, and never\n  retains or renders raw messages or `error.data`. A failed browser-support\n  preflight stays generic-unknown but carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. See the CLAUDE.md \"Video Players\" feature\n  entry and the \"DASH + ClearKey Playback\" section of\n  `docs/architecture/m3u-playlist-module.md`.\n- mpegts.js `1.8.1` errors from HTML5, Video.js, and ArtPlayer cross one\n  version-locked structured evidence boundary in `libs/playback/util`. Only\n  exact public type/detail pairs, pair-derived stage/failure, terminal\n  disposition, and the validated HTTP 4xx/5xx status slot are retained; raw\n  messages and arbitrary `info`\n  never reach diagnostics. This is a sibling of `PlayerController`, not part\n  of the controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- The built-in HTML5/hls.js player is the second guarded consumer.\n  `HtmlVideoPlayerComponent` provides a component-scoped\n  `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared\n  with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,\n  caption preference, and source cleanup.\n  `HtmlVideoElementSession` owns native video-event lifecycle, persisted\n  volume, start-time/time/ended propagation, and legacy post-play caption\n  suppression.\n  `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD\n  metadata, while a visible playback diagnostic disables both shared surface\n  interaction and shortcuts and exits the HTML5 shell's own fullscreen so the\n  diagnostic actions remain visible. The preference-off path keeps native\n  controls and legacy series navigation unchanged, while the playback keyboard\n  shortcuts (Space/K, F, arrow seek/volume, M) attach through\n  `LegacyPlayerShortcuts` with commands acting on the native video element\n  (`html-video-legacy-shortcuts.ts`); seek requires authoritative VOD metadata\n  plus a finite positive duration, and a visible diagnostic disables the keys.\n- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech\n  video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,\n  preserves caption preference and explicit subtitle-off state, and reads\n  duration from Video.js. Reset-driven raw MPEG-TS changes pause first,\n  coalesce to the latest desired source, preserve actual volume across\n  Video.js's reset, and restart when authoritative live/VOD metadata changes.\n  The shared-controls path disables native controls, Video.js\n  click/double-click/hotkey actions, and spatial navigation;\n  diagnostic gating and owned-fullscreen exit match HTML5. The preference-off\n  path keeps the existing Video.js skin and legacy series navigation unchanged\n  (still without `userActions.hotkeys`), while the playback keyboard shortcuts\n  attach through `LegacyPlayerShortcuts` and drive the player API so the\n  vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).\n- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns\n  HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and\n  a destroyed-session guard for delayed `customType` callbacks, while\n  `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses\n  authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,\n  MPEG-TS VOD duration correction, and reapplies app volume directly after\n  ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,\n  and a transparent capture layer gives shared controls exclusive click and\n  double-click ownership. Diagnostic interaction gating and owned-fullscreen\n  exit match the other web players. The preference-off path keeps the legacy\n  ArtPlayer skin, source behavior, and series navigation unchanged, while the\n  playback keyboard shortcuts attach through `LegacyPlayerShortcuts` using the\n  vendor setters ArtPlayer's own hotkeys used\n  (`art-player-legacy-shortcuts.ts`); the legacy chrome passes `hotkey: false`\n  because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would\n  double-handle every key, and the wiring restores its Escape-exits-web-\n  fullscreen behavior.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n- Canonical docs: `docs/architecture/player-controls-contract.md` and\n  `docs/architecture/embedded-mpv-native.md`\n\n## Display Sleep During Playback\n\n- `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock.\n- Electron: a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`);\n  the renderer's vote is auto-cleared on renderer reload, crash\n  (`render-process-gone`), or destruction. PWA: the Screen Wake Lock API,\n  re-requested after browser auto-release; state changes masked by an\n  in-flight `request()` queue one re-evaluation on rejection.\n- Radio's `<audio>` deliberately never blocks display sleep. Embedded MPV\n  holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n\n## Linux Embedded MPV Packaging\n\n- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,\n  Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain\n  marker-only and must never inherit x64 native artifacts from environment\n  overrides.\n- Packaging runs three isolated profiles:\n    - `system`: DEB/RPM/Pacman, no private `native/lib`, with package\n      dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n      RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n      Pacman=`mpv,libglvnd,mesa`\n    - `portable`: AppImage/Snap with the pinned LGPL-compatible closure\n    - `flatpak`: Flatpak with the same pinned closure\n- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real\n  Electron ELF so Electron Builder's `electron-wrapper` passes it directly to\n  Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and\n  `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.\n- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04\n  provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the\n  package dependency or advertising frame-copy without a compatible runtime.\n- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,\n  Electron libraries, `embedded_mpv.node`, and\n  `embedded_mpv_frame_reader.node` must not load or link it. Preserve this\n  process-isolation contract in build, package, and smoke checks.\n- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`\n  exclusively writes the profile-normalized unpacked native tree. Layout and\n  final-artifact checks must reject every archived\n  `/electron-backend/native/**` entry so system and marker-only packages cannot\n  hide stale x64 artifacts.\n- Packaged addon, frame-reader, and helper discovery is package-owned\n  `app.asar.unpacked` only. Writable cwd/dist candidates are development-only\n  and must never satisfy packaged native-view support or the frame-copy gate.\n- Pristine afterPack/unpacked layouts scan Electron libraries recursively.\n  Extracted Snap payloads exclude only the package-manager `lib/**` and\n  `usr/lib/**` trees that Snap overlays into the same root; every other\n  directory remains recursive, and Electron-library symlinks still fail\n  closed.\n- Linux frame-copy availability is fail-closed. The packaged manifest,\n  artifact modes, declared bundled hashes/closure, and bounded\n  `--runtime-probe` must all succeed before frame-copy can relax the renderer\n  sandbox. Any failure reports a stable reason and falls back to native-view\n  without crashing; an environment flag never bypasses this gate.\n- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus\n  the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,\n  with `mesa-core22` as default provider. It declares only the canonical\n  provider layouts: `/usr/share/libdrm` binds from\n  `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to\n  `$SNAP/graphics/drirc.d`. The provider is external shared content, not part\n  of IPTVnator's package size, source archive, or notices. Installed-Snap CI\n  must prove controlled unavailable exit after disconnect, then reconnect and\n  prove success. Static artifact verification requires regular\n  `desktop-init.sh`, `desktop-common.sh`, and `desktop-gnome-specific.sh`\n  files at the Snap root, with `desktop-init.sh` executable. The helper links\n  `libGL.so.1` rather than `libOpenGL.so.0`.\n- The probe and playback helper share one sanitized loader environment:\n  ambient audit, preload, library, graphics-driver, and shell-startup overrides\n  are removed; the validated private closure wins; trusted Snap GL,\n  `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots\n  precede generic in-snap roots. The core22 base must precede GNOME so its\n  `libedit.so.2` cannot be replaced by the older copy requiring\n  `libtinfo.so.5`. The extracted-artifact verifier removes the identical\n  unsafe loader/graphics/shell set before direct helper smoke while preserving\n  feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the\n  wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches\n  probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch\n  runs the complete cached manifest/hash/helper gate before BrowserWindow\n  startup and exits with one availability JSON line. A nonzero helper exit\n  keeps top-level reason `helper-probe-failed`; `helperReason` is present only\n  for an exact protocol-v1 line carrying a fixed allowlisted reason, and its\n  optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid\n  detail suppresses both helper fields. Every probe uses an explicit 16 MiB\n  aggregate captured-output ceiling independent of tracing. With\n  `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted\n  separately as one JSON-escaped stderr line whose `stderr` field is limited\n  to 16,384 characters and whose `truncated` field is always explicit;\n  trace-write failure cannot change the capability result. Installed-Snap CI\n  enables Mesa EGL/GL diagnostics through this bounded channel. Any loader\n  failure remains a stable native-view fallback, never a flag-enabled success.\n- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop\n  Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL\n  extension loader path comes from the sandbox cache. Flatpak CI must invoke\n  the application-level `--embedded-mpv-runtime-probe`, not a direct helper\n  probe that bypasses capability detection.\n- The packaged x64 Playwright smoke runs its fixture-contract target first and\n  passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.\n  This launch-only flag does not bypass the manifest, hash, loader, or helper\n  capability gate; `--no-sandbox` remains root-only.\n- Bundled Linux releases must publish the exact source archives/git records,\n  checksums, licenses, flags, patches, build scripts, and the pinned hwdata\n  `pnp.ids` input. Each bundled package carries\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact\n  `licenses/**` files. CI may cache immutable source inputs, but regenerates\n  notices and a VCS-metadata-free\n  `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every\n  run while retaining the exact pinned six recursive libplacebo submodule\n  records. Each record is canonical `full-commit safe/path`; clone-depth\n  dependent `git describe` annotations are discarded and never form part of\n  the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. The final\n  archive's SHA-256 and repository revision are copied into every bundled x64\n  package manifest; system and marker-only packages carry no source-archive\n  binding.\n  Automated Snap Store publication is allowed only after a public `v*` GitHub\n  release contains both the Snap assets and exactly one matching source\n  archive. Before any upload, the workflow hashes and inspects that archive,\n  verifies its exact member/type set and size bounds, clean tag revision,\n  pinned sources including the six recursive submodule records and exact\n  libplacebo tree digest, legal files, and exact released tooling, then\n  performs bounded extraction and static package validation for every Snap.\n  That public-release boundary independently revalidates the exact strict\n  `meta/snap.yaml` graphics/shared-memory contract and enumerates\n  `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`.\n  Exactly one x64 Snap must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain\n  marker-only. Checkout and the artifact-transfer actions are pinned to full\n  commits; checkout does not persist credentials, and repository credentials\n  are limited to download steps. A secretless verification job copies assets\n  through no-follow descriptors, checks pre/post hashes, writes an exact\n  receipt, repeats the complete source/package verification on a root-owned\n  read-only snapshot, and transfers only that data through the pinned artifact\n  service while its receipt digest travels separately through a job output.\n  The dependent publish job runs on a bounded `ubuntu-latest` runner with no\n  checkout or release-tag code, verifies that digest plus the exact receipt,\n  asset hashes, and file-only layout, root-seals the data again, and installs\n  Snapcraft directly. Store credentials exist only in its final fixed shell\n  step, which resolves no PATH command, executes no released code, and exposes\n  the credential only to each exact\n  `/snap/bin/snapcraft upload --release=edge` process.\n  Candidate/stable promotion is manual after installed-Snap frame-copy and\n  missing-runtime fallback smoke; GitHub Actions never promotes automatically.\n  Canonical maintenance docs:\n  `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n\n## Repo Skills\n\n- `.codex/skills/iptvnator-nx-architecture/SKILL.md`\n- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`\n- `.codex/skills/iptvnator-theme-style/SKILL.md`\n- `.codex/skills/iptvnator-ui-design/SKILL.md`\n- `.codex/skills/release-cut/SKILL.md`\n- `.codex/skills/release-notes/SKILL.md`\n- `.codex/skills/stalker-portal/SKILL.md`\n- `.codex/skills/xtream-electron/SKILL.md`\n\nDescriptions and trigger conditions are canonical in each skill's frontmatter;\ndo not duplicate them here.\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n"},"files":{"CLAUDE.md":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n> The process sections below (Plan Mode, Documentation After Changes, 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.\n\n## Plan Mode\n\n- When Claude Code is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Project Overview\n\nIPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.\n\n**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.\n\n## Development Commands\n\n### Agent Bootstrap\n\n```bash\npnpm install --frozen-lockfile\npnpm nx show projects\n```\n\n- 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.\n- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`.\n- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n### Building and Serving\n\n```bash\n# Serve the Angular web app only (development mode, baseHref=\"/\")\npnpm run serve:frontend\n# or\nnx serve web\n\n# Serve with PWA configuration (optimized, baseHref=\"/\")\npnpm run serve:frontend:pwa\n# or\nnx serve web --configuration=pwa\n\n# Serve the Electron app (starts both frontend and backend)\npnpm run serve:backend\n# or\nnx serve electron-backend\n\n# Build frontend for Electron (baseHref=\"./\")\npnpm run build:frontend\n# or\nnx build web\n\n# Build frontend for PWA deployment (baseHref=\"/\")\npnpm run build:frontend:pwa\n# or\nnx build web --configuration=pwa\n\n# Build backend (Electron)\npnpm run build:backend\n# or\nnx build electron-backend\n\n# Package the app (creates distributable without installers)\npnpm run package:app\n# or\nnx run electron-backend:package\n\n# Create installers/executables\npnpm run make:app\n# or\nnx run electron-backend:make\n```\n\n### Electron CDP Debugging\n\n- Start Electron in dev mode with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- The workspace is configured to always launch Electron with: `--remote-debugging-port=9222`\n- Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222`\n- When the task is Electron automation/debugging, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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`.\n- 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.\n\nFor startup tracing or white-screen debugging:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\nUseful narrower flags:\n\n- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events\n- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections\n- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle\n- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal\n- `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\n- `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\n\nSettings, portal request/response, and trace payloads must use\n`@iptvnator/shared/logging` or the redacting portal logger before reaching\n`console.*`; never log raw credentials while debugging.\n\nIf the Nx daemon gets into a bad state before rerunning Electron:\n\n```bash\npnpm nx reset\n```\n\nUse global `agent-browser` (preferred):\n\n```bash\n# Verify CDP targets\nagent-browser --cdp 9222 tab list\n\n# Switch to the app tab and inspect interactive elements\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\n\n# Capture debug artifacts\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\nagent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip\nagent-browser --cdp 9222 wait 1500\nagent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip\n```\n\nIf `agent-browser` is not in PATH, use:\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### Testing\n\n```bash\n# Run frontend tests\npnpm run test:frontend\n# or\npnpm nx test web\n\n# Run backend tests\npnpm run test:backend\n# or\npnpm nx test electron-backend\n\n# Run targeted E2E tests (Playwright)\npnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts\npnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts\n\n# Run broad E2E suites only when the impact justifies it\npnpm nx e2e web-e2e\npnpm nx e2e electron-backend-e2e\n\n# Run tests with coverage when needed\npnpm nx test web --configuration=ci\n```\n\nBefore finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.\n\n### Linting\n\n```bash\n# Lint all projects (CI runs this on master; PRs lint affected projects)\npnpm run lint\n\n# Lint a single project\nnx lint web\nnx lint electron-backend\n```\n\nCI lints affected projects on PRs (`nx affected`) and every project on master\npushes (`.github/workflows/ci.yml`). This enforces the\nNx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint\nrule. The limits and their rationale live in one place,\n`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the\nbaseline generator import so the enforced rule and the generated list cannot\ndrift:\n\n- **Production TypeScript: hard maximum 400 lines.**\n- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under\n  `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one\n  at the production limit yields arbitrary `-2.spec.ts` files, and length there\n  signals coverage rather than the design debt the production limit catches.\n- **Blank lines and comments are not counted** (`skipBlankLines`,\n  `skipComments`), so a docblock is never the reason a file must be split.\n\nPre-existing oversized files are baselined in\n`tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with\n`node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The\ngenerator decides who belongs on the list by running ESLint's own `max-lines`\nrule, not by counting lines itself — a private reimplementation would silently\ndisagree with the rule and produce a baseline that turns CI red while looking\ncorrect. Never add new files to the baseline — the list must only shrink. A new\nfile that genuinely cannot be split (for example a function serialized into\nanother process) instead carries its own file-wide\n`/* eslint-disable max-lines -- <why> */`; the generator skips those files, so\na justified exemption never lands in the baseline. If such a directive later\nbecomes unnecessary, ESLint reports it as an unused disable directive — remove\nit rather than leaving a stale justification behind.\n\nProject `lint` targets that shell out to eslint must quote the glob, e.g.\n`eslint \"apps/<project>/**/*.ts\"`. An unquoted `**` is expanded by the POSIX\nshell on Linux and macOS (which has no `globstar`, so it matches only a\nshallow subset of files) while Windows passes the literal pattern to ESLint,\nwhich expands it recursively — the two hosts then lint different file sets.\nThe target still reports success either way, so a broken glob hides missing\ncoverage instead of failing. After changing such a target, compare the linted\nfile count against `find <project> -name '*.ts' | wc -l`.\n\n## Architecture\n\n### Monorepo Structure (Nx Workspace)\n\nThis is an Nx monorepo with the following structure:\n\n- **apps/web** - Angular application (frontend, shared by Electron and PWA)\n- **apps/electron-backend** - Electron main process\n- **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 (`network-family-autoselection.ts`) 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\n- **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend\n- **apps/web-e2e** - Playwright E2E tests against the web app\n- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app\n- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E\n- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E\n- **apps/website** - Astro + Tailwind landing page and blog\n- **libs/** - Shared libraries:\n    - **epg/data-access** - EPG services, runtime bridge, program normalization\n    - **m3u-state** - NgRx state management for M3U playlists\n    - **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs)\n    - **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes\n    - **playlist/shared/{ui,util}** - Shared playlist UI and utilities\n    - **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components\n    - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components\n    - **portal/catalog/feature** - Portal catalog UI\n    - **portal/downloads/feature** - Download manager UI\n    - **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\n    - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)\n    - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)\n    - **shared/logging** - Dependency-free structured redaction for diagnostic logs\n    - **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\n    - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)\n    - **shared/m3u-utils** - M3U playlist utilities\n    - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks\n    - **shared/testing** - Shared test helpers\n    - **ui/components** - Reusable UI components (incl. channel list)\n    - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)\n    - **ui/playback** - Player UI (video/audio players)\n    - **ui/pipes** - Angular pipes\n    - **ui/remote-control** - Remote-control UI pieces\n    - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`)\n    - **ui/styles** - Shared styles/theme\n    - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard\n\n### Frontend Architecture (Angular)\n\n**State Management**: Uses NgRx for playlist state management:\n\n- Store configuration in `apps/web/src/app/app.config.ts`\n- Playlist state, actions, effects, and reducers in `libs/m3u-state/`\n- Entity adapter pattern for managing playlists collection\n- Router store integration for route-based state\n\n**XtreamStore Architecture** (Signal Store with Feature Composition):\n\nThe Xtream Codes module uses NgRx Signal Store with a layered architecture:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        PRESENTATION LAYER                        │\n│              Components use XtreamStore (facade)                 │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                         FACADE LAYER                             │\n│                         XtreamStore                              │\n│            (Composes feature stores, unified API)                │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ withPortal · withContent · withSelection · withSearch · withEpg │\n│ withPlayer · withFavorites · withRecentItems                     │\n│ withPlaybackPositions                                           │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                    DATA SOURCE LAYER                             │\n│                   IXtreamDataSource                              │\n│         ┌───────────────────┬───────────────────┐               │\n│         ▼                   ▼                                    │\n│  ElectronDataSource    PwaDataSource                            │\n│  (DB-first + API)      (API-only)                               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nFile structure:\n\n```\nlibs/portal/xtream/\n├── data-access/src/lib/\n│   ├── stores/\n│   │   ├── features/\n│   │   │   ├── with-portal.feature.ts             # Playlist & portal status\n│   │   │   ├── with-content.feature.ts            # Categories & streams\n│   │   │   ├── with-selection.feature.ts          # UI selection & infinite-scroll window\n│   │   │   ├── with-search.feature.ts             # Search functionality\n│   │   │   ├── with-epg.feature.ts                # EPG data\n│   │   │   ├── with-player.feature.ts             # Stream URLs & player\n│   │   │   ├── with-playback-positions.feature.ts # Resume/playback positions\n│   │   │   └── index.ts\n│   │   ├── xtream.store.ts                        # Facade composing all features\n│   │   └── index.ts\n│   ├── services/\n│   │   ├── xtream-api.service.ts                  # Xtream Codes API calls\n│   │   ├── xtream-url.service.ts                  # Stream URL construction\n│   │   ├── favorites.service.ts                   # Favorites persistence\n│   │   ├── epg-queue.service.ts                   # EPG fetch queueing\n│   │   ├── xtream-xmltv-fallback.service.ts       # XMLTV fallback EPG\n│   │   └── index.ts\n│   ├── data-sources/\n│   │   ├── xtream-data-source.interface.ts        # Abstract interface + types\n│   │   ├── electron-xtream-data-source.ts         # DB-first implementation\n│   │   ├── pwa-xtream-data-source.ts              # API-only implementation\n│   │   └── index.ts                               # provideXtreamDataSource() factory\n│   ├── with-favorites.feature.ts                  # Favorites feature\n│   └── with-recent-items.ts                       # Recently viewed feature\n└── feature/src/lib/                               # Routed components\n    ├── xtream-feature.routes.ts                   # createXtreamRoutes(): /workspace/xtreams/:id tree\n    ├── live-stream-layout/, vod-details/, serial-details/, ...\n    └── global-search-results/                     # Global search (Electron-only route)\n```\n\nKey patterns:\n\n- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality\n- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility\n- **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and\n  API/in-memory implementations\n- **Factory injection**: `provideXtreamDataSource()` selects\n  `ElectronXtreamDataSource` only when\n  `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it\n  selects `PwaXtreamDataSource`\n- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging.\n  `withSelection` keeps a `visibleCount` render window over the in-memory\n  catalog plus bounded per-selection scroll snapshots for detail/tab\n  round-trips; the shared `InfiniteScrollDirective`\n  (`libs/portal/shared/ui`) measures container overflow to auto-fill tall\n  viewports (terminating on lack of container growth, not on a load count)\n  and fires `loadMore` near the bottom. The search layout routes its results\n  container through the same directive (`nearEnd*` inputs). Stalker feeds the\n  same contract from server-paged appends: portal pages accumulate into one\n  deduplicated list, `hasMoreContent` derives from accumulated length vs\n  `total_items`, a failed append keeps loaded pages and offers a tail retry,\n  and the facade maps page 0 to the skeleton and later pages to the tail\n  spinner. No paginator remains anywhere in the app\n\nXtream data strategies by runtime capability:\n\n| Capability                        | Strategy                                                 |\n| --------------------------------- | -------------------------------------------------------- |\n| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB  |\n| **Bridge unavailable**            | API-only: fetch from API and keep session data in memory |\n\n**M3U Playlist Module Architecture**:\n\nThe M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│                         VIDEO PLAYER PAGE                            │\n│        libs/playlist/m3u/feature-player/src/lib/video-player/       │\n├─────────────────────────────────────────────────────────────────────┤\n│  ┌─────────────┐  ┌───────────────────────────────────────────────┐│\n│  │   Sidebar   │  │        Video Player (ArtPlayer/Video.js)      ││\n│  │ ┌─────────┐ │  │                                               ││\n│  │ │Channel  │ │  ├───────────────────────────────────────────────┤│\n│  │ │List     │ │  │  EPG timeline ribbon (app-epg-timeline)       ││\n│  │ │Container│ │  │  horizontal, under the player                 ││\n│  │ └─────────┘ │  └───────────────────────────────────────────────┘│\n│  └─────────────┘                                                    │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nThe 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.\n\n**Radio Channel Layout** (when `channel.radio === 'true'`):\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│  ┌─────────────┐  ┌────────────────────────────────────────────────┐│\n│  │   Sidebar   │  │  Blurred backdrop (station logo)              ││\n│  │             │  │  ┌──────────┐                                 ││\n│  │             │  │  │ Artwork  │  ← cinematic hero layout        ││\n│  │             │  │  └──────────┘                                 ││\n│  │             │  │  Station Name                                 ││\n│  │             │  │  [LIVE] badge                                 ││\n│  │             │  │  ⏮  ▶/⏸  ⏭   ← transport controls          ││\n│  │             │  │  🔊 ━━━━━━━━━  ← volume slider               ││\n│  │             │  │  (no EPG panel)                               ││\n│  └─────────────┘  └────────────────────────────────────────────────┘│\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nKey radio behavior:\n\n- Detection: `channel.radio === 'true'` (string from M3U `radio` attribute)\n- The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio\n- EPG panel is conditionally hidden in the template when radio is active\n- Volume is shared with video player via `localStorage` key `'volume'`\n- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry\nrecognized as a movie FILE swaps the player + EPG area for the portals'\ntwo-state VOD detail shell fed by TMDB, watch-first (activation still plays\nimmediately; Esc reveals the Browse hero). Detection is synchronous URL-shape\nheuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`)\nor an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/`\npaths and episode-marker names (`S01E02`, \"2 серия\") fail toward the live\nlayout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB\nenrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in\nSettings → Metadata (TMDB)). Host: `m3u-vod-detail/` in\n`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`,\nparent's `embeddedPlayback()` with `isLive: false`); external MPV/VLC users\nkeep Browse. See \"Movie Recognition (VOD Detail View)\" in\n`docs/architecture/m3u-playlist-module.md`.\n\nChannel List Component Structure (parent coordinator pattern):\n\n```\nlibs/ui/components/src/lib/channel-list-container/\n├── channel-list-container.component.ts   # Parent - shared state coordinator\n├── all-channels-view/                     # Virtual scroll + debounced search\n├── groups-view/                           # Expansion panels + infinite scroll\n├── favorites-view/                        # CDK drag-drop reordering\n├── recent-view/                           # Recently viewed channels\n└── channel-list-item/                     # Individual channel display\n```\n\nKey patterns:\n\n- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance\n- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)\n- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists\n- **Infinite scroll**: IntersectionObserver in groups view loads 50 items at a time\n- **Global progress tick**: Single 30s interval instead of per-item intervals\n\nState management via NgRx (`libs/m3u-state/`):\n\n- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist\n- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive\n- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag\n- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites\n\nSee `docs/architecture/m3u-playlist-module.md` for complete documentation.\n\n**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.\n\n- Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources`\n- M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player`\n- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`\n- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`\n- Global collections: `/workspace/global-favorites`, `/workspace/global-recent`\n- Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`)\n- Downloads: `/workspace/downloads` with focused\n  `/workspace/downloads/:downloadId`; source-scoped equivalents are\n  `/workspace/xtreams/:id/downloads/:downloadId` and\n  `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide\n  the workspace context panel.\n- 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 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\n\n**Service Architecture** (Factory Pattern):\n\n- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract\n- Two environment-specific implementations:\n    - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend\n    - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version\n- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject:\n    ```typescript\n    if (window.electron) {\n        return inject(ElectronService);\n    }\n    return inject(PwaService);\n    ```\n\n**Data Storage (Environment-Specific)**:\n\n- **Electron**: SQLite database via Drizzle ORM (`better-sqlite3` driver)\n    - Location: `~/.iptvnator/databases/iptvnator.db`\n    - Full-featured relational database with foreign keys and indexes\n    - Canonical schema and connection live in `libs/shared/database`\n- **PWA (Web)**: IndexedDB via `ngx-indexed-db`\n    - Browser-based NoSQL storage\n    - Same schema structure but implemented in IndexedDB\n    - Limited by browser storage quotas\n\n**TypeScript File Size Rule**:\n\nKeep production TypeScript files under **300 lines**. Hard maximum is\n**350–400 lines**, and CI enforces the 400. Blank lines and comments do not\ncount toward it, so documenting a file never costs you headroom. Tests\n(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the\nguidance below is about production code.\n\n- When creating new files, design them to stay within this limit from the start.\n- 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.\n- 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.\n\nTypical split strategies:\n\n- Angular components: extract child components, move logic to a dedicated service or store feature\n- Signal store features: split into smaller `with*` feature functions in separate files\n- Services: split by responsibility (e.g. separate API, transformation, and state concerns)\n- Utility files: group by domain and export from a barrel `index.ts`\n\nThis rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.\n\n---\n\n**Angular Coding Standards**:\n\nThis project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:\n\n- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly menu = viewChild.required<MatMenu>('menuRef');\n    readonly items = viewChildren<ElementRef>('item');\n\n    // ❌ Incorrect - Old decorator syntax\n    @ViewChild('menuRef') menu!: MatMenu;\n    @ViewChildren('item') items!: QueryList<ElementRef>;\n    ```\n\n    **Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:\n\n    ```html\n    <!-- ✅ Correct - Unwrap the signal -->\n    <button [matMenuTriggerFor]=\"menu()\">Open Menu</button>\n\n    <!-- ❌ Incorrect - Signal not unwrapped -->\n    <button [matMenuTriggerFor]=\"menu\">Open Menu</button>\n    ```\n\n- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly title = input.required<string>();\n    readonly size = input<number>(10); // with default value\n    readonly clicked = output<string>();\n\n    // ❌ Incorrect - Old decorator syntax\n    @Input({ required: true }) title!: string;\n    @Input() size = 10;\n    @Output() clicked = new EventEmitter<string>();\n    ```\n\n- **Reactive State**: Use signal primitives for reactive state management\n\n    ```typescript\n    // ✅ Use signal(), computed(), effect(), linkedSignal()\n    readonly count = signal(0);\n    readonly doubled = computed(() => this.count() * 2);\n\n    constructor() {\n        effect(() => {\n            console.log('Count changed:', this.count());\n        });\n    }\n    ```\n\n- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)\n\n    ```typescript\n    @HostBinding('class.active') get isActive() { return this.active(); }\n    @HostListener('click') onClick() { /* ... */ }\n    ```\n\n- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`\n\n    ```typescript\n    // ✅ Correct - Modern syntax\n    @if (isLoggedIn()) {\n        <p>Welcome!</p>\n    }\n\n    @for (item of items(); track item.id) {\n        <li>{{ item.name }}</li>\n    }\n\n    // ❌ Incorrect - Old syntax\n    <p *ngIf=\"isLoggedIn\">Welcome!</p>\n    <li *ngFor=\"let item of items; trackBy: trackById\">{{ item.name }}</li>\n    ```\n\n### Backend Architecture (Electron)\n\n**Main Entry**: `apps/electron-backend/src/main.ts`\n\n- Bootstraps Electron app and initializes database\n- Registers event handlers for IPC communication\n- 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.\n\n**Database**:\n\n- **ORM**: Drizzle ORM with `better-sqlite3` (local SQLite file)\n- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)\n- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):\n    - `playlists` - Playlist metadata (M3U, Xtream, Stalker)\n    - `categories` - Content categories (live, movies, series)\n    - `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\n    - `favorites` - User favorites\n    - `recentlyViewed` - Watch history\n    - `epgChannels`, `epgPrograms` - Persisted EPG data\n    - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)\n    - `playbackPositions` - Resume positions\n    - `downloads` - Download manager state\n    - `appState` - Key-value app state (also tracks one-off data migrations)\n    - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)\n    - `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`)\n- **Connection**: `libs/shared/database/src/lib/connection.ts`\n    - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)\n    - Provides full read-write access for `electron-backend` and a read-only mode\n    - A root `drizzle.config.ts` configures Drizzle Kit tooling (points at the schema via the compat shim)\n\n**IPC Communication**:\n\n- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts`\n    - Exposes `window.electron` API via `contextBridge`\n    - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)\n    - 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.\n- **Event handlers**: `apps/electron-backend/src/app/events/`\n    - `database.events.ts` - Database CRUD operations\n    - `playlist.events.ts` - Playlist import/update\n    - `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`\n    - `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`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`\n    - `xtream.events.ts` - Xtream Codes API\n    - `stalker.events.ts` - Stalker portal API\n    - `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`) 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 <code>`, 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`\n    - `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\n    - `settings.events.ts` - App settings\n    - `electron.events.ts` - App version, etc.\n\n**Workers** (`apps/electron-backend/src/app/workers/`):\n\n- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`\n- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)\n- 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`)\n\n### Key Features\n\n**Playlist Support**:\n\n- M3U/M3U8 files (local or URL)\n- Xtream Codes API (`username`, `password`, `serverUrl`)\n- Stalker portal (`macAddress`, `url`)\n\n**Stalker playback links**: `create_link` runs only when the catalog row sets\n`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays\ndirectly. One helper decides\n(`resolveStalkerStaticPlaybackUrl` in\n`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied\nby `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by\n`StreamResolverService` for Favorites/Recently Viewed. It falls back to\n`create_link` for anything it cannot resolve alone: no row to read flags from,\na relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,\nor a loopback host; an episode (`series` set) always mints, since the parameter\nselects the episode server-side. Temporary links live ~5 s, so no resolved URL\nis persisted or replayed — favorites and recently-viewed store the `cmd`,\nplayback positions store ids, and the main-process context map stores headers\nkeyed by origin+path. Downloads are the one exception (they must retry a URL).\n`forced_storage`/`play_token` are deliberately unwired. Contract:\n`docs/architecture/stalker-portal.md` (\"Playback Link Resolution\").\n\n**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed\non the command line, opened through a file association, or delivered by macOS'\n`open-file` event is normalized to an absolute path in the main process\n(`services/playlist-open-request.ts`) and queued there. The renderer\n(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the\n`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is\nwhat makes the main process flush. `OPEN_FILE` is the only way out of the\nqueue, and a request stays there until the renderer confirms receipt via\n`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the\nlistener runs, and a reload or dead render process keeps the `WebContents`\nalive, so a successful push is not proof of delivery. Anything unacknowledged\nis replayed to the next renderer that announces itself. The renderer\nimports them on a single promise chain so a burst arrives in a deterministic\norder. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)\nfor the same reason: each action carries a different playlist, so a newer add\nmust never cancel an older one's write, EPG fetch and navigation. The import\nitself reuses the normal file path\n(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,\nplaylist-scoped EPG, and the navigation to the new playlist all behave exactly\nlike a dialog import.\n\nThe OS-level registration that makes those paths reachable is\n`fileAssociations` in `electron-builder.json` — one entry per extension, each\nwith its own `mimeType`. Electron Builder derives all three platform\nregistrations from it: macOS `CFBundleDocumentTypes` (which is what makes\n`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the\ndesktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for\ndeb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading\n`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and\nmust not be used; and it appends `%U` to `Exec`, so Linux file managers hand\nover percent-encoded `file://` URIs rather than paths —\n`createPlaylistOpenRequest` decodes them before the extension check. `%U` is\nalso the _plural_ exec code, so a multi-file selection arrives as one launch\nwith one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all\nof them and `enqueueAll` queues the batch, because stopping at the first match\nwould silently drop the rest of the selection. Adding an exec code to\n`linux.executableArgs` would suppress the `%U` but also pass that code to the\napp as a real argument, so it is not an option.\n\n**Video Players**:\n\n- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer\n- mpegts.js `1.8.1` errors from all three built-in players cross one\n  version-locked structured evidence boundary in `libs/playback/util`. It\n  retains only exact public type/detail pairs, pair-derived stage/failure,\n  terminal disposition, and a\n  validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach\n  stored or rendered diagnostics. HTTP/network failures avoid false decoder\n  recommendations, while exact format, codec, truncated-stream, and\n  MediaSource failures retain actionable recovery guidance. This diagnostic\n  layer remains separate from the shared `PlayerController` controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded\n  Shaka Player source engine inside the HTML5 and ArtPlayer components (no new\n  player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`\n  lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in\n  `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all\n  import paths). DASH channels always play inline: `isDashChannel()` bypasses\n  the external-player setting (radio precedent) and routes Video.js/MPV/VLC/\n  embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps\n  ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,\n  need the castLabs Electron fork) surface a DRM playback diagnostic instead\n  of crashing. ClearKey EME works in stock Electron. Engine:\n  `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.4`\n  diagnostic boundary lives in `libs/playback/util`; it version-locks public\n  severity/category/code evidence, ignores\n  recoverable error events, treats rejected loads as terminal lifecycle\n  outcomes, preserves exact public DASH text-parser category/code evidence with\n  unknown stage/failure, and never retains or renders raw messages or\n  `error.data`. A failed browser-support preflight stays generic-unknown but\n  carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. Details in\n  `docs/architecture/m3u-playlist-module.md` (\"DASH + ClearKey Playback\").\n- External players: MPV, VLC (via IPC to Electron backend)\n- Display sleep during playback: `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock: in\n  Electron a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`;\n  auto-cleared on renderer reload/crash), in the PWA the Screen Wake Lock\n  API. Radio's `<audio>` deliberately never blocks display sleep. Embedded\n  MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. 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=<x11-window>` 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. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.\n- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux\n  x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy\nengine` (restart required) or\n  `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV\n  experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,\n  EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the\n  preload frame pump uploads them to\n  `<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM\n  UI; native-view retains the legacy dock. On Linux, only\n  `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the\n  addon, and frame reader must not. Pristine afterPack/unpacked layouts scan\n  Electron libraries recursively; extracted Snap payloads exclude only the\n  package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.\n  Every other directory remains recursive, and Electron-library symlinks still\n  fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;\n  `afterPack` alone owns the profile-normalized unpacked native tree, and\n  package checks reject every archived `/electron-backend/native/**` entry.\n  Packaged addon, frame-reader, and helper discovery uses only package-owned\n  `app.asar.unpacked` paths; cwd/dist candidates remain development-only.\n  Official x64 packages use three separate profiles:\n  DEB/RPM/Pacman depend on system libmpv plus the helper's direct\n  EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and\n  Flatpak bundles the same closure. Flatpak is an isolated packaging pass and\n  keeps `iptvnator` as the real Electron ELF so Electron Builder's\n  `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the\n  conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed\n  Flatpak/non-Flatpak target sets fail before mutation. Exact system\n  dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n  RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n  Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;\n  Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.\n  ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the\n  fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;\n  any failure keeps the sandbox enabled, records a stable reason, and falls\n  back to native-view without crashing. Snap is `core22`/strict and uses an\n  exact private `shared-memory` plug plus the `graphics-core22` content plug at\n  a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the\n  default provider. Its only provider-data layouts bind `/usr/share/libdrm`\n  from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to\n  `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable\n  status after disconnect, then reconnects and requires success. Static\n  artifact verification requires regular `desktop-init.sh`,\n  `desktop-common.sh`, and `desktop-gnome-specific.sh` files at the Snap root,\n  with `desktop-init.sh` executable. The helper links `libGL.so.1`, and\n  probe/playback share a sanitized loader environment\n  in which ambient audit, preload, library, graphics-driver, and shell-startup\n  overrides are removed; the validated private closure plus trusted host GL,\n  graphics-content, core22 base x64, and exact GNOME-platform roots have\n  explicit precedence. The core22 base stays ahead of GNOME so the older\n  `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The\n  extracted-artifact verifier removes the identical unsafe loader/graphics/\n  shell set before direct helper smoke while preserving selectors such as\n  `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,\n  removes exported `BASH_FUNC_*` functions, and\n  launches probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only\n  `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate\n  before BrowserWindow startup and emits one availability JSON line. A nonzero\n  helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is\n  present only for an exact protocol-v1 line carrying a fixed allowlisted\n  reason, and its optional `helperDetail` must be 1–1024 printable ASCII\n  characters. Invalid detail suppresses both helper fields. Every probe uses\n  an explicit 16 MiB aggregate captured-output ceiling independent of tracing.\n  With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately\n  as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an\n  explicit `truncated` field; trace-write failure cannot change availability.\n  Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded\n  channel. The exact packaged Flatpak `/app` context reconstructs only\n  Freedesktop Platform 24.08's immutable\n  `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that\n  application-level probe instead of the helper directly. The packaged x64\n  Playwright smoke runs its fixture-contract target first and passes Chromium\n  `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass\n  the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux\n  packages carry hash-validated\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.\n  CI caches the staged runtime plus immutable source inputs, never finished\n  notices or the compliance tarball; it regenerates those notices and the\n  VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current\n  checkout while preserving the exact pinned six recursive libplacebo\n  submodule records. Each record is canonical `full-commit safe/path`;\n  clone-depth dependent `git describe` annotations are discarded and never\n  form part of the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. Every\n  bundled x64 package manifest binds the final archive's SHA-256 and repository\n  revision; system and marker-only packages do not carry that binding. Snap\n  Store\n  publication runs only from a public `v*` GitHub release that already\n  contains the Snap assets and exactly one source archive. Before any upload,\n  the workflow hashes and checks the archive's exact member/type set and size\n  bounds, verifies its clean tag revision, pinned sources including the six\n  recursive submodule records and exact libplacebo tree digest, legal payload,\n  and exact released tooling, then performs bounded extraction and static\n  validation for every Snap. That public-release boundary independently\n  revalidates the exact strict `meta/snap.yaml` graphics/shared-memory\n  contract and enumerates `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`. Exactly one x64 Snap\n  must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.\n  Checkout and artifact-transfer actions are pinned to full commits; checkout\n  does not persist credentials, and repository credentials are scoped to\n  download steps. A secretless verification job copies assets through\n  no-follow descriptors, checks them before and after inspection, writes an\n  exact receipt, fully reverifies a root-owned read-only snapshot, and\n  transfers only that data through the pinned artifact service while passing\n  the receipt digest separately through a job output. The dependent publish\n  job uses a bounded `ubuntu-latest` runner with no checkout or release-tag\n  code, verifies that digest plus the exact receipt, asset hashes, and\n  file-only layout, root-seals the data again, and installs Snapcraft directly.\n  Its final fixed shell step alone receives the Store credential, resolves no\n  PATH command, executes no released code, and exposes that credential only to\n  each exact\n  `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable\n  promotion is manual after installed-Snap frame-copy and missing-runtime\n  fallback smoke; GitHub Actions never promotes automatically. On Windows,\n  package validation requires the exact MPV DLL named by the helper's PE import\n  table beside the executable.\n  Backend adapter:\n  `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;\n  shared-controls adapter:\n  `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;\n  helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime\n  contracts: `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n- 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. 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-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `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, 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 active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. 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). `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`.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n\n**Download Manager**:\n\n- Fresh Xtream movie and series-episode downloads propagate the playlist's\n  User-Agent, Referer, and Origin, defaulting User-Agent to the same\n  provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and\n  stream probes. Retry, resume, and missing-file\n  recovery also add the fallback to legacy Xtream rows that have no stored\n  User-Agent. Because download rows survive source deletion, a headerless\n  legacy row whose playlist is already absent receives the same IPTV-player\n  fallback; a known Stalker row remains unchanged. Allowlisted connection\n  resets after bytes reach disk retain the partial and show a credential-safe\n  `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong\n  ETag or Last-Modified validator. Retry then continues with Range/If-Range;\n  without a validator it starts from byte zero and overwrites the unverified\n  partial instead of risking mixed-representation corruption.\n- The desktop-only manager shares one global download store across the global,\n  Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series\n  cards use the global Small/Medium/Large cover-grid tokens; missing completed\n  files move to Needs attention instead of remaining in Ready to watch.\n- Series details route individual and selected-season episode downloads through\n  the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode\n  pending identities synchronously, submits season candidates sequentially and\n  best-effort through the existing `DOWNLOADS_START` path, performs one final\n  authoritative refresh after added or stable duplicate submissions, and\n  reports added, skipped, and failed counts. Xtream and Stalker adapters remain\n  responsible for provider URLs, headers, and metadata; the backend still runs\n  one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole\n  start IPC. A reserved completed-missing match triggers one authoritative\n  preflight refresh before provider preparation. Download-list loads are\n  serialized as one active IPC plus one coalesced trailing refresh; a preflight\n  assigned to that trailing refresh cannot be starved by later progress\n  broadcasts. A restored Stalker file can therefore become a stable skip\n  without a portal request. The IPC's stable\n  `reason: 'already-in-progress'` and `reason: 'already-downloaded'`\n  results are counted as skipped, and no batch IPC is introduced. The latter\n  comes from an asynchronous main-process filesystem recheck before a\n  completed-missing row can be reset, so a file restored after the renderer\n  snapshot is not orphaned or downloaded again. The recheck has a one-second\n  caller deadline that starts before shared-slot acquisition; timeout or probe\n  failure leaves the row untouched and reports a failed submission so the\n  season loop can continue. Completed-file list callers use the same deadline\n  and report a timeout as missing for that snapshot. The underlying filesystem\n  operation remains coalesced and charged against the four-probe cap until it\n  settles, so later callers have independent bounded waits without duplicating\n  stalled native work. Only `ENOENT` and `ENOTDIR` prove absence; permission,\n  I/O, and other filesystem errors remain unknown and cannot clear a completed\n  row. Before a completed-missing, failed, or canceled row clears its retained\n  path, the start IPC asynchronously removes any `.part` through a separate,\n  same-path-coalesced, four-operation cap. A one-second admission deadline\n  rejects queued work before unlink starts; started work is awaited so it cannot\n  mutate after a failure response. Non-absence errors keep the row's ownership\n  intact; `ENOENT` and `ENOTDIR` safely proceed. Episode and season download\n  actions require an authoritative global list. A\n  successful snapshot remains authoritative while a later background refresh\n  is in flight; a latest refresh failure leaves\n  loading/empty-state resolution intact but disables starts until another\n  snapshot succeeds. Overlapping download-list callers join one serialized\n  trailing refresh, so responses commit in request order and frequent progress\n  events cannot perpetually postpone a waiting series action.\n- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`\n  for both providers; Stalker playback identifiers only resolve the URL. Exact\n  `(playlistId, contentType, xtreamId)` matches are authoritative, while\n  complete playlist/series/season/episode coordinates are a fail-closed legacy\n  fallback that migrates reusable rows to the canonical id. Numeric season\n  zero, including fallback key `\"0\"`, remains a valid Specials coordinate for\n  both providers. Stalker persists\n  `episode_identity_scope` separately for regular `/series`, embedded VOD\n  `series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not\n  match; a pre-scope coordinate row is ambiguous and blocked, while an exact\n  canonical legacy row remains authoritative. Renderer lookup preserves that\n  ambiguity or conflicting ownership as a distinct ineligible state, so\n  neither the episode action nor the season count treats it as a row-less\n  download. SQLite `null` and optional `undefined` coordinates both mean an\n  incomplete canonical legacy row, matching the backend resolver. Pending and\n  active rows plus completed available/unknown rows are skipped; failed,\n  canceled, completed-missing, and unambiguous row-less episodes remain\n  eligible.\n- Ready cards (movies, grouped series, and standalone episodes) open a focused\n  local detail; local file actions (Play, Show in folder, Copy URL, Remove)\n  live in the poster's overflow menu. Movies play the finalized local file;\n  series list only locally available episode rows and every episode action\n  targets its own downloaded file. Focused routes disable route search and use\n  `contextPanel: 'none'`.\n- Downloads capture a versioned metadata snapshot from the rendered Xtream or\n  Stalker movie/episode detail at start time, including already-merged TMDB\n  fields. Legacy, sparse, stale, or wrong-language snapshots are safely\n  backfilled from row/provider metadata and optional TMDB enrichment when the\n  focused detail opens.\n- `View in portal` resolves a concrete Xtream category/item route. Stalker\n  accepts a recently-viewed shape only when its raw movie/series mode matches\n  the download, and prefers an exact numeric category from the download\n  snapshot. Without that shape, only a movie carrying an exact category can\n  form a metadata-only target; unproven episode and legacy-movie handoffs stay\n  unavailable. The normal detail uses one-shot `provider-only` presentation:\n  it exposes provider content/playback it can resolve while hiding\n  Offline/local/download actions. A second, independent `View in portal`\n  bridge exists for inline collection details — see **Collection Detail\n  Portal Handoff** below; it deliberately does NOT use `provider-only`.\n- Download rows and local files survive source deletion. The global offline\n  library remains visible with no playlists; only provider handoff is disabled\n  until the source exists again.\n- If a finalized file disappears while a focused detail is open, the\n  authoritative download list refreshes and returns to the manager. A failed\n  redirect leaves an actionable missing-file state with Back and Retry.\n- Canonical contract: `docs/architecture/download-manager.md`; provider handoff:\n  `docs/architecture/portal-detail-navigation.md`.\n\n**Collection Detail Portal Handoff** (`View in portal` for inline details):\n\n- Details opened outside portal category context — `/workspace/global-favorites`,\n  `/workspace/global-recent` (which also receive the dashboard hero, Continue\n  Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent`\n  tabs — render full-width with no category sidebar. They expose a separate-row\n  hero action that jumps to the item inside its owning portal.\n- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action`\n  (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host\n  provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are\n  `XtreamCollectionDetailComponent` (through its dynamic detail injector) and\n  `StalkerCollectionDetailComponent` (component providers), which exist only in\n  collection contexts — so router-mounted category details need no opt-out. When\n  hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim\n  a phantom row in the hero action container.\n- Targets come from `getUnifiedCollectionDetailNavigation()`\n  (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike\n  `getUnifiedCollectionNavigation` it NEVER degrades to a category- or\n  section-only route: an Xtream item without a resolvable category and positive\n  item id keeps the action hidden rather than promising a jump to the title and\n  landing in a list.\n- Stalker section resolution mirrors `resolveStalkerCollectionDetailMode()`\n  (`libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`) and\n  must not be\n  simplified to `item.contentType`: `extractStalkerItemType()` reports `series`\n  for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but\n  both belong in the VOD catalog — the lazy season/episode fetch in\n  `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so\n  a `/series` route leaves the detail unable to load episodes. The virtual\n  `series` category is normalized to `vod` the same way\n  `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries\n  `stalkerReturnTo` plus\n  `stalkerReturnByHistory`, and the portal detail's back affordance\n  (`StalkerCatalogDetailComponent.onVodBack()`,\n  `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back\n  one history entry instead of calling `navigateByUrl()`. The collection's\n  active tab, scope and open inline detail live only in `window.history.state`\n  (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would\n  reopen it on the default `live` tab and leave the portal page one browser\n  Back away. The marker carries the handed-off item's identity, not a bare\n  `true`: `openStalkerItem` is consumed on arrival while the return keys stay\n  on the entry, and a Stalker detail opens in place without pushing one — so\n  after Back + browser Forward the same entry can host a different title, whose\n  back affordance must just close it. A stale marker suppresses the whole\n  return contract, and honouring it retires both keys from the entry so a\n  browser Forward cannot replay them for a reopened title. Leaving with the\n  browser's own Back runs no affordance, so `CategoryContentViewComponent`\n  also retires the contract whenever it lands on the entry with no handoff\n  item and no open detail. That retirement is gated on the marker, so a plain\n  `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is\n  restricted to what `buildStalkerSelectedVodItem()` preserves (`id ??\nstream_id`); it drops `series_id`/`movie_id`, so the builder pins the\n  resolved id onto the handoff state item when the raw row carries neither —\n  those rows then get the same history return instead of degrading to a\n  re-navigation that resets the collection's tab.\n  Only this builder sets the marker, so the\n  dashboard handoff and any other `stalkerReturnTo` caller keeps\n  re-navigating.\n- Unlike the download handoff this bridge does NOT pass\n  `detailPresentation: 'provider-only'` — the item exists in the provider\n  catalog, so the full normal detail (downloads included) is wanted.\n- Contract: `docs/architecture/portal-detail-navigation.md`.\n\n**VOD/Series Detail Pages (two-state layout)**:\n\n- 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)\n- 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)\n- 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\n- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and \"Close player\" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)\n- 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.\n- 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.\n- 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. Lazy VOD episode tracking IDs scope the parent series, provider episode, 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 any stored season/episode coordinates. 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.\n- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block\n- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) 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)\n- Dashboard hero/Continue Watching clicks for an Xtream 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. Ordinary global-recent grid clicks remain detail-only.\n- See `docs/architecture/embedded-inline-playback.md` (\"Two-State Detail Layout\")\n\n**VOD Multi-Source** (alternative sources for a movie):\n\n- 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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.\n- 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`.\n- 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).\n- 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.\n- 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.\n- See `docs/architecture/vod-multi-source.md`\n\n**Radio Player**:\n\n- Dedicated audio player for channels with `radio=\"true\"` M3U attribute\n- Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls\n- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio\n- EPG panel is hidden for radio channels (radio streams have no EPG data)\n- Volume synced with video player via shared `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**EPG (Electronic Program Guide)**:\n\n- XMLTV format support\n- Background parsing in worker thread\n- Stored in database for quick lookup\n- 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/`\n\n**TMDB Metadata Enrichment** (opt-in):\n\n- 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\n- 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\n- \"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\n- 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`)\n- 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\n- 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`\n- 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`)\n- 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`\n- 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\n- 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); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)\n- 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:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment\n- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)\n- 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, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.\n- 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`)\n- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About\n- See `docs/architecture/tmdb-metadata-enrichment.md`\n\n**Portal Account Info**:\n\n- 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.\n- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.\n- 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\").\n- 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.\n\n**Stalker Portal Mode and Endpoint Discovery**:\n\n- 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.\n- 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.\n- 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 `<base>/portal.php` → `<base>/server/load.php` → `<base>/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 `<base>/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.\n- 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.\n- `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.\n- 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.\n- 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.\n- 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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Portal Mode and Endpoint Discovery\", \"Request Transport and `cmd` Encoding\").\n\n**Stalker Session Authentication**:\n\n- 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`.\n- `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.\n- 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.\n- 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.\n- 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.\n- 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.\n- Full contract: `docs/architecture/stalker-portal.md` (\"Session Authentication Lifecycle\").\n\n**Stalker Identity Hardening**:\n\n- 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.\n- 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.\n- `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.\n- `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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Stalker Identity Policy\").\n\n**Favorites and Recently Viewed**:\n\n- Per-playlist favorites and global favorites\n- Recently viewed tracks watch history\n\n**Internationalization**:\n\n- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/`\n\n## Development Notes\n\n### Environment Detection and Dual-Mode Architecture\n\nThe app determines whether it's running in Electron or as a PWA by checking:\n\n```typescript\nwindow.electron; // truthy in Electron, undefined in browser\n```\n\n**Why Dual Mode?**\nIPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:\n\n- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access\n- **PWA**: Lightweight web version that runs in any browser without installation\n\n**Environment-Specific Behavior**:\n\n- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment\n- `app.routes.ts` - Same `/workspace/...` route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA\n- Storage layer switches automatically:\n    - Electron → SQLite/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db`\n    - PWA → IndexedDB → Browser storage\n- External player support (MPV/VLC) only available in Electron\n- File system operations only available in Electron (uploading playlists from disk)\n\n**Base Href Configuration**:\nThe app uses different base href values depending on the build target:\n\n- **Development & PWA**: `baseHref=\"/\"` (from `index.html`)\n    - Used by: `pnpm run serve:frontend`, `pnpm run build:frontend:pwa`\n    - For web servers with proper routing\n- **Electron Production**: `baseHref=\"./\"` (overridden in build config)\n    - Used by: `pnpm run build:backend`, `pnpm run make:app`\n    - Required for `file://` protocol in Electron\n\nBuild configurations in `apps/web/project.json`:\n\n- `production`: Electron build with `baseHref=\"./\"`\n- `pwa`: Web deployment with `baseHref=\"/\"`\n- `development`: Dev mode with `baseHref=\"/\"` from index.html\n\n**Factory Pattern Implementation**:\nThe 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.\n\n**Build Commit In About**:\nCI 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 `\"<version> (<short-sha>)\"`. The semver version itself deliberately stays untouched — 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.\n\n### Testing Strategy\n\n- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`\n- **E2E tests**: Playwright testing the web app and Electron app\n- Backend tests use standard Jest\n- Bug fixes should add focused regression coverage unless there is a documented reason not to.\n- 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.\n\n### Nx Commands\n\nUse `nx` CLI for better performance:\n\n```bash\npnpm nx run <project>:<target>\n# Example: pnpm nx run web:build\n# Example: pnpm nx run electron-backend:serve\n```\n\nTo run multiple projects:\n\n```bash\npnpm nx run-many --target=test --all\n```\n\n### Electron Build Process\n\nThe Electron backend depends on the web app being built first:\n\n- `electron-backend:build` depends on `web:build`\n- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend)\n- Packaging combines both into distributable\n\n### Database Migrations\n\nNo formal migration system yet. Schema changes are applied via raw SQL in the `createTables()` function in `libs/shared/database/src/lib/connection.ts` using `CREATE TABLE IF NOT EXISTS`. One-off data migrations run guarded by keys stored in the `appState` table.\n\n### Common Patterns\n\n**IPC Communication**:\n\n1. Define handler in appropriate events file (e.g., `database.events.ts`)\n2. Register with `ipcMain.handle()` in the event bootstrap function\n3. Expose in preload script via `contextBridge.exposeInMainWorld()`\n4. Call from Angular via `window.electron.<methodName>()`\n\n**Adding New Playlist Source**:\n\n1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts`\n2. Create event handler in `apps/electron-backend/src/app/events/`\n3. 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\n4. Update database schema if needed\n\n**State Management**:\n\n- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)\n- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)\n- Use NgRx signals for reactive data streams\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n","AGENTS.md":"# AGENTS.md\n\nThis file provides guidance to coding agents working in this repository.\n\n## Plan Mode\n\n- When an agent is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Agent Bootstrap\n\n- 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.\n- After dependencies are installed, verify workspace discovery with `pnpm nx show projects`.\n- 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`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.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 -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused.\n- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint \"apps/<project>/**/*.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 <project> -name '*.ts' | wc -l`.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Electron Debugging (CDP)\n\n- Start the Electron development app with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- Electron is configured to start with: `--remote-debugging-port=9222`\n- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`\n- For Electron automation/debugging tasks, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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.\n- 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.\n- 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.\n\n### Trace / Debug Startup\n\n- Full startup tracing:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\n- Narrower trace flags:\n    - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n    - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events\n    - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker\n    - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events\n    - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n    - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal\n    - `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\n    - `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\n\n- Settings, portal request/response, and trace payloads must use\n  `@iptvnator/shared/logging` or the redacting portal logger before reaching\n  `console.*`; never log raw credentials while debugging.\n\n- If local Nx state gets weird before a rerun:\n\n```bash\npnpm nx reset\n```\n\n### agent-browser (global install)\n\n```bash\nagent-browser --cdp 9222 tab list\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\n```\n\n### Fallback\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### DevTools Workaround\n\n```bash\nELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend\ncurl http://127.0.0.1:9222/json/list\nagent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>\nagent-browser screenshot /tmp/iptvnator-cdp.png\n```\n\n## Radio / Audio Player\n\nM3U playlists can contain radio channels identified by the `radio=\"true\"` attribute on `#EXTINF` lines. When a radio channel is selected:\n\n- The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player\n- The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored\n- The EPG panel is hidden (radio streams have no EPG data)\n- 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\n- Volume is shared with the video player via `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle)\n- Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean\n\nKey files:\n\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling\n- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video\n- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface\n\n## Shared Player Controls\n\n- `libs/ui/playback/src/lib/player-controls/` contains the additive,\n  engine-neutral `PlayerController` contract, standalone\n  `app-player-controls`, generic web-video adapter/helper, and component-scoped\n  `WEB_PLAYER_SHARED_CONTROLS` rollout token.\n- In fullscreen, `app-player-controls` shows a pointer-transparent media-title\n  overlay at the top while controls are revealed (`mediaTitle` input:\n  movie/channel/series name, plus an `S01E03` second line for episodes). Series\n  names flow from the Xtream/Stalker detail views through\n  `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;\n  movie and live hosts fall back to `playback.title`, skipping raw stream-URL\n  fallbacks. Outside fullscreen the overlay stays hidden.\n- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox\n  appears only when HTML5, Video.js, or ArtPlayer is selected.\n  `WebPlayerViewComponent` snapshots the preference into\n  `WEB_PLAYER_SHARED_CONTROLS` for each new player host. The parent `/workspace`\n  route awaits the initial `SettingsStore` load, including cold-start direct\n  links, before this snapshot can occur. Saving applies to the next host without\n  an application restart; an existing session never changes controls mode in\n  place.\n- `Settings.showCaptions` is deliberately outside this rollout gate: it is\n  engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both\n  modes — shared controls through their controls bridge, the preference-off\n  paths through the same helpers without an adapter (`WebVideoSourceTracks` for\n  HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference\n  as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it\n  from `SettingsStore` rather than a host input, so the M3U player, the\n  Xtream/Stalker live layouts, and the portal detail inline player all inherit\n  it (#1155).\n- The modes differ in how long the preference is enforced. Shared controls are\n  authoritative for the session; user intent arrives through `setSubtitleTrack`\n  and wins until the source changes. Vendor chrome is source-default: the\n  preference seeds each new source and is released once the media element\n  reports `playing`, so the engine's own caption menu keeps working. The mode is\n  selected by the optional `playbackStarted` probe the legacy owners pass to all\n  three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper\n  deselects the track (`subtitleTrack = -1`) instead of hiding it, because\n  `subtitleDisplay` would silently override whatever the vendor menu picks. For\n  DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,\n  so the helper only stops re-suppressing afterwards.\n- Embedded MPV ignores the web-player preference. Frame-copy always uses shared\n  DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while\n  native-view retains the legacy compositor-safe dock and external MPV/VLC\n  retain their own UI. The host must render exactly one controls system for the\n  reported Embedded MPV engine.\n- Frame-copy shared controls own DOM surface interactions, shortcuts,\n  fullscreen, and recording feedback. `showControls=false` detaches the shared\n  surface, modal overlays gate playback shortcuts, fullscreen still triggers\n  bounds sync, and a playback/session transition key prevents engine or session\n  handoff from presenting stale recording feedback while timers and pending\n  commands are cancelled. Same-session IPC replies also yield to a broadcast\n  snapshot received while the command was pending, preventing a successful\n  recording acknowledgement from being rolled back by a stale reply.\n- DASH (`.mpd`) sources play through a lazily imported Shaka Player source\n  engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and\n  ArtPlayer components; ClearKey keys come from KODIPROP-derived\n  `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via\n  source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in\n  `libs/playback/util`; it version-locks public severity/category/code evidence,\n  ignores recoverable error events,\n  treats rejected loads as terminal lifecycle outcomes, preserves exact public\n  DASH text-parser category/code evidence with unknown stage/failure, and never\n  retains or renders raw messages or `error.data`. A failed browser-support\n  preflight stays generic-unknown but carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. See the CLAUDE.md \"Video Players\" feature\n  entry and the \"DASH + ClearKey Playback\" section of\n  `docs/architecture/m3u-playlist-module.md`.\n- mpegts.js `1.8.1` errors from HTML5, Video.js, and ArtPlayer cross one\n  version-locked structured evidence boundary in `libs/playback/util`. Only\n  exact public type/detail pairs, pair-derived stage/failure, terminal\n  disposition, and the validated HTTP 4xx/5xx status slot are retained; raw\n  messages and arbitrary `info`\n  never reach diagnostics. This is a sibling of `PlayerController`, not part\n  of the controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- The built-in HTML5/hls.js player is the second guarded consumer.\n  `HtmlVideoPlayerComponent` provides a component-scoped\n  `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared\n  with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,\n  caption preference, and source cleanup.\n  `HtmlVideoElementSession` owns native video-event lifecycle, persisted\n  volume, start-time/time/ended propagation, and legacy post-play caption\n  suppression.\n  `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD\n  metadata, while a visible playback diagnostic disables both shared surface\n  interaction and shortcuts and exits the HTML5 shell's own fullscreen so the\n  diagnostic actions remain visible. The preference-off path keeps native\n  controls and legacy series navigation unchanged, while the playback keyboard\n  shortcuts (Space/K, F, arrow seek/volume, M) attach through\n  `LegacyPlayerShortcuts` with commands acting on the native video element\n  (`html-video-legacy-shortcuts.ts`); seek requires authoritative VOD metadata\n  plus a finite positive duration, and a visible diagnostic disables the keys.\n- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech\n  video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,\n  preserves caption preference and explicit subtitle-off state, and reads\n  duration from Video.js. Reset-driven raw MPEG-TS changes pause first,\n  coalesce to the latest desired source, preserve actual volume across\n  Video.js's reset, and restart when authoritative live/VOD metadata changes.\n  The shared-controls path disables native controls, Video.js\n  click/double-click/hotkey actions, and spatial navigation;\n  diagnostic gating and owned-fullscreen exit match HTML5. The preference-off\n  path keeps the existing Video.js skin and legacy series navigation unchanged\n  (still without `userActions.hotkeys`), while the playback keyboard shortcuts\n  attach through `LegacyPlayerShortcuts` and drive the player API so the\n  vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).\n- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns\n  HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and\n  a destroyed-session guard for delayed `customType` callbacks, while\n  `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses\n  authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,\n  MPEG-TS VOD duration correction, and reapplies app volume directly after\n  ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,\n  and a transparent capture layer gives shared controls exclusive click and\n  double-click ownership. Diagnostic interaction gating and owned-fullscreen\n  exit match the other web players. The preference-off path keeps the legacy\n  ArtPlayer skin, source behavior, and series navigation unchanged, while the\n  playback keyboard shortcuts attach through `LegacyPlayerShortcuts` using the\n  vendor setters ArtPlayer's own hotkeys used\n  (`art-player-legacy-shortcuts.ts`); the legacy chrome passes `hotkey: false`\n  because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would\n  double-handle every key, and the wiring restores its Escape-exits-web-\n  fullscreen behavior.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n- Canonical docs: `docs/architecture/player-controls-contract.md` and\n  `docs/architecture/embedded-mpv-native.md`\n\n## Display Sleep During Playback\n\n- `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock.\n- Electron: a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`);\n  the renderer's vote is auto-cleared on renderer reload, crash\n  (`render-process-gone`), or destruction. PWA: the Screen Wake Lock API,\n  re-requested after browser auto-release; state changes masked by an\n  in-flight `request()` queue one re-evaluation on rejection.\n- Radio's `<audio>` deliberately never blocks display sleep. Embedded MPV\n  holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n\n## Linux Embedded MPV Packaging\n\n- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,\n  Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain\n  marker-only and must never inherit x64 native artifacts from environment\n  overrides.\n- Packaging runs three isolated profiles:\n    - `system`: DEB/RPM/Pacman, no private `native/lib`, with package\n      dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n      RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n      Pacman=`mpv,libglvnd,mesa`\n    - `portable`: AppImage/Snap with the pinned LGPL-compatible closure\n    - `flatpak`: Flatpak with the same pinned closure\n- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real\n  Electron ELF so Electron Builder's `electron-wrapper` passes it directly to\n  Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and\n  `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.\n- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04\n  provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the\n  package dependency or advertising frame-copy without a compatible runtime.\n- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,\n  Electron libraries, `embedded_mpv.node`, and\n  `embedded_mpv_frame_reader.node` must not load or link it. Preserve this\n  process-isolation contract in build, package, and smoke checks.\n- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`\n  exclusively writes the profile-normalized unpacked native tree. Layout and\n  final-artifact checks must reject every archived\n  `/electron-backend/native/**` entry so system and marker-only packages cannot\n  hide stale x64 artifacts.\n- Packaged addon, frame-reader, and helper discovery is package-owned\n  `app.asar.unpacked` only. Writable cwd/dist candidates are development-only\n  and must never satisfy packaged native-view support or the frame-copy gate.\n- Pristine afterPack/unpacked layouts scan Electron libraries recursively.\n  Extracted Snap payloads exclude only the package-manager `lib/**` and\n  `usr/lib/**` trees that Snap overlays into the same root; every other\n  directory remains recursive, and Electron-library symlinks still fail\n  closed.\n- Linux frame-copy availability is fail-closed. The packaged manifest,\n  artifact modes, declared bundled hashes/closure, and bounded\n  `--runtime-probe` must all succeed before frame-copy can relax the renderer\n  sandbox. Any failure reports a stable reason and falls back to native-view\n  without crashing; an environment flag never bypasses this gate.\n- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus\n  the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,\n  with `mesa-core22` as default provider. It declares only the canonical\n  provider layouts: `/usr/share/libdrm` binds from\n  `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to\n  `$SNAP/graphics/drirc.d`. The provider is external shared content, not part\n  of IPTVnator's package size, source archive, or notices. Installed-Snap CI\n  must prove controlled unavailable exit after disconnect, then reconnect and\n  prove success. Static artifact verification requires regular\n  `desktop-init.sh`, `desktop-common.sh`, and `desktop-gnome-specific.sh`\n  files at the Snap root, with `desktop-init.sh` executable. The helper links\n  `libGL.so.1` rather than `libOpenGL.so.0`.\n- The probe and playback helper share one sanitized loader environment:\n  ambient audit, preload, library, graphics-driver, and shell-startup overrides\n  are removed; the validated private closure wins; trusted Snap GL,\n  `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots\n  precede generic in-snap roots. The core22 base must precede GNOME so its\n  `libedit.so.2` cannot be replaced by the older copy requiring\n  `libtinfo.so.5`. The extracted-artifact verifier removes the identical\n  unsafe loader/graphics/shell set before direct helper smoke while preserving\n  feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the\n  wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches\n  probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch\n  runs the complete cached manifest/hash/helper gate before BrowserWindow\n  startup and exits with one availability JSON line. A nonzero helper exit\n  keeps top-level reason `helper-probe-failed`; `helperReason` is present only\n  for an exact protocol-v1 line carrying a fixed allowlisted reason, and its\n  optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid\n  detail suppresses both helper fields. Every probe uses an explicit 16 MiB\n  aggregate captured-output ceiling independent of tracing. With\n  `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted\n  separately as one JSON-escaped stderr line whose `stderr` field is limited\n  to 16,384 characters and whose `truncated` field is always explicit;\n  trace-write failure cannot change the capability result. Installed-Snap CI\n  enables Mesa EGL/GL diagnostics through this bounded channel. Any loader\n  failure remains a stable native-view fallback, never a flag-enabled success.\n- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop\n  Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL\n  extension loader path comes from the sandbox cache. Flatpak CI must invoke\n  the application-level `--embedded-mpv-runtime-probe`, not a direct helper\n  probe that bypasses capability detection.\n- The packaged x64 Playwright smoke runs its fixture-contract target first and\n  passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.\n  This launch-only flag does not bypass the manifest, hash, loader, or helper\n  capability gate; `--no-sandbox` remains root-only.\n- Bundled Linux releases must publish the exact source archives/git records,\n  checksums, licenses, flags, patches, build scripts, and the pinned hwdata\n  `pnp.ids` input. Each bundled package carries\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact\n  `licenses/**` files. CI may cache immutable source inputs, but regenerates\n  notices and a VCS-metadata-free\n  `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every\n  run while retaining the exact pinned six recursive libplacebo submodule\n  records. Each record is canonical `full-commit safe/path`; clone-depth\n  dependent `git describe` annotations are discarded and never form part of\n  the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. The final\n  archive's SHA-256 and repository revision are copied into every bundled x64\n  package manifest; system and marker-only packages carry no source-archive\n  binding.\n  Automated Snap Store publication is allowed only after a public `v*` GitHub\n  release contains both the Snap assets and exactly one matching source\n  archive. Before any upload, the workflow hashes and inspects that archive,\n  verifies its exact member/type set and size bounds, clean tag revision,\n  pinned sources including the six recursive submodule records and exact\n  libplacebo tree digest, legal files, and exact released tooling, then\n  performs bounded extraction and static package validation for every Snap.\n  That public-release boundary independently revalidates the exact strict\n  `meta/snap.yaml` graphics/shared-memory contract and enumerates\n  `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`.\n  Exactly one x64 Snap must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain\n  marker-only. Checkout and the artifact-transfer actions are pinned to full\n  commits; checkout does not persist credentials, and repository credentials\n  are limited to download steps. A secretless verification job copies assets\n  through no-follow descriptors, checks pre/post hashes, writes an exact\n  receipt, repeats the complete source/package verification on a root-owned\n  read-only snapshot, and transfers only that data through the pinned artifact\n  service while its receipt digest travels separately through a job output.\n  The dependent publish job runs on a bounded `ubuntu-latest` runner with no\n  checkout or release-tag code, verifies that digest plus the exact receipt,\n  asset hashes, and file-only layout, root-seals the data again, and installs\n  Snapcraft directly. Store credentials exist only in its final fixed shell\n  step, which resolves no PATH command, executes no released code, and exposes\n  the credential only to each exact\n  `/snap/bin/snapcraft upload --release=edge` process.\n  Candidate/stable promotion is manual after installed-Snap frame-copy and\n  missing-runtime fallback smoke; GitHub Actions never promotes automatically.\n  Canonical maintenance docs:\n  `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n\n## Repo Skills\n\n- `.codex/skills/iptvnator-nx-architecture/SKILL.md`\n- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`\n- `.codex/skills/iptvnator-theme-style/SKILL.md`\n- `.codex/skills/iptvnator-ui-design/SKILL.md`\n- `.codex/skills/release-cut/SKILL.md`\n- `.codex/skills/release-notes/SKILL.md`\n- `.codex/skills/stalker-portal/SKILL.md`\n- `.codex/skills/xtream-electron/SKILL.md`\n\nDescriptions and trigger conditions are canonical in each skill's frontmatter;\ndo not duplicate them here.\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n"},"items":[{"name":"CLAUDE.md","path":"CLAUDE.md","title":"CLAUDE.md","content":"# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n> The process sections below (Plan Mode, Documentation After Changes, 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.\n\n## Plan Mode\n\n- When Claude Code is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Project Overview\n\nIPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.\n\n**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.\n\n## Development Commands\n\n### Agent Bootstrap\n\n```bash\npnpm install --frozen-lockfile\npnpm nx show projects\n```\n\n- 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.\n- Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`.\n- Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n### Building and Serving\n\n```bash\n# Serve the Angular web app only (development mode, baseHref=\"/\")\npnpm run serve:frontend\n# or\nnx serve web\n\n# Serve with PWA configuration (optimized, baseHref=\"/\")\npnpm run serve:frontend:pwa\n# or\nnx serve web --configuration=pwa\n\n# Serve the Electron app (starts both frontend and backend)\npnpm run serve:backend\n# or\nnx serve electron-backend\n\n# Build frontend for Electron (baseHref=\"./\")\npnpm run build:frontend\n# or\nnx build web\n\n# Build frontend for PWA deployment (baseHref=\"/\")\npnpm run build:frontend:pwa\n# or\nnx build web --configuration=pwa\n\n# Build backend (Electron)\npnpm run build:backend\n# or\nnx build electron-backend\n\n# Package the app (creates distributable without installers)\npnpm run package:app\n# or\nnx run electron-backend:package\n\n# Create installers/executables\npnpm run make:app\n# or\nnx run electron-backend:make\n```\n\n### Electron CDP Debugging\n\n- Start Electron in dev mode with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- The workspace is configured to always launch Electron with: `--remote-debugging-port=9222`\n- Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222`\n- When the task is Electron automation/debugging, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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`.\n- 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.\n\nFor startup tracing or white-screen debugging:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\nUseful narrower flags:\n\n- `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n- `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events\n- `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections\n- `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle\n- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n- `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal\n- `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\n- `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\n\nSettings, portal request/response, and trace payloads must use\n`@iptvnator/shared/logging` or the redacting portal logger before reaching\n`console.*`; never log raw credentials while debugging.\n\nIf the Nx daemon gets into a bad state before rerunning Electron:\n\n```bash\npnpm nx reset\n```\n\nUse global `agent-browser` (preferred):\n\n```bash\n# Verify CDP targets\nagent-browser --cdp 9222 tab list\n\n# Switch to the app tab and inspect interactive elements\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\n\n# Capture debug artifacts\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\nagent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip\nagent-browser --cdp 9222 wait 1500\nagent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip\n```\n\nIf `agent-browser` is not in PATH, use:\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### Testing\n\n```bash\n# Run frontend tests\npnpm run test:frontend\n# or\npnpm nx test web\n\n# Run backend tests\npnpm run test:backend\n# or\npnpm nx test electron-backend\n\n# Run targeted E2E tests (Playwright)\npnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts\npnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts\n\n# Run broad E2E suites only when the impact justifies it\npnpm nx e2e web-e2e\npnpm nx e2e electron-backend-e2e\n\n# Run tests with coverage when needed\npnpm nx test web --configuration=ci\n```\n\nBefore finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary.\n\n### Linting\n\n```bash\n# Lint all projects (CI runs this on master; PRs lint affected projects)\npnpm run lint\n\n# Lint a single project\nnx lint web\nnx lint electron-backend\n```\n\nCI lints affected projects on PRs (`nx affected`) and every project on master\npushes (`.github/workflows/ci.yml`). This enforces the\nNx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint\nrule. The limits and their rationale live in one place,\n`tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the\nbaseline generator import so the enforced rule and the generated list cannot\ndrift:\n\n- **Production TypeScript: hard maximum 400 lines.**\n- **Tests: 1200.** `**/*.spec.ts`, `**/*.e2e.ts` and everything under\n  `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one\n  at the production limit yields arbitrary `-2.spec.ts` files, and length there\n  signals coverage rather than the design debt the production limit catches.\n- **Blank lines and comments are not counted** (`skipBlankLines`,\n  `skipComments`), so a docblock is never the reason a file must be split.\n\nPre-existing oversized files are baselined in\n`tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with\n`node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The\ngenerator decides who belongs on the list by running ESLint's own `max-lines`\nrule, not by counting lines itself — a private reimplementation would silently\ndisagree with the rule and produce a baseline that turns CI red while looking\ncorrect. Never add new files to the baseline — the list must only shrink. A new\nfile that genuinely cannot be split (for example a function serialized into\nanother process) instead carries its own file-wide\n`/* eslint-disable max-lines -- <why> */`; the generator skips those files, so\na justified exemption never lands in the baseline. If such a directive later\nbecomes unnecessary, ESLint reports it as an unused disable directive — remove\nit rather than leaving a stale justification behind.\n\nProject `lint` targets that shell out to eslint must quote the glob, e.g.\n`eslint \"apps/<project>/**/*.ts\"`. An unquoted `**` is expanded by the POSIX\nshell on Linux and macOS (which has no `globstar`, so it matches only a\nshallow subset of files) while Windows passes the literal pattern to ESLint,\nwhich expands it recursively — the two hosts then lint different file sets.\nThe target still reports success either way, so a broken glob hides missing\ncoverage instead of failing. After changing such a target, compare the linted\nfile count against `find <project> -name '*.ts' | wc -l`.\n\n## Architecture\n\n### Monorepo Structure (Nx Workspace)\n\nThis is an Nx monorepo with the following structure:\n\n- **apps/web** - Angular application (frontend, shared by Electron and PWA)\n- **apps/electron-backend** - Electron main process\n- **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 (`network-family-autoselection.ts`) 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\n- **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend\n- **apps/web-e2e** - Playwright E2E tests against the web app\n- **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app\n- **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E\n- **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E\n- **apps/website** - Astro + Tailwind landing page and blog\n- **libs/** - Shared libraries:\n    - **epg/data-access** - EPG services, runtime bridge, program normalization\n    - **m3u-state** - NgRx state management for M3U playlists\n    - **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs)\n    - **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes\n    - **playlist/shared/{ui,util}** - Shared playlist UI and utilities\n    - **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components\n    - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components\n    - **portal/catalog/feature** - Portal catalog UI\n    - **portal/downloads/feature** - Download manager UI\n    - **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\n    - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`)\n    - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`)\n    - **shared/logging** - Dependency-free structured redaction for diagnostic logs\n    - **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\n    - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend)\n    - **shared/m3u-utils** - M3U playlist utilities\n    - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata shared by the Xtream and Stalker marketing mocks\n    - **shared/testing** - Shared test helpers\n    - **ui/components** - Reusable UI components (incl. channel list)\n    - **ui/epg** - EPG UI (timeline ribbon, multi-EPG, progress panel, program dialogs)\n    - **ui/playback** - Player UI (video/audio players)\n    - **ui/pipes** - Angular pipes\n    - **ui/remote-control** - Remote-control UI pieces\n    - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`)\n    - **ui/styles** - Shared styles/theme\n    - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard\n\n### Frontend Architecture (Angular)\n\n**State Management**: Uses NgRx for playlist state management:\n\n- Store configuration in `apps/web/src/app/app.config.ts`\n- Playlist state, actions, effects, and reducers in `libs/m3u-state/`\n- Entity adapter pattern for managing playlists collection\n- Router store integration for route-based state\n\n**XtreamStore Architecture** (Signal Store with Feature Composition):\n\nThe Xtream Codes module uses NgRx Signal Store with a layered architecture:\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        PRESENTATION LAYER                        │\n│              Components use XtreamStore (facade)                 │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                         FACADE LAYER                             │\n│                         XtreamStore                              │\n│            (Composes feature stores, unified API)                │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│ withPortal · withContent · withSelection · withSearch · withEpg │\n│ withPlayer · withFavorites · withRecentItems                     │\n│ withPlaybackPositions                                           │\n└─────────────────────────────────────────────────────────────────┘\n                                  │\n                                  ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                    DATA SOURCE LAYER                             │\n│                   IXtreamDataSource                              │\n│         ┌───────────────────┬───────────────────┐               │\n│         ▼                   ▼                                    │\n│  ElectronDataSource    PwaDataSource                            │\n│  (DB-first + API)      (API-only)                               │\n└─────────────────────────────────────────────────────────────────┘\n```\n\nFile structure:\n\n```\nlibs/portal/xtream/\n├── data-access/src/lib/\n│   ├── stores/\n│   │   ├── features/\n│   │   │   ├── with-portal.feature.ts             # Playlist & portal status\n│   │   │   ├── with-content.feature.ts            # Categories & streams\n│   │   │   ├── with-selection.feature.ts          # UI selection & infinite-scroll window\n│   │   │   ├── with-search.feature.ts             # Search functionality\n│   │   │   ├── with-epg.feature.ts                # EPG data\n│   │   │   ├── with-player.feature.ts             # Stream URLs & player\n│   │   │   ├── with-playback-positions.feature.ts # Resume/playback positions\n│   │   │   └── index.ts\n│   │   ├── xtream.store.ts                        # Facade composing all features\n│   │   └── index.ts\n│   ├── services/\n│   │   ├── xtream-api.service.ts                  # Xtream Codes API calls\n│   │   ├── xtream-url.service.ts                  # Stream URL construction\n│   │   ├── favorites.service.ts                   # Favorites persistence\n│   │   ├── epg-queue.service.ts                   # EPG fetch queueing\n│   │   ├── xtream-xmltv-fallback.service.ts       # XMLTV fallback EPG\n│   │   └── index.ts\n│   ├── data-sources/\n│   │   ├── xtream-data-source.interface.ts        # Abstract interface + types\n│   │   ├── electron-xtream-data-source.ts         # DB-first implementation\n│   │   ├── pwa-xtream-data-source.ts              # API-only implementation\n│   │   └── index.ts                               # provideXtreamDataSource() factory\n│   ├── with-favorites.feature.ts                  # Favorites feature\n│   └── with-recent-items.ts                       # Recently viewed feature\n└── feature/src/lib/                               # Routed components\n    ├── xtream-feature.routes.ts                   # createXtreamRoutes(): /workspace/xtreams/:id tree\n    ├── live-stream-layout/, vod-details/, serial-details/, ...\n    └── global-search-results/                     # Global search (Electron-only route)\n```\n\nKey patterns:\n\n- **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality\n- **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility\n- **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and\n  API/in-memory implementations\n- **Factory injection**: `provideXtreamDataSource()` selects\n  `ElectronXtreamDataSource` only when\n  `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it\n  selects `PwaXtreamDataSource`\n- **Catalog lazy loading**: catalog grids scroll infinitely instead of paging.\n  `withSelection` keeps a `visibleCount` render window over the in-memory\n  catalog plus bounded per-selection scroll snapshots for detail/tab\n  round-trips; the shared `InfiniteScrollDirective`\n  (`libs/portal/shared/ui`) measures container overflow to auto-fill tall\n  viewports (terminating on lack of container growth, not on a load count)\n  and fires `loadMore` near the bottom. The search layout routes its results\n  container through the same directive (`nearEnd*` inputs). Stalker feeds the\n  same contract from server-paged appends: portal pages accumulate into one\n  deduplicated list, `hasMoreContent` derives from accumulated length vs\n  `total_items`, a failed append keeps loaded pages and offers a tail retry,\n  and the facade maps page 0 to the skeleton and later pages to the tail\n  spinner. No paginator remains anywhere in the app\n\nXtream data strategies by runtime capability:\n\n| Capability                        | Strategy                                                 |\n| --------------------------------- | -------------------------------------------------------- |\n| **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB  |\n| **Bridge unavailable**            | API-only: fetch from API and keep session data in memory |\n\n**M3U Playlist Module Architecture**:\n\nThe M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels.\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│                         VIDEO PLAYER PAGE                            │\n│        libs/playlist/m3u/feature-player/src/lib/video-player/       │\n├─────────────────────────────────────────────────────────────────────┤\n│  ┌─────────────┐  ┌───────────────────────────────────────────────┐│\n│  │   Sidebar   │  │        Video Player (ArtPlayer/Video.js)      ││\n│  │ ┌─────────┐ │  │                                               ││\n│  │ │Channel  │ │  ├───────────────────────────────────────────────┤│\n│  │ │List     │ │  │  EPG timeline ribbon (app-epg-timeline)       ││\n│  │ │Container│ │  │  horizontal, under the player                 ││\n│  │ └─────────┘ │  └───────────────────────────────────────────────┘│\n│  └─────────────┘                                                    │\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nThe 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.\n\n**Radio Channel Layout** (when `channel.radio === 'true'`):\n\n```\n┌─────────────────────────────────────────────────────────────────────┐\n│  ┌─────────────┐  ┌────────────────────────────────────────────────┐│\n│  │   Sidebar   │  │  Blurred backdrop (station logo)              ││\n│  │             │  │  ┌──────────┐                                 ││\n│  │             │  │  │ Artwork  │  ← cinematic hero layout        ││\n│  │             │  │  └──────────┘                                 ││\n│  │             │  │  Station Name                                 ││\n│  │             │  │  [LIVE] badge                                 ││\n│  │             │  │  ⏮  ▶/⏸  ⏭   ← transport controls          ││\n│  │             │  │  🔊 ━━━━━━━━━  ← volume slider               ││\n│  │             │  │  (no EPG panel)                               ││\n│  └─────────────┘  └────────────────────────────────────────────────┘│\n└─────────────────────────────────────────────────────────────────────┘\n```\n\nKey radio behavior:\n\n- Detection: `channel.radio === 'true'` (string from M3U `radio` attribute)\n- The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio\n- EPG panel is conditionally hidden in the template when radio is active\n- Volume is shared with video player via `localStorage` key `'volume'`\n- Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry\nrecognized as a movie FILE swaps the player + EPG area for the portals'\ntwo-state VOD detail shell fed by TMDB, watch-first (activation still plays\nimmediately; Esc reveals the Browse hero). Detection is synchronous URL-shape\nheuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`)\nor an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/`\npaths and episode-marker names (`S01E02`, \"2 серия\") fail toward the live\nlayout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB\nenrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in\nSettings → Metadata (TMDB)). Host: `m3u-vod-detail/` in\n`libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`,\nparent's `embeddedPlayback()` with `isLive: false`); external MPV/VLC users\nkeep Browse. See \"Movie Recognition (VOD Detail View)\" in\n`docs/architecture/m3u-playlist-module.md`.\n\nChannel List Component Structure (parent coordinator pattern):\n\n```\nlibs/ui/components/src/lib/channel-list-container/\n├── channel-list-container.component.ts   # Parent - shared state coordinator\n├── all-channels-view/                     # Virtual scroll + debounced search\n├── groups-view/                           # Expansion panels + infinite scroll\n├── favorites-view/                        # CDK drag-drop reordering\n├── recent-view/                           # Recently viewed channels\n└── channel-list-item/                     # Individual channel display\n```\n\nKey patterns:\n\n- **EnrichedChannel**: Pre-computed EPG data attached to channels for performance\n- **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`)\n- **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists\n- **Infinite scroll**: IntersectionObserver in groups view loads 50 items at a time\n- **Global progress tick**: Single 30s interval instead of per-item intervals\n\nState management via NgRx (`libs/m3u-state/`):\n\n- `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist\n- `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive\n- `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag\n- `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites\n\nSee `docs/architecture/m3u-playlist-module.md` for complete documentation.\n\n**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.\n\n- Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources`\n- M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player`\n- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts`\n- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts`\n- Global collections: `/workspace/global-favorites`, `/workspace/global-recent`\n- Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`)\n- Downloads: `/workspace/downloads` with focused\n  `/workspace/downloads/:downloadId`; source-scoped equivalents are\n  `/workspace/xtreams/:id/downloads/:downloadId` and\n  `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide\n  the workspace context panel.\n- 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 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\n\n**Service Architecture** (Factory Pattern):\n\n- Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract\n- Two environment-specific implementations:\n    - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend\n    - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version\n- Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject:\n    ```typescript\n    if (window.electron) {\n        return inject(ElectronService);\n    }\n    return inject(PwaService);\n    ```\n\n**Data Storage (Environment-Specific)**:\n\n- **Electron**: SQLite database via Drizzle ORM (`better-sqlite3` driver)\n    - Location: `~/.iptvnator/databases/iptvnator.db`\n    - Full-featured relational database with foreign keys and indexes\n    - Canonical schema and connection live in `libs/shared/database`\n- **PWA (Web)**: IndexedDB via `ngx-indexed-db`\n    - Browser-based NoSQL storage\n    - Same schema structure but implemented in IndexedDB\n    - Limited by browser storage quotas\n\n**TypeScript File Size Rule**:\n\nKeep production TypeScript files under **300 lines**. Hard maximum is\n**350–400 lines**, and CI enforces the 400. Blank lines and comments do not\ncount toward it, so documenting a file never costs you headroom. Tests\n(`**/*.spec.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the\nguidance below is about production code.\n\n- When creating new files, design them to stay within this limit from the start.\n- 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.\n- 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.\n\nTypical split strategies:\n\n- Angular components: extract child components, move logic to a dedicated service or store feature\n- Signal store features: split into smaller `with*` feature functions in separate files\n- Services: split by responsibility (e.g. separate API, transformation, and state concerns)\n- Utility files: group by domain and export from a barrel `index.ts`\n\nThis rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.\n\n---\n\n**Angular Coding Standards**:\n\nThis project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following:\n\n- **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly menu = viewChild.required<MatMenu>('menuRef');\n    readonly items = viewChildren<ElementRef>('item');\n\n    // ❌ Incorrect - Old decorator syntax\n    @ViewChild('menuRef') menu!: MatMenu;\n    @ViewChildren('item') items!: QueryList<ElementRef>;\n    ```\n\n    **Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:\n\n    ```html\n    <!-- ✅ Correct - Unwrap the signal -->\n    <button [matMenuTriggerFor]=\"menu()\">Open Menu</button>\n\n    <!-- ❌ Incorrect - Signal not unwrapped -->\n    <button [matMenuTriggerFor]=\"menu\">Open Menu</button>\n    ```\n\n- **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators\n\n    ```typescript\n    // ✅ Correct - Signal-based\n    readonly title = input.required<string>();\n    readonly size = input<number>(10); // with default value\n    readonly clicked = output<string>();\n\n    // ❌ Incorrect - Old decorator syntax\n    @Input({ required: true }) title!: string;\n    @Input() size = 10;\n    @Output() clicked = new EventEmitter<string>();\n    ```\n\n- **Reactive State**: Use signal primitives for reactive state management\n\n    ```typescript\n    // ✅ Use signal(), computed(), effect(), linkedSignal()\n    readonly count = signal(0);\n    readonly doubled = computed(() => this.count() * 2);\n\n    constructor() {\n        effect(() => {\n            console.log('Count changed:', this.count());\n        });\n    }\n    ```\n\n- **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet)\n\n    ```typescript\n    @HostBinding('class.active') get isActive() { return this.active(); }\n    @HostListener('click') onClick() { /* ... */ }\n    ```\n\n- **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch`\n\n    ```typescript\n    // ✅ Correct - Modern syntax\n    @if (isLoggedIn()) {\n        <p>Welcome!</p>\n    }\n\n    @for (item of items(); track item.id) {\n        <li>{{ item.name }}</li>\n    }\n\n    // ❌ Incorrect - Old syntax\n    <p *ngIf=\"isLoggedIn\">Welcome!</p>\n    <li *ngFor=\"let item of items; trackBy: trackById\">{{ item.name }}</li>\n    ```\n\n### Backend Architecture (Electron)\n\n**Main Entry**: `apps/electron-backend/src/main.ts`\n\n- Bootstraps Electron app and initializes database\n- Registers event handlers for IPC communication\n- 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.\n\n**Database**:\n\n- **ORM**: Drizzle ORM with `better-sqlite3` (local SQLite file)\n- **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path)\n- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):\n    - `playlists` - Playlist metadata (M3U, Xtream, Stalker)\n    - `categories` - Content categories (live, movies, series)\n    - `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\n    - `favorites` - User favorites\n    - `recentlyViewed` - Watch history\n    - `epgChannels`, `epgPrograms` - Persisted EPG data\n    - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`)\n    - `playbackPositions` - Resume positions\n    - `downloads` - Download manager state\n    - `appState` - Key-value app state (also tracks one-off data migrations)\n    - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)\n    - `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`)\n- **Connection**: `libs/shared/database/src/lib/connection.ts`\n    - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`)\n    - Provides full read-write access for `electron-backend` and a read-only mode\n    - A root `drizzle.config.ts` configures Drizzle Kit tooling (points at the schema via the compat shim)\n\n**IPC Communication**:\n\n- **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts`\n    - Exposes `window.electron` API via `contextBridge`\n    - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)\n    - 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.\n- **Event handlers**: `apps/electron-backend/src/app/events/`\n    - `database.events.ts` - Database CRUD operations\n    - `playlist.events.ts` - Playlist import/update\n    - `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`\n    - `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`, worker lifecycle in `epg-worker.service.ts`, DB lookups in `epg-query.service.ts`\n    - `xtream.events.ts` - Xtream Codes API\n    - `stalker.events.ts` - Stalker portal API\n    - `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`) 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 <code>`, 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`\n    - `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\n    - `settings.events.ts` - App settings\n    - `electron.events.ts` - App version, etc.\n\n**Workers** (`apps/electron-backend/src/app/workers/`):\n\n- EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts`\n- Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`)\n- 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`)\n\n### Key Features\n\n**Playlist Support**:\n\n- M3U/M3U8 files (local or URL)\n- Xtream Codes API (`username`, `password`, `serverUrl`)\n- Stalker portal (`macAddress`, `url`)\n\n**Stalker playback links**: `create_link` runs only when the catalog row sets\n`use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays\ndirectly. One helper decides\n(`resolveStalkerStaticPlaybackUrl` in\n`libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied\nby `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by\n`StreamResolverService` for Favorites/Recently Viewed. It falls back to\n`create_link` for anything it cannot resolve alone: no row to read flags from,\na relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme,\nor a loopback host; an episode (`series` set) always mints, since the parameter\nselects the episode server-side. Temporary links live ~5 s, so no resolved URL\nis persisted or replayed — favorites and recently-viewed store the `cmd`,\nplayback positions store ids, and the main-process context map stores headers\nkeyed by origin+path. Downloads are the one exception (they must retry a URL).\n`forced_storage`/`play_token` are deliberately unwired. Contract:\n`docs/architecture/stalker-portal.md` (\"Playback Link Resolution\").\n\n**Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed\non the command line, opened through a file association, or delivered by macOS'\n`open-file` event is normalized to an absolute path in the main process\n(`services/playlist-open-request.ts`) and queued there. The renderer\n(`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the\n`OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is\nwhat makes the main process flush. `OPEN_FILE` is the only way out of the\nqueue, and a request stays there until the renderer confirms receipt via\n`acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the\nlistener runs, and a reload or dead render process keeps the `WebContents`\nalive, so a successful push is not proof of delivery. Anything unacknowledged\nis replayed to the next renderer that announces itself. The renderer\nimports them on a single promise chain so a burst arrives in a deterministic\norder. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`)\nfor the same reason: each action carries a different playlist, so a newer add\nmust never cancel an older one's write, EPG fetch and navigation. The import\nitself reuses the normal file path\n(`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence,\nplaylist-scoped EPG, and the navigation to the new playlist all behave exactly\nlike a dialog import.\n\nThe OS-level registration that makes those paths reachable is\n`fileAssociations` in `electron-builder.json` — one entry per extension, each\nwith its own `mimeType`. Electron Builder derives all three platform\nregistrations from it: macOS `CFBundleDocumentTypes` (which is what makes\n`open-file` fire from Finder), the NSIS registry entries, and, on Linux, the\ndesktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for\ndeb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading\n`linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and\nmust not be used; and it appends `%U` to `Exec`, so Linux file managers hand\nover percent-encoded `file://` URIs rather than paths —\n`createPlaylistOpenRequest` decodes them before the extension check. `%U` is\nalso the _plural_ exec code, so a multi-file selection arrives as one launch\nwith one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all\nof them and `enqueueAll` queues the batch, because stopping at the first match\nwould silently drop the rest of the selection. Adding an exec code to\n`linux.executableArgs` would suppress the `%U` but also pass that code to the\napp as a real argument, so it is not an option.\n\n**Video Players**:\n\n- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer\n- mpegts.js `1.8.1` errors from all three built-in players cross one\n  version-locked structured evidence boundary in `libs/playback/util`. It\n  retains only exact public type/detail pairs, pair-derived stage/failure,\n  terminal disposition, and a\n  validated HTTP 4xx/5xx status; raw messages and arbitrary `info` never reach\n  stored or rendered diagnostics. HTTP/network failures avoid false decoder\n  recommendations, while exact format, codec, truncated-stream, and\n  MediaSource failures retain actionable recovery guidance. This diagnostic\n  layer remains separate from the shared `PlayerController` controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- DASH + ClearKey (M3U module): `.mpd` channels play through a lazily loaded\n  Shaka Player source engine inside the HTML5 and ArtPlayer components (no new\n  player in settings). ClearKey keys come from `#KODIPROP:inputstream.adaptive.*`\n  lines, post-processed into `Channel.drm` by `extractDrmFromRaw()` in\n  `libs/shared/m3u-utils` (hooked in `createPlaylistObject()`, covering all\n  import paths). DASH channels always play inline: `isDashChannel()` bypasses\n  the external-player setting (radio precedent) and routes Video.js/MPV/VLC/\n  embedded-MPV users to the HTML5 player via `playerOverride` (ArtPlayer keeps\n  ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope,\n  need the castLabs Electron fork) surface a DRM playback diagnostic instead\n  of crashing. ClearKey EME works in stock Electron. Engine:\n  `libs/ui/playback/src/lib/shaka-engine/`. Its DOM-free Shaka `5.2.4`\n  diagnostic boundary lives in `libs/playback/util`; it version-locks public\n  severity/category/code evidence, ignores\n  recoverable error events, treats rejected loads as terminal lifecycle\n  outcomes, preserves exact public DASH text-parser category/code evidence with\n  unknown stage/failure, and never retains or renders raw messages or\n  `error.data`. A failed browser-support preflight stays generic-unknown but\n  carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. Details in\n  `docs/architecture/m3u-playlist-module.md` (\"DASH + ClearKey Playback\").\n- External players: MPV, VLC (via IPC to Electron backend)\n- Display sleep during playback: `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock: in\n  Electron a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`;\n  auto-cleared on renderer reload/crash), in the PWA the Screen Wake Lock\n  API. Radio's `<audio>` deliberately never blocks display sleep. Embedded\n  MPV holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. 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=<x11-window>` 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. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`.\n- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux\n  x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy\nengine` (restart required) or\n  `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV\n  experiment flag): a per-session helper renders mpv offscreen (CGL on macOS,\n  EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the\n  preload frame pump uploads them to\n  `<canvas data-embedded-mpv-frame>`. Shared `app-player-controls` owns the DOM\n  UI; native-view retains the legacy dock. On Linux, only\n  `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the\n  addon, and frame reader must not. Pristine afterPack/unpacked layouts scan\n  Electron libraries recursively; extracted Snap payloads exclude only the\n  package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root.\n  Every other directory remains recursive, and Electron-library symlinks still\n  fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`;\n  `afterPack` alone owns the profile-normalized unpacked native tree, and\n  package checks reject every archived `/electron-backend/native/**` entry.\n  Packaged addon, frame-reader, and helper discovery uses only package-owned\n  `app.asar.unpacked` paths; cwd/dist candidates remain development-only.\n  Official x64 packages use three separate profiles:\n  DEB/RPM/Pacman depend on system libmpv plus the helper's direct\n  EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and\n  Flatpak bundles the same closure. Flatpak is an isolated packaging pass and\n  keeps `iptvnator` as the real Electron ELF so Electron Builder's\n  `electron-wrapper` passes it directly to Zypak. Other Linux targets retain the\n  conditional `iptvnator` wrapper and `iptvnator.bin`. Mixed\n  Flatpak/non-Flatpak target sets fail before mutation. Exact system\n  dependencies are DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n  RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n  Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+;\n  Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`.\n  ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the\n  fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`;\n  any failure keeps the sandbox enabled, records a stable reason, and falls\n  back to native-view without crashing. Snap is `core22`/strict and uses an\n  exact private `shared-memory` plug plus the `graphics-core22` content plug at\n  a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the\n  default provider. Its only provider-data layouts bind `/usr/share/libdrm`\n  from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to\n  `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable\n  status after disconnect, then reconnects and requires success. Static\n  artifact verification requires regular `desktop-init.sh`,\n  `desktop-common.sh`, and `desktop-gnome-specific.sh` files at the Snap root,\n  with `desktop-init.sh` executable. The helper links `libGL.so.1`, and\n  probe/playback share a sanitized loader environment\n  in which ambient audit, preload, library, graphics-driver, and shell-startup\n  overrides are removed; the validated private closure plus trusted host GL,\n  graphics-content, core22 base x64, and exact GNOME-platform roots have\n  explicit precedence. The core22 base stays ahead of GNOME so the older\n  `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The\n  extracted-artifact verifier removes the identical unsafe loader/graphics/\n  shell set before direct helper smoke while preserving selectors such as\n  `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`,\n  removes exported `BASH_FUNC_*` functions, and\n  launches probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only\n  `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate\n  before BrowserWindow startup and emits one availability JSON line. A nonzero\n  helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is\n  present only for an exact protocol-v1 line carrying a fixed allowlisted\n  reason, and its optional `helperDetail` must be 1–1024 printable ASCII\n  characters. Invalid detail suppresses both helper fields. Every probe uses\n  an explicit 16 MiB aggregate captured-output ceiling independent of tracing.\n  With `IPTVNATOR_TRACE_PLAYER=1`, non-empty helper stderr is emitted separately\n  as one JSON-escaped stderr line with a 16,384-character `stderr` limit and an\n  explicit `truncated` field; trace-write failure cannot change availability.\n  Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded\n  channel. The exact packaged Flatpak `/app` context reconstructs only\n  Freedesktop Platform 24.08's immutable\n  `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its CI smoke invokes that\n  application-level probe instead of the helper directly. The packaged x64\n  Playwright smoke runs its fixture-contract target first and passes Chromium\n  `--ignore-gpu-blocklist` so CI llvmpipe exposes WebGL2; this does not bypass\n  the runtime gate, and `--no-sandbox` remains root-only. Bundled Linux\n  packages carry hash-validated\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`.\n  CI caches the staged runtime plus immutable source inputs, never finished\n  notices or the compliance tarball; it regenerates those notices and the\n  VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current\n  checkout while preserving the exact pinned six recursive libplacebo\n  submodule records. Each record is canonical `full-commit safe/path`;\n  clone-depth dependent `git describe` annotations are discarded and never\n  form part of the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. Every\n  bundled x64 package manifest binds the final archive's SHA-256 and repository\n  revision; system and marker-only packages do not carry that binding. Snap\n  Store\n  publication runs only from a public `v*` GitHub release that already\n  contains the Snap assets and exactly one source archive. Before any upload,\n  the workflow hashes and checks the archive's exact member/type set and size\n  bounds, verifies its clean tag revision, pinned sources including the six\n  recursive submodule records and exact libplacebo tree digest, legal payload,\n  and exact released tooling, then performs bounded extraction and static\n  validation for every Snap. That public-release boundary independently\n  revalidates the exact strict `meta/snap.yaml` graphics/shared-memory\n  contract and enumerates `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`. Exactly one x64 Snap\n  must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap remains marker-only.\n  Checkout and artifact-transfer actions are pinned to full commits; checkout\n  does not persist credentials, and repository credentials are scoped to\n  download steps. A secretless verification job copies assets through\n  no-follow descriptors, checks them before and after inspection, writes an\n  exact receipt, fully reverifies a root-owned read-only snapshot, and\n  transfers only that data through the pinned artifact service while passing\n  the receipt digest separately through a job output. The dependent publish\n  job uses a bounded `ubuntu-latest` runner with no checkout or release-tag\n  code, verifies that digest plus the exact receipt, asset hashes, and\n  file-only layout, root-seals the data again, and installs Snapcraft directly.\n  Its final fixed shell step alone receives the Store credential, resolves no\n  PATH command, executes no released code, and exposes that credential only to\n  each exact\n  `/snap/bin/snapcraft upload --release=edge` process. Candidate/stable\n  promotion is manual after installed-Snap frame-copy and missing-runtime\n  fallback smoke; GitHub Actions never promotes automatically. On Windows,\n  package validation requires the exact MPV DLL named by the helper's PE import\n  table beside the executable.\n  Backend adapter:\n  `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`;\n  shared-controls adapter:\n  `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`;\n  helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime\n  contracts: `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n- 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. 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-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `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, 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 active HTML5, Video.js, or ArtPlayer shell's own fullscreen so ranked recovery actions remain visible. 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). `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`.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n\n**Download Manager**:\n\n- Fresh Xtream movie and series-episode downloads propagate the playlist's\n  User-Agent, Referer, and Origin, defaulting User-Agent to the same\n  provider-compatible `XTREAM_CLIENT_USER_AGENT` used by API requests and\n  stream probes. Retry, resume, and missing-file\n  recovery also add the fallback to legacy Xtream rows that have no stored\n  User-Agent. Because download rows survive source deletion, a headerless\n  legacy row whose playlist is already absent receives the same IPTV-player\n  fallback; a known Stalker row remains unchanged. Allowlisted connection\n  resets after bytes reach disk retain the partial and show a credential-safe\n  `DOWNLOAD_NETWORK_INTERRUPTED` code only when the response supplied a strong\n  ETag or Last-Modified validator. Retry then continues with Range/If-Range;\n  without a validator it starts from byte zero and overwrites the unverified\n  partial instead of risking mixed-representation corruption.\n- The desktop-only manager shares one global download store across the global,\n  Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series\n  cards use the global Small/Medium/Large cover-grid tokens; missing completed\n  files move to Needs attention instead of remaining in Ready to watch.\n- Series details route individual and selected-season episode downloads through\n  the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode\n  pending identities synchronously, submits season candidates sequentially and\n  best-effort through the existing `DOWNLOADS_START` path, performs one final\n  authoritative refresh after added or stable duplicate submissions, and\n  reports added, skipped, and failed counts. Xtream and Stalker adapters remain\n  responsible for provider URLs, headers, and metadata; the backend still runs\n  one active transfer with a FIFO queue. `DOWNLOADS_START` remains the sole\n  start IPC. A reserved completed-missing match triggers one authoritative\n  preflight refresh before provider preparation. Download-list loads are\n  serialized as one active IPC plus one coalesced trailing refresh; a preflight\n  assigned to that trailing refresh cannot be starved by later progress\n  broadcasts. A restored Stalker file can therefore become a stable skip\n  without a portal request. The IPC's stable\n  `reason: 'already-in-progress'` and `reason: 'already-downloaded'`\n  results are counted as skipped, and no batch IPC is introduced. The latter\n  comes from an asynchronous main-process filesystem recheck before a\n  completed-missing row can be reset, so a file restored after the renderer\n  snapshot is not orphaned or downloaded again. The recheck has a one-second\n  caller deadline that starts before shared-slot acquisition; timeout or probe\n  failure leaves the row untouched and reports a failed submission so the\n  season loop can continue. Completed-file list callers use the same deadline\n  and report a timeout as missing for that snapshot. The underlying filesystem\n  operation remains coalesced and charged against the four-probe cap until it\n  settles, so later callers have independent bounded waits without duplicating\n  stalled native work. Only `ENOENT` and `ENOTDIR` prove absence; permission,\n  I/O, and other filesystem errors remain unknown and cannot clear a completed\n  row. Before a completed-missing, failed, or canceled row clears its retained\n  path, the start IPC asynchronously removes any `.part` through a separate,\n  same-path-coalesced, four-operation cap. A one-second admission deadline\n  rejects queued work before unlink starts; started work is awaited so it cannot\n  mutate after a failure response. Non-absence errors keep the row's ownership\n  intact; `ENOENT` and `ENOTDIR` safely proceed. Episode and season download\n  actions require an authoritative global list. A\n  successful snapshot remains authoritative while a later background refresh\n  is in flight; a latest refresh failure leaves\n  loading/empty-state resolution intact but disables starts until another\n  snapshot succeeds. Overlapping download-list callers join one serialized\n  trailing refresh, so responses commit in request order and frequent progress\n  events cannot perpetually postpone a waiting series action.\n- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`\n  for both providers; Stalker playback identifiers only resolve the URL. Exact\n  `(playlistId, contentType, xtreamId)` matches are authoritative, while\n  complete playlist/series/season/episode coordinates are a fail-closed legacy\n  fallback that migrates reusable rows to the canonical id. Numeric season\n  zero, including fallback key `\"0\"`, remains a valid Specials coordinate for\n  both providers. Stalker persists\n  `episode_identity_scope` separately for regular `/series`, embedded VOD\n  `series[]`, and lazy Ministra VOD `is_series`. Known different scopes do not\n  match; a pre-scope coordinate row is ambiguous and blocked, while an exact\n  canonical legacy row remains authoritative. Renderer lookup preserves that\n  ambiguity or conflicting ownership as a distinct ineligible state, so\n  neither the episode action nor the season count treats it as a row-less\n  download. SQLite `null` and optional `undefined` coordinates both mean an\n  incomplete canonical legacy row, matching the backend resolver. Pending and\n  active rows plus completed available/unknown rows are skipped; failed,\n  canceled, completed-missing, and unambiguous row-less episodes remain\n  eligible.\n- Ready cards (movies, grouped series, and standalone episodes) open a focused\n  local detail; local file actions (Play, Show in folder, Copy URL, Remove)\n  live in the poster's overflow menu. Movies play the finalized local file;\n  series list only locally available episode rows and every episode action\n  targets its own downloaded file. Focused routes disable route search and use\n  `contextPanel: 'none'`.\n- Downloads capture a versioned metadata snapshot from the rendered Xtream or\n  Stalker movie/episode detail at start time, including already-merged TMDB\n  fields. Legacy, sparse, stale, or wrong-language snapshots are safely\n  backfilled from row/provider metadata and optional TMDB enrichment when the\n  focused detail opens.\n- `View in portal` resolves a concrete Xtream category/item route. Stalker\n  accepts a recently-viewed shape only when its raw movie/series mode matches\n  the download, and prefers an exact numeric category from the download\n  snapshot. Without that shape, only a movie carrying an exact category can\n  form a metadata-only target; unproven episode and legacy-movie handoffs stay\n  unavailable. The normal detail uses one-shot `provider-only` presentation:\n  it exposes provider content/playback it can resolve while hiding\n  Offline/local/download actions. A second, independent `View in portal`\n  bridge exists for inline collection details — see **Collection Detail\n  Portal Handoff** below; it deliberately does NOT use `provider-only`.\n- Download rows and local files survive source deletion. The global offline\n  library remains visible with no playlists; only provider handoff is disabled\n  until the source exists again.\n- If a finalized file disappears while a focused detail is open, the\n  authoritative download list refreshes and returns to the manager. A failed\n  redirect leaves an actionable missing-file state with Back and Retry.\n- Canonical contract: `docs/architecture/download-manager.md`; provider handoff:\n  `docs/architecture/portal-detail-navigation.md`.\n\n**Collection Detail Portal Handoff** (`View in portal` for inline details):\n\n- Details opened outside portal category context — `/workspace/global-favorites`,\n  `/workspace/global-recent` (which also receive the dashboard hero, Continue\n  Watching and favorites-rail handoffs), and a portal's own `favorites`/`recent`\n  tabs — render full-width with no category sidebar. They expose a separate-row\n  hero action that jumps to the item inside its owning portal.\n- Visibility is DI-gated, never URL-sniffed: `app-view-in-portal-action`\n  (`libs/ui/components/src/lib/view-in-portal-action/`) renders only when a host\n  provides `VIEW_IN_PORTAL_HANDOFF`. The sole providers are\n  `XtreamCollectionDetailComponent` (through its dynamic detail injector) and\n  `StalkerCollectionDetailComponent` (component providers), which exist only in\n  collection contexts — so router-mounted category details need no opt-out. When\n  hidden the host must stay `display: none`, or its `flex: 0 0 100%` would claim\n  a phantom row in the hero action container.\n- Targets come from `getUnifiedCollectionDetailNavigation()`\n  (`libs/portal/shared/util/.../collection-detail-portal-navigation.ts`). Unlike\n  `getUnifiedCollectionNavigation` it NEVER degrades to a category- or\n  section-only route: an Xtream item without a resolvable category and positive\n  item id keeps the action hidden rather than promising a jump to the title and\n  landing in a list.\n- Stalker section resolution mirrors `resolveStalkerCollectionDetailMode()`\n  (`libs/portal/stalker/feature/src/lib/stalker-collection-detail-mode.ts`) and\n  must not be\n  simplified to `item.contentType`: `extractStalkerItemType()` reports `series`\n  for embedded `series[]` snapshots and lazy Ministra VOD `is_series` items, but\n  both belong in the VOD catalog — the lazy season/episode fetch in\n  `StalkerCatalogFacadeService.selectItem()` is gated on the VOD content type, so\n  a `/series` route leaves the detail unable to load episodes. The virtual\n  `series` category is normalized to `vod` the same way\n  `resolveStalkerCollectionSelectedCategory()` does. Stalker also carries\n  `stalkerReturnTo` plus\n  `stalkerReturnByHistory`, and the portal detail's back affordance\n  (`StalkerCatalogDetailComponent.onVodBack()`,\n  `StalkerSeriesViewComponent.goBack()`) honours the latter by stepping back\n  one history entry instead of calling `navigateByUrl()`. The collection's\n  active tab, scope and open inline detail live only in `window.history.state`\n  (`collectionViewState` / `openCollectionDetailItem`), so re-navigating would\n  reopen it on the default `live` tab and leave the portal page one browser\n  Back away. The marker carries the handed-off item's identity, not a bare\n  `true`: `openStalkerItem` is consumed on arrival while the return keys stay\n  on the entry, and a Stalker detail opens in place without pushing one — so\n  after Back + browser Forward the same entry can host a different title, whose\n  back affordance must just close it. A stale marker suppresses the whole\n  return contract, and honouring it retires both keys from the entry so a\n  browser Forward cannot replay them for a reopened title. Leaving with the\n  browser's own Back runs no affordance, so `CategoryContentViewComponent`\n  also retires the contract whenever it lands on the entry with no handoff\n  item and no open detail. That retirement is gated on the marker, so a plain\n  `stalkerReturnTo` caller such as the dashboard handoff is unaffected. The identity is\n  restricted to what `buildStalkerSelectedVodItem()` preserves (`id ??\nstream_id`); it drops `series_id`/`movie_id`, so the builder pins the\n  resolved id onto the handoff state item when the raw row carries neither —\n  those rows then get the same history return instead of degrading to a\n  re-navigation that resets the collection's tab.\n  Only this builder sets the marker, so the\n  dashboard handoff and any other `stalkerReturnTo` caller keeps\n  re-navigating.\n- Unlike the download handoff this bridge does NOT pass\n  `detailPresentation: 'provider-only'` — the item exists in the provider\n  catalog, so the full normal detail (downloads included) is wanted.\n- Contract: `docs/architecture/portal-detail-navigation.md`.\n\n**VOD/Series Detail Pages (two-state layout)**:\n\n- 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)\n- 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)\n- 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\n- Watch state derives from `inlinePlayback() !== null` only; external MPV/VLC playback keeps the browse layout. Esc and \"Close player\" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's `goBack()`)\n- 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.\n- 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.\n- 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. Lazy VOD episode tracking IDs scope the parent series, provider episode, 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 any stored season/episode coordinates. 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.\n- Hosts pass hero chips/meta/actions as `*appDetailTags`/`*appDetailMeta`/`*appDetailActions` templates; the shell stamps them into both the hero and the About block\n- Seasons are tabs (`SeasonTabsComponent`, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) 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)\n- Dashboard hero/Continue Watching clicks for an Xtream 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. Ordinary global-recent grid clicks remain detail-only.\n- See `docs/architecture/embedded-inline-playback.md` (\"Two-State Detail Layout\")\n\n**VOD Multi-Source** (alternative sources for a movie):\n\n- 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).\n- 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.\n- **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.\n- 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.\n- 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.\n- 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.\n- 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`.\n- 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).\n- 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.\n- 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.\n- See `docs/architecture/vod-multi-source.md`\n\n**Radio Player**:\n\n- Dedicated audio player for channels with `radio=\"true\"` M3U attribute\n- Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls\n- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio\n- EPG panel is hidden for radio channels (radio streams have no EPG data)\n- Volume synced with video player via shared `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)\n- Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts`\n\n**EPG (Electronic Program Guide)**:\n\n- XMLTV format support\n- Background parsing in worker thread\n- Stored in database for quick lookup\n- 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/`\n\n**TMDB Metadata Enrichment** (opt-in):\n\n- 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\n- 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\n- \"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\n- 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`)\n- 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\n- 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`\n- 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`)\n- 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`\n- 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\n- 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); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`)\n- 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:<id>` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment\n- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)\n- 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, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker.\n- 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`)\n- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About\n- See `docs/architecture/tmdb-metadata-enrichment.md`\n\n**Portal Account Info**:\n\n- 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.\n- Xtream: `AccountInfoComponent` (`libs/portal/xtream/feature/src/lib/account-info/`), queries `get_account_info` live.\n- 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\").\n- 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.\n\n**Stalker Portal Mode and Endpoint Discovery**:\n\n- 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.\n- 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.\n- 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 `<base>/portal.php` → `<base>/server/load.php` → `<base>/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 `<base>/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.\n- 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.\n- `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.\n- 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.\n- 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.\n- 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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Portal Mode and Endpoint Discovery\", \"Request Transport and `cmd` Encoding\").\n\n**Stalker Session Authentication**:\n\n- 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`.\n- `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.\n- 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.\n- 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.\n- 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.\n- 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.\n- Full contract: `docs/architecture/stalker-portal.md` (\"Session Authentication Lifecycle\").\n\n**Stalker Identity Hardening**:\n\n- 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.\n- 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.\n- `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.\n- `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.\n- Contract: `docs/architecture/stalker-portal.md` (\"Stalker Identity Policy\").\n\n**Favorites and Recently Viewed**:\n\n- Per-playlist favorites and global favorites\n- Recently viewed tracks watch history\n\n**Internationalization**:\n\n- Uses `@ngx-translate` with 19 language files in `apps/web/src/assets/i18n/`\n\n## Development Notes\n\n### Environment Detection and Dual-Mode Architecture\n\nThe app determines whether it's running in Electron or as a PWA by checking:\n\n```typescript\nwindow.electron; // truthy in Electron, undefined in browser\n```\n\n**Why Dual Mode?**\nIPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:\n\n- **Electron**: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access\n- **PWA**: Lightweight web version that runs in any browser without installation\n\n**Environment-Specific Behavior**:\n\n- `app.config.ts` - `DataFactory()` selects DataService implementation based on environment\n- `app.routes.ts` - Same `/workspace/...` route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA\n- Storage layer switches automatically:\n    - Electron → SQLite/Drizzle ORM → `~/.iptvnator/databases/iptvnator.db`\n    - PWA → IndexedDB → Browser storage\n- External player support (MPV/VLC) only available in Electron\n- File system operations only available in Electron (uploading playlists from disk)\n\n**Base Href Configuration**:\nThe app uses different base href values depending on the build target:\n\n- **Development & PWA**: `baseHref=\"/\"` (from `index.html`)\n    - Used by: `pnpm run serve:frontend`, `pnpm run build:frontend:pwa`\n    - For web servers with proper routing\n- **Electron Production**: `baseHref=\"./\"` (overridden in build config)\n    - Used by: `pnpm run build:backend`, `pnpm run make:app`\n    - Required for `file://` protocol in Electron\n\nBuild configurations in `apps/web/project.json`:\n\n- `production`: Electron build with `baseHref=\"./\"`\n- `pwa`: Web deployment with `baseHref=\"/\"`\n- `development`: Dev mode with `baseHref=\"/\"` from index.html\n\n**Factory Pattern Implementation**:\nThe 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.\n\n**Build Commit In About**:\nCI 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 `\"<version> (<short-sha>)\"`. The semver version itself deliberately stays untouched — 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.\n\n### Testing Strategy\n\n- **Unit tests**: Jest with `jest-preset-angular` and `ng-mocks`\n- **E2E tests**: Playwright testing the web app and Electron app\n- Backend tests use standard Jest\n- Bug fixes should add focused regression coverage unless there is a documented reason not to.\n- 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.\n\n### Nx Commands\n\nUse `nx` CLI for better performance:\n\n```bash\npnpm nx run <project>:<target>\n# Example: pnpm nx run web:build\n# Example: pnpm nx run electron-backend:serve\n```\n\nTo run multiple projects:\n\n```bash\npnpm nx run-many --target=test --all\n```\n\n### Electron Build Process\n\nThe Electron backend depends on the web app being built first:\n\n- `electron-backend:build` depends on `web:build`\n- Output goes to `dist/apps/electron-backend` (backend) and `dist/apps/web` (frontend)\n- Packaging combines both into distributable\n\n### Database Migrations\n\nNo formal migration system yet. Schema changes are applied via raw SQL in the `createTables()` function in `libs/shared/database/src/lib/connection.ts` using `CREATE TABLE IF NOT EXISTS`. One-off data migrations run guarded by keys stored in the `appState` table.\n\n### Common Patterns\n\n**IPC Communication**:\n\n1. Define handler in appropriate events file (e.g., `database.events.ts`)\n2. Register with `ipcMain.handle()` in the event bootstrap function\n3. Expose in preload script via `contextBridge.exposeInMainWorld()`\n4. Call from Angular via `window.electron.<methodName>()`\n\n**Adding New Playlist Source**:\n\n1. Add type to `libs/shared/interfaces/src/lib/playlist.interface.ts`\n2. Create event handler in `apps/electron-backend/src/app/events/`\n3. 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\n4. Update database schema if needed\n\n**State Management**:\n\n- Use NgRx for global application state (M3U playlists, `libs/m3u-state`)\n- Use NgRx Signal Store with `signalStoreFeature()` composition for portal/feature state (XtreamStore, StalkerStore)\n- Use NgRx signals for reactive data streams\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n","category":"root","tokens":38608},{"name":"AGENTS.md","path":"AGENTS.md","title":"AGENTS.md","content":"# AGENTS.md\n\nThis file provides guidance to coding agents working in this repository.\n\n## Plan Mode\n\n- When an agent is in Plan Mode and produces a final `<proposed_plan>`, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory.\n- Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`.\n- Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`.\n- If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on.\n\n## Agent Bootstrap\n\n- 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.\n- After dependencies are installed, verify workspace discovery with `pnpm nx show projects`.\n- 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`.\n- Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents.\n- See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy.\n- Keep `nx` and every official `@nx/*` package on the same exact version; run\n  `pnpm run deps:nx:validate` after dependency updates.\n- Vite `7.3.6`, resolved through Angular's build tooling, is patched with\n  bounded transform prefilters and the upstream precise matchers in\n  `patches/vite@7.3.6.patch`. Keep the patch until supported Angular tooling\n  resolves a Vite version containing the fix, and run `pnpm run deps:vite:test`\n  after related dependency updates.\n- A directory holding files consumed by other projects must be an Nx project.\n  Nx builds its graph from TypeScript imports only, so a relative SCSS `@use`\n  across project roots creates no edge and the imported file lands in no task\n  hash — edits then return a cache hit instead of rebuilding. Shared partials\n  live in `libs/ui/styles` (project `ui-styles`), and each consumer declares\n  `\"implicitDependencies\": [\"ui-styles\"]`. Run `pnpm run styles:inputs:validate`\n  after adding a cross-project stylesheet import.\n- Update Nx with `pnpm nx migrate nx@<target> --skipInstall`, regenerate the\n  lockfile, run generated migrations when present, and validate before opening\n  a PR. Major updates are always manual. Replace incomplete Dependabot security\n  PRs with a coordinated update instead of editing the bot branch.\n- ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.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 -- <why> */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused.\n- Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint \"apps/<project>/**/*.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 <project> -name '*.ts' | wc -l`.\n- Repository-specific skills live under `.codex/skills/`.\n- Frontmatter descriptions are trigger-only and begin with `Use when`; keep\n  each skill at or below 500 words.\n- Run `pnpm run skills:validate` after editing a committed skill or a literal\n  path it documents.\n- Keep `.codex` and `.claude` copies of `release-notes` and `release-cut`\n  byte-identical.\n\n## Documentation After Changes\n\n- After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete.\n- 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.\n- Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.\n- Prefer updating an existing authoritative doc before creating a new one:\n    1. `README.md` for top-level developer or user workflows\n    2. `docs/architecture/` for architecture, ownership, and behavior contracts\n    3. the nearest module `README.md` for local usage or behavior\n- 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.\n- 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.\n- 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.\n- Repo docs are canonical even when they were originally drafted by an LLM.\n- Final task summaries should state whether docs were updated and which doc changed.\n\n## Release Notes For User-Visible Changes\n\n- 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`.\n- Name it `<area>-<short-slug>.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time.\n- 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.\n- `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.\n- 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.\n- 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).\n- The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence.\n- Validate before finishing: `pnpm run release:notes:validate`.\n- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.\n- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.\n- 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.\n- Final task summaries should state whether a release note was added or why it was skipped.\n\n## Regression Prevention And Test Updates\n\n- 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.\n- 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.\n- 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.\n- Default validation ladder:\n    1. Run targeted unit tests for directly affected projects with `pnpm nx test <project>` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader.\n    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.\n    3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear.\n    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`.\n- 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.\n- 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.\n\n## Electron Debugging (CDP)\n\n- Start the Electron development app with: `nx serve electron-backend`\n- Package-script equivalent: `pnpm run serve:backend`\n- Electron is configured to start with: `--remote-debugging-port=9222`\n- Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222`\n- For Electron automation/debugging tasks, use the `electron` skill\n- Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`.\n- 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.\n- 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.\n- 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.\n\n### Trace / Debug Startup\n\n- Full startup tracing:\n\n```bash\nIPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend\n```\n\n- Narrower trace flags:\n    - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls\n    - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events\n    - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker\n    - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events\n    - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr\n    - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal\n    - `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\n    - `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\n\n- Settings, portal request/response, and trace payloads must use\n  `@iptvnator/shared/logging` or the redacting portal logger before reaching\n  `console.*`; never log raw credentials while debugging.\n\n- If local Nx state gets weird before a rerun:\n\n```bash\npnpm nx reset\n```\n\n### agent-browser (global install)\n\n```bash\nagent-browser --cdp 9222 tab list\nagent-browser --cdp 9222 tab 1\nagent-browser --cdp 9222 snapshot -i -c -d 4\nagent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png\n```\n\n### Fallback\n\n```bash\nnpx --yes agent-browser --cdp 9222 tab list\n```\n\n### DevTools Workaround\n\n```bash\nELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend\ncurl http://127.0.0.1:9222/json/list\nagent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>\nagent-browser screenshot /tmp/iptvnator-cdp.png\n```\n\n## Radio / Audio Player\n\nM3U playlists can contain radio channels identified by the `radio=\"true\"` attribute on `#EXTINF` lines. When a radio channel is selected:\n\n- The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player\n- The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored\n- The EPG panel is hidden (radio streams have no EPG data)\n- 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\n- Volume is shared with the video player via `localStorage` key `'volume'`\n- Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle)\n- Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean\n\nKey files:\n\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component\n- `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling\n- `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video\n- `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface\n\n## Shared Player Controls\n\n- `libs/ui/playback/src/lib/player-controls/` contains the additive,\n  engine-neutral `PlayerController` contract, standalone\n  `app-player-controls`, generic web-video adapter/helper, and component-scoped\n  `WEB_PLAYER_SHARED_CONTROLS` rollout token.\n- In fullscreen, `app-player-controls` shows a pointer-transparent media-title\n  overlay at the top while controls are revealed (`mediaTitle` input:\n  movie/channel/series name, plus an `S01E03` second line for episodes). Series\n  names flow from the Xtream/Stalker detail views through\n  `PortalInlinePlayerComponent.seriesTitle` and `WebPlayerViewComponent.mediaTitle`;\n  movie and live hosts fall back to `playback.title`, skipping raw stream-URL\n  fallbacks. Outside fullscreen the overlay stays hidden.\n- Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox\n  appears only when HTML5, Video.js, or ArtPlayer is selected.\n  `WebPlayerViewComponent` snapshots the preference into\n  `WEB_PLAYER_SHARED_CONTROLS` for each new player host. The parent `/workspace`\n  route awaits the initial `SettingsStore` load, including cold-start direct\n  links, before this snapshot can occur. Saving applies to the next host without\n  an application restart; an existing session never changes controls mode in\n  place.\n- `Settings.showCaptions` is deliberately outside this rollout gate: it is\n  engine state, not controls UI. HTML5, Video.js, and ArtPlayer apply it in both\n  modes — shared controls through their controls bridge, the preference-off\n  paths through the same helpers without an adapter (`WebVideoSourceTracks` for\n  HTML5/ArtPlayer, `VjsLegacyTracks` for Video.js). Both re-apply the preference\n  as the engine adds or switches text tracks. `WebPlayerViewComponent` reads it\n  from `SettingsStore` rather than a host input, so the M3U player, the\n  Xtream/Stalker live layouts, and the portal detail inline player all inherit\n  it (#1155).\n- The modes differ in how long the preference is enforced. Shared controls are\n  authoritative for the session; user intent arrives through `setSubtitleTrack`\n  and wins until the source changes. Vendor chrome is source-default: the\n  preference seeds each new source and is released once the media element\n  reports `playing`, so the engine's own caption menu keeps working. The mode is\n  selected by the optional `playbackStarted` probe the legacy owners pass to all\n  three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper\n  deselects the track (`subtitleTrack = -1`) instead of hiding it, because\n  `subtitleDisplay` would silently override whatever the vendor menu picks. For\n  DASH the seed happens in `ShakaVideoSession.start()` after the manifest loads,\n  so the helper only stops re-suppressing afterwards.\n- Embedded MPV ignores the web-player preference. Frame-copy always uses shared\n  DOM controls through its component-scoped `EmbeddedMpvControlsAdapter`, while\n  native-view retains the legacy compositor-safe dock and external MPV/VLC\n  retain their own UI. The host must render exactly one controls system for the\n  reported Embedded MPV engine.\n- Frame-copy shared controls own DOM surface interactions, shortcuts,\n  fullscreen, and recording feedback. `showControls=false` detaches the shared\n  surface, modal overlays gate playback shortcuts, fullscreen still triggers\n  bounds sync, and a playback/session transition key prevents engine or session\n  handoff from presenting stale recording feedback while timers and pending\n  commands are cancelled. Same-session IPC replies also yield to a broadcast\n  snapshot received while the command was pending, preventing a successful\n  recording acknowledgement from being rolled back by a stale reply.\n- DASH (`.mpd`) sources play through a lazily imported Shaka Player source\n  engine (`libs/ui/playback/src/lib/shaka-engine/`) inside the HTML5 and\n  ArtPlayer components; ClearKey keys come from KODIPROP-derived\n  `Channel.drm`, and the shared bridge exposes Shaka audio/text tracks via\n  source kind `shaka`. The DOM-free Shaka `5.2.4` diagnostic boundary lives in\n  `libs/playback/util`; it version-locks public severity/category/code evidence,\n  ignores recoverable error events,\n  treats rejected loads as terminal lifecycle outcomes, preserves exact public\n  DASH text-parser category/code evidence with unknown stage/failure, and never\n  retains or renders raw messages or `error.data`. A failed browser-support\n  preflight stays generic-unknown but carries the exact app-owned\n  `PlaybackRuntimeSupport.ShakaBrowserUnsupported` marker, preserving managed\n  external fallback only for clear transferable DASH; PWA capability and\n  KODIPROP DRM still suppress it. See the CLAUDE.md \"Video Players\" feature\n  entry and the \"DASH + ClearKey Playback\" section of\n  `docs/architecture/m3u-playlist-module.md`.\n- mpegts.js `1.8.1` errors from HTML5, Video.js, and ArtPlayer cross one\n  version-locked structured evidence boundary in `libs/playback/util`. Only\n  exact public type/detail pairs, pair-derived stage/failure, terminal\n  disposition, and the validated HTTP 4xx/5xx status slot are retained; raw\n  messages and arbitrary `info`\n  never reach diagnostics. This is a sibling of `PlayerController`, not part\n  of the controls contract.\n- Browser playback diagnostics and recovery policy live in\n  `libs/playback/util` and are exported by `@iptvnator/playback/util`.\n  Public engine errors cross allowlisted sanitizers into a\n  `PlaybackDiagnostic`; `recommendPlaybackRecovery(context)` then ranks at\n  most three actions, and `WebPlayerViewComponent` executes only the action\n  the user selects. The policy is a sibling of `PlayerController`; shared\n  controls only gate interaction while the diagnostic panel is visible.\n  `WebPlayerViewComponent` owns a host-derived content-session key that is\n  stable for the mounted logical selection, attempted target IDs, the temporary\n  player override, and VOD handoff position. Its `PlaybackBinding` is exactly\n  `{ generation, target }`, while every source/target/reload application uses a\n  fieldless opaque `Symbol` token. Diagnostic storage uses a separate fieldless\n  intent `Symbol`, and source applications advance a third fieldless revision\n  `Symbol` that clears only the VOD handoff position; target-only switches and\n  Retry leave that revision stable. None of these ownership primitives contains\n  URLs, headers, DRM material, or credentials. The application effect\n  synchronizes the content session before tracking intent, so clearing a\n  temporary player override cannot schedule a duplicate application or header\n  handoff. Every application start clears both the diagnostic owner and backing\n  signal before asynchronous header setup; a current false result or rejection\n  leaves them clear, and a stale completion cannot erase a newer owned\n  diagnostic. Each\n  rendered web or Embedded MPV application captures its nullable binding, the\n  application and source-revision tokens, and live/VOD flag; a time update\n  changes resume state only while that exact capture still owns the current\n  application. A recommended built-in\n  player temporarily\n  outranks the host override and saved player for that mounted content session,\n  never mutates `Settings.player`, and resumes finite VOD position on a\n  best-effort basis; live playback returns to the live edge. Retry and\n  alternative sources preserve attempts, while a different content-session key\n  or component teardown resets them. Recovery recommendations never\n  auto-switch, persist history, learn across sessions, or emit telemetry.\n  The policy projects attempted inline target IDs through the validated\n  canonical source/target capabilities and excludes every attempted engine\n  family, so HTML5 and ArtPlayer are not separate hls.js recoveries. Network\n  and generic unknown evidence fail closed to Retry/alternative source; the\n  exact Shaka browser-unsupported preflight marker is the sole unknown-code\n  exception. PWA capability suppresses managed MPV/VLC, and ClearKey/KODIPROP\n  DRM suppresses external targets because its payload is not transferable. Raw\n  engine messages, arbitrary data, and credentials never enter recommendation\n  evidence or ownership state. MPV/VLC actions remain mounted after an attempt\n  and expose credential-free per-target launching/started/playing/error state;\n  only an exact Electron `playing` update is labelled Playing. One handshake is\n  allowed at a time. The renderer claims the credential-free content identity\n  before awaiting Electron, so primary Play is disabled and a launching or\n  closable-error alternative remains owned before the controller commits it.\n  Every route action that can start the same external playback, including\n  Restart and the provider-source shortcut, observes that local pre-IPC guard.\n  The Xtream VOD diagnostic-fallback handler records the same route-scoped\n  destination and pending generation before invoking MPV/VLC, so route reuse\n  cannot orphan that process outside the next route's close-before-play path.\n  Its fieldless intent is bound to the exact session returned\n  by the source owner's launch promise, so a late timed-out attempt cannot take\n  over a retry; later global updates must match that ID. A replacement waits for\n  confirmed teardown of the tracked external process, applies the old exact\n  close before launch, and cancels an unlaunched handoff if diagnostic ownership\n  changes. Process teardown has bounded graceful and forced confirmation\n  windows, and reusable MPV bounds the IPC command that precedes them; if any\n  stage cannot reach a confirmed exit, the exact session stays live and the\n  replacement fails closed instead of overlapping it. A process-wide teardown\n  gate starts before any potentially slow teardown preparation, including VLC\n  position flush and a reused player's protocol quit, and rejects every\n  MPV/VLC spawn until that exact child reports exit. If bounded\n  teardown fails while a fresh launch is still pending, that launch IPC rejects\n  and the exact session remains a closable error instead of hanging forever.\n  If a pre-content reuse failure has no still-live displaced session to restore,\n  the replacement error keeps its attached closer so Stop can retry the orphaned\n  child teardown. A terminal error without a closer is never restorable.\n  A failed close is single-flight only while its promise is pending: Stop can\n  retry the same exact child after a bounded confirmation failure. Reuse maps\n  the child to its current content session, so a stale older closer becomes a\n  no-op instead of terminating a newer `loadfile`/VLC enqueue handoff.\n  A duplicate close for an already closed session returns its terminal snapshot\n  without re-entering the saved closer, and a late process error cannot revive\n  that terminal session. Reused MPV commands are bound to the socket captured\n  for that exact child, so a later process cannot inherit a stale protocol quit.\n  Stop observed before a pending MPV content command or VLC enqueue command\n  prevents that command from dispatching. A source handoff fails closed while\n  a live session has no closer (`canClose: false`); renderer Dismiss is not\n  teardown confirmation. That denied handoff advances neither the multi-source\n  switch token nor the playback generation, so it cannot cancel the sole launch\n  already in flight.\n  VLC rechecks the gate at each concrete spawn after port allocation or reuse\n  work; if a post-start fallback is blocked there, the opened session becomes\n  an error rather than retaining a false started status. A failed RC-port\n  allocation never claims reuse ownership, so the fallback VLC child retains\n  its exact one-shot closer.\n  Reuse failures before a content command restore the globally displaced\n  renderer session, not the reusable process's prior owner, and only while the\n  exact displaced-session ID is still active; after\n  `loadfile`/VLC `clear` is dispatched,\n  the replacement owns the process and remains a closable error instead of\n  restoring stale content metadata. Stop during an in-flight MPV or VLC reuse\n  command, including during failed-command teardown or the subsequent VLC\n  fallback port-allocation wait, settles that exact close without falling\n  through to a fresh spawn;\n  a stopped VLC spawn error that reports only `close` also settles its original\n  launch IPC with the exact closed session;\n  a fresh fallback retires the old child's exit under its prior session so it\n  cannot close the replacement. Source handoffs recheck ownership after launch\n  and accept only `opened`/`playing`; a stale returned session is closed exactly\n  and a Stop-returned `closed` session is never committed. If that exact stale\n  close fails, its credential-free destination owner is retained for the next\n  close attempt. Retained destination ownership is scoped to the initiating\n  playlist/VOD route key, so route reuse cannot expose Stop for the previous\n  movie's external session. Play/Resume capture that route key before awaiting\n  close and cancel if navigation changes it; a late diagnostic fallback closes\n  its exact returned session instead of adopting it on the new route. They\n  supersede an older source resolution before awaiting the shared\n  close-before-replacement path, and accepting a diagnostic fallback retires\n  the same older resolution before opening MPV/VLC. They publish the route-source\n  badge, caption evidence, and position only after start succeeds.\n  Closable errors still participate in every replacement close and keep Stop as\n  the global dock's only teardown affordance; Dismiss is reserved for terminal\n  errors that have no closer. The shared `isLiveExternalPlayerSession` predicate\n  keeps M3U and series ownership while\n  such an error can still be stopped; consumers must not treat every `error`\n  status as terminal.\n  If the local handshake times out after an exact Electron session is known,\n  that ID remains\n  correlated so a later exact update can recover the UI. The global dock mirrors\n  those statuses, keeps closable errors visible until Stop confirms teardown and\n  terminal errors visible until dismissal, and intentionally has no retry because\n  it does not own the original launch headers or credentials.\n- The built-in HTML5/hls.js player is the second guarded consumer.\n  `HtmlVideoPlayerComponent` provides a component-scoped\n  `WebVideoControlsAdapter`; its neutral `web-video-support` bridge is shared\n  with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction,\n  caption preference, and source cleanup.\n  `HtmlVideoElementSession` owns native video-event lifecycle, persisted\n  volume, start-time/time/ended propagation, and legacy post-play caption\n  suppression.\n  `WebPlayerViewComponent.resolvedIsLive` supplies authoritative live/VOD\n  metadata, while a visible playback diagnostic disables both shared surface\n  interaction and shortcuts and exits the HTML5 shell's own fullscreen so the\n  diagnostic actions remain visible. The preference-off path keeps native\n  controls and legacy series navigation unchanged, while the playback keyboard\n  shortcuts (Space/K, F, arrow seek/volume, M) attach through\n  `LegacyPlayerShortcuts` with commands acting on the native video element\n  (`html-video-legacy-shortcuts.ts`); seek requires authoritative VOD metadata\n  plus a finite positive duration, and a visible diagnostic disables the keys.\n- Video.js is the third guarded consumer. `VjsPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; its bridge binds the current Tech\n  video, rebinds after `playerreset`, exposes source-stable audio/subtitle IDs,\n  preserves caption preference and explicit subtitle-off state, and reads\n  duration from Video.js. Reset-driven raw MPEG-TS changes pause first,\n  coalesce to the latest desired source, preserve actual volume across\n  Video.js's reset, and restart when authoritative live/VOD metadata changes.\n  The shared-controls path disables native controls, Video.js\n  click/double-click/hotkey actions, and spatial navigation;\n  diagnostic gating and owned-fullscreen exit match HTML5. The preference-off\n  path keeps the existing Video.js skin and legacy series navigation unchanged\n  (still without `userActions.hotkeys`), while the playback keyboard shortcuts\n  attach through `LegacyPlayerShortcuts` and drive the player API so the\n  vendor control bar stays in sync (`vjs-legacy-shortcuts.ts`).\n- ArtPlayer is the fourth guarded consumer. `ArtPlayerComponent` provides a\n  component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns\n  HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and\n  a destroyed-session guard for delayed `customType` callbacks, while\n  `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared mode uses\n  authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference,\n  MPEG-TS VOD duration correction, and reapplies app volume directly after\n  ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled,\n  and a transparent capture layer gives shared controls exclusive click and\n  double-click ownership. Diagnostic interaction gating and owned-fullscreen\n  exit match the other web players. The preference-off path keeps the legacy\n  ArtPlayer skin, source behavior, and series navigation unchanged, while the\n  playback keyboard shortcuts attach through `LegacyPlayerShortcuts` using the\n  vendor setters ArtPlayer's own hotkeys used\n  (`art-player-legacy-shortcuts.ts`); the legacy chrome passes `hotkey: false`\n  because ArtPlayer's focus-scoped hotkeys ignore `defaultPrevented` and would\n  double-handle every key, and the wiring restores its Escape-exits-web-\n  fullscreen behavior.\n- Shared web picture-in-picture stays inside that default-off rollout.\n  `PlayerController` exposes capability `pictureInPicture`, state\n  `pictureInPictureActive`/`canPictureInPicture`, and command\n  `togglePictureInPicture()`. HTML5, Video.js, and ArtPlayer use standard\n  element PiP from the adapter's attached video; shared ArtPlayer keeps vendor\n  `pip: false`, while preference-off native/vendor paths remain unchanged. The\n  capability-gated button sits before fullscreen and uses active enter/exit\n  semantics; entry is disabled until metadata, and the action is disabled while\n  an operation is pending. Embedded MPV reports capability/state false with a\n  no-op command and has no popup/mini-window.\n- `WebVideoControlsAdapter` supplies its current video and binding generation to\n  `WebVideoPictureInPictureController`; the controller reads the video's\n  `ownerDocument`, while browser enter/leave events remain authoritative.\n  Exact-owner exit stays available if request support changes. Request/exit\n  invocation remains synchronous for user activation, one operation is\n  serialized, and binding generation plus exact video identity protects\n  replacement and teardown from stale completion. Video.js Tech reset and\n  ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a\n  retained target preserve PiP.\n  Standard PiP shows the browser/OS video surface without Angular control\n  chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP\n  keyboard shortcut, and Embedded MPV popup/native support are out of scope.\n- Canonical docs: `docs/architecture/player-controls-contract.md` and\n  `docs/architecture/embedded-mpv-native.md`\n\n## Display Sleep During Playback\n\n- `PlaybackKeepAwakeService`\n  (`apps/web/src/app/services/playback-keep-awake.service.ts`) watches every\n  `<video>` via document-level capture listeners (media events don't bubble;\n  release listeners sit on the tracked element because Chromium's\n  removed-from-DOM pause never reaches the document) and, while any video is\n  playing and the document is visible (or the playing video is in\n  picture-in-picture — the PiP surface survives a minimized window), holds a\n  display-sleep lock.\n- Electron: a main-process `powerSaveBlocker` behind\n  `window.electron.setPlaybackKeepAwake`\n  (`apps/electron-backend/src/app/services/playback-keep-awake.service.ts`);\n  the renderer's vote is auto-cleared on renderer reload, crash\n  (`render-process-gone`), or destruction. PWA: the Screen Wake Lock API,\n  re-requested after browser auto-release; state changes masked by an\n  in-flight `request()` queue one re-evaluation on rejection.\n- Radio's `<audio>` deliberately never blocks display sleep. Embedded MPV\n  holds its own blocker in `EmbeddedMpvNativeService`; external MPV/VLC\n  inhibit the screensaver themselves.\n\n## Linux Embedded MPV Packaging\n\n- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM,\n  Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain\n  marker-only and must never inherit x64 native artifacts from environment\n  overrides.\n- Packaging runs three isolated profiles:\n    - `system`: DEB/RPM/Pacman, no private `native/lib`, with package\n      dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`,\n      RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and\n      Pacman=`mpv,libglvnd,mesa`\n    - `portable`: AppImage/Snap with the pinned LGPL-compatible closure\n    - `flatpak`: Flatpak with the same pinned closure\n- Flatpak is an isolated packaging pass and keeps `iptvnator` as the real\n  Electron ELF so Electron Builder's `electron-wrapper` passes it directly to\n  Zypak. Other Linux targets retain the conditional `iptvnator` wrapper and\n  `iptvnator.bin`. Mixed Flatpak/non-Flatpak target sets fail before mutation.\n- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04\n  provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the\n  package dependency or advertising frame-copy without a compatible runtime.\n- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable,\n  Electron libraries, `embedded_mpv.node`, and\n  `embedded_mpv_frame_reader.node` must not load or link it. Preserve this\n  process-isolation contract in build, package, and smoke checks.\n- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack`\n  exclusively writes the profile-normalized unpacked native tree. Layout and\n  final-artifact checks must reject every archived\n  `/electron-backend/native/**` entry so system and marker-only packages cannot\n  hide stale x64 artifacts.\n- Packaged addon, frame-reader, and helper discovery is package-owned\n  `app.asar.unpacked` only. Writable cwd/dist candidates are development-only\n  and must never satisfy packaged native-view support or the frame-copy gate.\n- Pristine afterPack/unpacked layouts scan Electron libraries recursively.\n  Extracted Snap payloads exclude only the package-manager `lib/**` and\n  `usr/lib/**` trees that Snap overlays into the same root; every other\n  directory remains recursive, and Electron-library symlinks still fail\n  closed.\n- Linux frame-copy availability is fail-closed. The packaged manifest,\n  artifact modes, declared bundled hashes/closure, and bounded\n  `--runtime-probe` must all succeed before frame-copy can relax the renderer\n  sandbox. Any failure reports a stable reason and falls back to native-view\n  without crashing; an environment flag never bypasses this gate.\n- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus\n  the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`,\n  with `mesa-core22` as default provider. It declares only the canonical\n  provider layouts: `/usr/share/libdrm` binds from\n  `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to\n  `$SNAP/graphics/drirc.d`. The provider is external shared content, not part\n  of IPTVnator's package size, source archive, or notices. Installed-Snap CI\n  must prove controlled unavailable exit after disconnect, then reconnect and\n  prove success. Static artifact verification requires regular\n  `desktop-init.sh`, `desktop-common.sh`, and `desktop-gnome-specific.sh`\n  files at the Snap root, with `desktop-init.sh` executable. The helper links\n  `libGL.so.1` rather than `libOpenGL.so.0`.\n- The probe and playback helper share one sanitized loader environment:\n  ambient audit, preload, library, graphics-driver, and shell-startup overrides\n  are removed; the validated private closure wins; trusted Snap GL,\n  `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots\n  precede generic in-snap roots. The core22 base must precede GNOME so its\n  `libedit.so.2` cannot be replaced by the older copy requiring\n  `libtinfo.so.5`. The extracted-artifact verifier removes the identical\n  unsafe loader/graphics/shell set before direct helper smoke while preserving\n  feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the\n  wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches\n  probe/playback through the regular executable\n  `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or\n  disconnected provider returns `snap-graphics-provider-unavailable` before\n  helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch\n  runs the complete cached manifest/hash/helper gate before BrowserWindow\n  startup and exits with one availability JSON line. A nonzero helper exit\n  keeps top-level reason `helper-probe-failed`; `helperReason` is present only\n  for an exact protocol-v1 line carrying a fixed allowlisted reason, and its\n  optional `helperDetail` must be 1–1024 printable ASCII characters. Invalid\n  detail suppresses both helper fields. Every probe uses an explicit 16 MiB\n  aggregate captured-output ceiling independent of tracing. With\n  `IPTVNATOR_TRACE_PLAYER=1`, a non-empty helper stderr capture is emitted\n  separately as one JSON-escaped stderr line whose `stderr` field is limited\n  to 16,384 characters and whose `truncated` field is always explicit;\n  trace-write failure cannot change the capability result. Installed-Snap CI\n  enables Mesa EGL/GL diagnostics through this bounded channel. Any loader\n  failure remains a stable native-view fallback, never a flag-enabled success.\n- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop\n  Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL\n  extension loader path comes from the sandbox cache. Flatpak CI must invoke\n  the application-level `--embedded-mpv-runtime-probe`, not a direct helper\n  probe that bypasses capability detection.\n- The packaged x64 Playwright smoke runs its fixture-contract target first and\n  passes Chromium `--ignore-gpu-blocklist` so CI llvmpipe can expose WebGL2.\n  This launch-only flag does not bypass the manifest, hash, loader, or helper\n  capability gate; `--no-sandbox` remains root-only.\n- Bundled Linux releases must publish the exact source archives/git records,\n  checksums, licenses, flags, patches, build scripts, and the pinned hwdata\n  `pnp.ids` input. Each bundled package carries\n  `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact\n  `licenses/**` files. CI may cache immutable source inputs, but regenerates\n  notices and a VCS-metadata-free\n  `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every\n  run while retaining the exact pinned six recursive libplacebo submodule\n  records. Each record is canonical `full-commit safe/path`; clone-depth\n  dependent `git describe` annotations are discarded and never form part of\n  the provenance identity. Its source index carries the globally sorted libplacebo\n  directory/file/symlink inventory; file hashes, sizes, executable bits, link\n  targets, aggregates, and canonical tree digest must match the trusted pinned\n  checkout. The archive has an exact member/type layout and its\n  `metadata/archive-sha256.txt` records must match the actual source archives.\n  Concatenated tar/xz streams are inspected past every end marker. The final\n  archive's SHA-256 and repository revision are copied into every bundled x64\n  package manifest; system and marker-only packages carry no source-archive\n  binding.\n  Automated Snap Store publication is allowed only after a public `v*` GitHub\n  release contains both the Snap assets and exactly one matching source\n  archive. Before any upload, the workflow hashes and inspects that archive,\n  verifies its exact member/type set and size bounds, clean tag revision,\n  pinned sources including the six recursive submodule records and exact\n  libplacebo tree digest, legal files, and exact released tooling, then\n  performs bounded extraction and static package validation for every Snap.\n  That public-release boundary independently revalidates the exact strict\n  `meta/snap.yaml` graphics/shared-memory contract and enumerates\n  `resources/app.asar`, rejecting any archived\n  `electron-backend/native/**` payload before publication. Its bounded ASAR\n  header reader uses only Node built-ins and released local tooling, so the\n  clean tag checkout does not require `node_modules`.\n  Exactly one x64 Snap must have matching\n  `sourceArchive` and `sourceRuntime`; any non-x64 Snap must remain\n  marker-only. Checkout and the artifact-transfer actions are pinned to full\n  commits; checkout does not persist credentials, and repository credentials\n  are limited to download steps. A secretless verification job copies assets\n  through no-follow descriptors, checks pre/post hashes, writes an exact\n  receipt, repeats the complete source/package verification on a root-owned\n  read-only snapshot, and transfers only that data through the pinned artifact\n  service while its receipt digest travels separately through a job output.\n  The dependent publish job runs on a bounded `ubuntu-latest` runner with no\n  checkout or release-tag code, verifies that digest plus the exact receipt,\n  asset hashes, and file-only layout, root-seals the data again, and installs\n  Snapcraft directly. Store credentials exist only in its final fixed shell\n  step, which resolves no PATH command, executes no released code, and exposes\n  the credential only to each exact\n  `/snap/bin/snapcraft upload --release=edge` process.\n  Candidate/stable promotion is manual after installed-Snap frame-copy and\n  missing-runtime fallback smoke; GitHub Actions never promotes automatically.\n  Canonical maintenance docs:\n  `docs/architecture/embedded-mpv-native.md` and\n  `tools/embedded-mpv/README.md`.\n\n## Repo Skills\n\n- `.codex/skills/iptvnator-nx-architecture/SKILL.md`\n- `.codex/skills/iptvnator-sqlite-db-worker/SKILL.md`\n- `.codex/skills/iptvnator-theme-style/SKILL.md`\n- `.codex/skills/iptvnator-ui-design/SKILL.md`\n- `.codex/skills/release-cut/SKILL.md`\n- `.codex/skills/release-notes/SKILL.md`\n- `.codex/skills/stalker-portal/SKILL.md`\n- `.codex/skills/xtream-electron/SKILL.md`\n\nDescriptions and trigger conditions are canonical in each skill's frontmatter;\ndo not duplicate them here.\n\n<!-- nx configuration start-->\n<!-- Leave the start & end comments to automatically receive updates. -->\n\n## General Guidelines for working with Nx\n\n- 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.\n- 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\n- Prefix nx commands with the workspace's package manager (e.g., `pnpm nx build`, `npm exec nx test`) - avoids using globally installed CLI\n- You have access to the Nx MCP server and its tools, use them to help the user\n- For Nx plugin best practices, check `node_modules/@nx/<plugin>/PLUGIN.md`. Not all plugins have this file - proceed without it if unavailable.\n- NEVER guess CLI flags - always check nx_docs or `--help` first when unsure\n\n## Scaffolding & Generators\n\n- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the `nx-generate` skill FIRST before exploring or calling MCP tools\n\n## When to use nx_docs\n\n- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases\n- DON'T USE for: basic generator syntax (`nx g @nx/react:app`), standard commands, things you already know\n- The `nx-generate` skill handles generator discovery internally - don't call nx_docs just to look up generator syntax\n\n<!-- nx configuration end-->\n","category":"root","tokens":12273}]}