# AGENTS.md This file provides guidance to coding agents working in this repository. ## Plan Mode - When an agent is in Plan Mode and produces a final ``, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory. - Save only finalized plans. Do not write interim exploration, questions, or draft revisions to `.plans/`. - Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`. - If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on. ## Agent Bootstrap - In a fresh worktree, run `pnpm install --frozen-lockfile` before relying on Nx project discovery, lint, test, or build commands. Without `node_modules`, `pnpm nx show projects` will fail because the local Nx modules are unavailable. - Re-run the install whenever the checkout moves — `git pull`, `git reset --hard`, a rebase, or a worktree branch being re-pointed. Git rewrites `pnpm-lock.yaml` but never re-links `node_modules`, so a tree installed at an older commit keeps serving the old dependency versions and tests fail locally while CI stays green. Check with `cmp pnpm-lock.yaml node_modules/.pnpm/lock.yaml`; any difference means the tree is stale, and a plain `pnpm install --frozen-lockfile` in that directory repairs it. Each worktree needs its own install — with no local `node_modules`, Nx aborts with `Could not find ".modules.yaml"`. - After dependencies are installed, verify workspace discovery with `pnpm nx show projects`. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json` so `@nx/enforce-module-boundaries` remains useful for humans and agents. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. - Use the Node version in `.nvmrc` for development and CI. Angular 22 requires Node `^22.22.3 || ^24.15.0` and TypeScript `>=6.0 <6.1` in this workspace. - Vite `8.1.5`, resolved through Angular's build tooling, retains upstream precise matchers and adds bounded raw-code prefilters through `patches/vite@8.1.5.patch`. Keep the patch until upstream also preserves comment-bearing asset/worker expressions; run `pnpm run deps:vite:test` after related dependency updates. - `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in `patches/app-builder-lib@26.15.7.patch` with the upstream backport electron-userland/electron-builder#10172: `security set-key-partition-list -k` must receive the temporary keychain's own password, not the `.p12` import password. macOS runner images since `macos-26-arm64` 20260831 verify that password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user name or passphrase you entered is not correct`. Keep the patch until electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+), and run `pnpm run deps:electron-builder:test` after related dependency updates — the test fails when the patched version no longer matches the installed one. - `nx-electron@22.0.0` uses a local Nx 23 export-path patch and an explicit `webpack-node-externals` package extension. Scoped peer allowances for it and `ngx-indexed-db@22.0.0` live in `pnpm-workspace.yaml`; they are project compatibility bridges, not upstream support declarations. See `docs/architecture/nx-workspace-boundaries.md` before removing them. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task hash — edits then return a cache hit instead of rebuilding. Shared partials live in `libs/ui/styles` (project `ui-styles`), and each consumer declares `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate` after adding a cross-project stylesheet import. - Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the lockfile, run generated migrations when present, and validate before opening a PR. Major updates are always manual. Replace incomplete Dependabot security PRs with a coordinated update instead of editing the bot branch. - ESLint enforces `max-lines` on TypeScript files: production code targets under 300 with a hard maximum of 400, while tests (`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 — a long spec signals coverage, not the design debt the production limit catches. Blank lines and comments are not counted, so a docblock never forces a split. Limits live in `tools/eslint/max-lines-config.mjs`, imported by both `eslint.config.mjs` and the generator so the rule and the baseline cannot drift. Files that predate the rule are baselined in `tools/eslint/max-lines-baseline.mjs`; after splitting a file, regenerate it with `node tools/eslint/generate-max-lines-baseline.mjs` (it runs ESLint's own rule rather than counting lines itself). Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. Remove such a directive once ESLint reports it as unused. - Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. - Repository-specific skills live under `.codex/skills/`. - Frontmatter descriptions are trigger-only and begin with `Use when`; keep each skill at or below 500 words. - Run `pnpm run skills:validate` after editing a committed skill or a literal path it documents. - Keep `.codex` and `.claude` copies of `release-notes` and `release-cut` byte-identical. ## Documentation After Changes - After implementing a meaningful change, agents must assess whether canonical repo docs need updates before considering the task complete. - Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries. - Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes. - Prefer updating an existing authoritative doc before creating a new one: 1. `README.md` for top-level developer or user workflows 2. `docs/architecture/` for architecture, ownership, and behavior contracts 3. the nearest module `README.md` for local usage or behavior - Keep the root `CLAUDE.md` and this file up to date. They are living documents: whenever a change touches something they describe — monorepo structure (new/moved/renamed apps or libs), routes, database schema/tables, stores and their features, key components, commands, environment behavior, or coding conventions — update the affected sections as part of the same task, and keep the process sections mirrored between `AGENTS.md` and `CLAUDE.md` in sync. - When adding a new feature area, check whether the Architecture or Key Features sections of `CLAUDE.md` describe the surrounding area; if they do, reflect the addition there instead of leaving the description stale. - Do not let `CLAUDE.md` or `AGENTS.md` drift: a stale path or route in these files poisons the context of every future agent session. If you notice an outdated claim while working, fix it (or flag it in the final summary) even if it is unrelated to the current task. - Repo docs are canonical even when they were originally drafted by an LLM. - Final task summaries should state whether docs were updated and which doc changed. ## Release Notes For User-Visible Changes - Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. - `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, become ready-made blog section headings, and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. - Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. - Final task summaries should state whether a release note was added or why it was skipped. ## AppImage Manager Metadata AppManager full-download discovery uses `appImage.desktop.entry` URL fields. Electron Builder generates the version; `extraMetadata.desktopName=iptvnator` preserves Linux window identity without a shared `linux.desktop.entry` object (builder's nested merge would leak AppImage fields into Snap). This does not enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` (AppImage external-manager metadata). ## Upgrade And Migration Compatibility - Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version. - Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception. - Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns. - For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores. - See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance. ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. - Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed. - Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite. - Default validation ladder: 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader. 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior. 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear. 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`. - Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below. - Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead. ## Legacy Desktop Profile Migration `electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend` profile before eager main-process imports only when current Chromium storage is unused. Existing profiles retain their settings and offer explicit recovery of missing sources from a disposable legacy snapshot. Playlist rows and a completion receipt commit atomically in the DB worker; original IndexedDB is retained, current payload rows are preserved, and completed imports never replay deleted sources. Contract and recovery limits: `docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles). Startup shows `AppStartupStatusComponent` until the initial route and source inventory are ready, including XMLTV reconciliation. Inventory reads retry once; failed reads show an explicit Retry action instead of an empty library. Successful inventory reads first await settings loading, then pending XMLTV reconciliation, and retry failed cleanup with its last committed URLs before exposing the workspace. See the same contract for startup readiness and error handling. ## Electron Debugging (CDP) - Start the Electron development app with: `nx serve electron-backend` - Package-script equivalent: `pnpm run serve:backend` - Electron is configured to start with: `--remote-debugging-port=9222` - Connect Chrome DevTools Protocol tools to: `127.0.0.1:9222` - For Electron automation/debugging tasks, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window. Symptoms: `tab list` shows `about:blank`, snapshots are empty, and screenshots are black. - If that happens, inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the IPTVnator page websocket from the `webSocketDebuggerUrl` field. - The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. ### Trace / Debug Startup - Full startup tracing: ```bash IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend ``` - Narrower trace flags: - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset - Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching `console.*`; never log raw credentials while debugging. - If local Nx state gets weird before a rerun: ```bash pnpm nx reset ``` ### agent-browser (global install) ```bash agent-browser --cdp 9222 tab list agent-browser --cdp 9222 tab 1 agent-browser --cdp 9222 snapshot -i -c -d 4 agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png ``` ### Fallback ```bash npx --yes agent-browser --cdp 9222 tab list ``` ### DevTools Workaround ```bash ELECTRON_OPEN_DEVTOOLS=1 nx serve electron-backend curl http://127.0.0.1:9222/json/list agent-browser connect ws://127.0.0.1:9222/devtools/page/ agent-browser screenshot /tmp/iptvnator-cdp.png ``` ## Xtream Category Management The Electron Live TV, Movies, and Series category dialog applies Select/Deselect to search results while a filter is active and to the whole type otherwise. Button states use the matching group; "Total selected" counts the whole catalog. Save persists the complete draft, Close discards it, and refresh restores hidden categories by provider ID and type. See `docs/architecture/category-management.md`. ## XMLTV Response Compression Electron decodes HTTP compression before the gzip file layer. For `.gz`/gzip metadata plus HTTP gzip, a streaming signature check unwraps one remaining file layer while preserving single-layer providers. Errors and cancellation close the decoding chain. Contract: `docs/architecture/m3u-playlist-module.md` ("XMLTV response compression"). ## XMLTV Source Removal Saving Settings → EPG reconciles cached XMLTV with committed global URLs and all enabled M3U playlist sources. Startup runs the same reconciliation after settings load and playlist migration. Ordinary saves skip unchanged normalized source sets; an explicitly edited EPG field can retry a failed cleanup. A cleanup failure after persistence still mirrors committed settings to Electron; the form stays dirty for retry. Failed storage writes never mirror to main. Failed settings reads and incomplete playlist migration never authorize pruning. Removed sources retire queued/running imports and dismiss retained error rows before worker-owned deletion. Retry waits for reconciliation and rechecks its error row, including after trust-setting writes. Shared channel IDs survive while another source has programmes or per-source channel metadata. The additive `epg_channel_sources` table preserves each imported source's name, logo, URL and timestamp plus transaction-ordered `write_order`, so removal restores the latest surviving snapshot even when import timestamps tie; ambiguous legacy metadata falls back to the XMLTV ID until reimport. Manual mappings remain user preferences, but no longer resolve deleted data. Renderer lookup generations, Xtream previews and Stalker mapping-cache invalidation prevent late results from restoring removed programmes. Provider EPG is independent. See `docs/architecture/m3u-playlist-module.md` ("XMLTV source lifecycle"). ## Web Backend Provider Redirects All four provider proxy routes use `ValidatedHttpClient`: automatic redirects are disabled, the initial URL and at most five redirect hops pass full URL/DNS validation, and fresh agents pin each connection to that hop's validated IPs. Host/SNI and TLS verification remain intact; outbound environment proxies are disabled. Private-network opt-in applies to the chain. Cross-origin redirects strip session headers; original query params are not replayed. One portal admission owns the entire chain and final body, with explicit redirect evidence preventing destination failures from penalizing the initial endpoint. Contracts: `docs/architecture/pwa-self-hosted.md` and `docs/architecture/host-connectivity-guard.md`. ## Portal Connectivity Preference - Half-open trial slots follow the complete request lifetime with no elapsed-time expiry. All four Electron/web-backend portal handlers release in `finally`, independently of outcome reporting; cleanup preserves trial/epoch ownership and works while the environment override is disabled. Contract: `docs/architecture/host-connectivity-guard.md` ("Trial ownership follows the request lifetime"). - Desktop Settings > General > Portal connections exposes default-on `Settings.portalConnectivityGuard`. Only explicit false opts out. Save mirrors the value to Electron `PORTAL_CONNECTIVITY_GUARD` and applies it without restart; settings bootstrap restores it before the renderer loads. It controls Xtream and Stalker together. Preference transitions clear cooldowns and invalidate old request completions; unchanged saves preserve evidence. The environment switch `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1` remains authoritative. PWA clients do not control the shared backend's guard. - Both account-info dialogs explain guard refusals with localized paused-request copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. ## Live Channel Return Xtream and Stalker (including radio) capture displayed playback order on explicit selection. Remote up/down, numbers and status use that queue while browsing categories or search. Stalker commits after successful current URL resolution and extends only loaded pages of the original scope. The conditional channel header action clears search, returns to the accessible playing category and focuses its row without changing playback. Contract: `docs/architecture/remote-control.md` (Live channel return and playback order). ## Stalker Live Search ITV sidebar and fullscreen searches independently filter the complete selected category; only All Items searches the whole public catalog. Cached categories search before windowing; missing/censored genres keep provider pagination, including automatic continuation for short or empty search results. ITV search never narrows shared provider pages or resets their index. Category changes reset list windows and retain playback/active EPG. Contract: `docs/architecture/stalker-portal.md` (Full ITV Channel List Cache). ## Live TV Panel Levels Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels from the outside in, in three nested levels owned by `LiveSidebarState` (`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail + player), `categories-hidden` (channels rail + player) and `collapsed` (player only). `LiveLayoutSidebarStateService` is the single source of truth, per surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the shell context sidebar folds the categories rail on `areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has a selected category — the live root has no channels header to host the way back — and always at level 3), the channels rail folds on `isCollapsedFor('portal')`. While the rail is folded the channels header turns its title into a category dropdown that opens the same `WorkspaceContextPanelComponent` as a CDK popover through the `LIVE_CATEGORIES_POPOVER` token: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`, closed by backdrop, Escape, selection, its footer and any `NavigationStart`), the live layouts reach it through `createLivePanelsController()` (level flags, dropdown bridge and focus handoff in one shared object; the token is optional). `Cmd/Ctrl+B`, the header toggle and the floating restore handle return to the level the user collapsed from (the target is session-only; every level is restored as stored per surface). Folded rails carry `inert`, and `handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the replacement affordance only when the activated button was removed or inerted. M3U and the unified live tab have no categories rail and treat level 2 like level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar"). ## Channel and Detail Keyboard Scrolling Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection focuses the viewport, native scrolling survives virtual row recycling, and row Enter/Space activation stays separate from focus movement. Portal Live TV uses ArrowRight from the selected category and ArrowLeft from the channels pane to move between columns. Shared live sidebars reserve scrollbar space beside the resize handle. `PortalDetailShellComponent` owns a visible native scrollbar and guarded initial page focus. Its sticky control and Escape close inline playback to browse, then invoke the host's existing Back action; the now-playing bar retains its separate direct route Back. Browse Escape requires focus inside the shell; watch preserves the global close shortcut. Menus, dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain their keys. M3U and collection bootstrap shells set `backAvailable=false` when there is no browse return action. Contracts: `docs/architecture/iptvnator-ui-guidelines.md` and `docs/architecture/portal-detail-navigation.md`. ## Xtream Connection Test Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before the click and can replace an unavailable HTTPS base with a verified active HTTP base in the form. Only initial refused-port or TLS wrong-version evidence permits the same-host attempt; HTTP errors, certificate failures and redirect failures do not. Add/Save persists `serverUrl`, and the routed session observes the metadata change. Passive checks never change the protocol. Separate XMLTV and already-issued media/download URLs stay independent. Contract: `docs/architecture/xtream-portal-compatibility.md` ("Explicit protocol discovery"). ## Xtream Live Auto Format The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit HLS+TS account evidence, using the canonical URL builder and original headers. The same web player may try TS once after an owned initial terminal HTTP failure, before `playing`; the old transport unmounts before the guarded render callback starts TS. No player preference or playlist cache changes. Manual formats, unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without a terminal diagnostic also need manual TS. Contract and full support matrix: `docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure). ## Xtream Catch-Up Server Timezone The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS timezone (`server_info.timezone`), never the viewer's (issue #1562). `withPortal.checkPortalStatus()` normalizes it with `resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock pair) and persists it on the playlist row through `IXtreamDataSource.rememberServerTimezone` — Electron: one conditional `json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because the Favorites / Recent resolver reads the STORED row, not the store, and the worker interleaves requests, so no read may precede the write. `DB_GET_PLAYLIST` projects it back from the row payload, and a server URL change drops it until the next account-info check. The same value converts timestamp-less EPG `start`/`end` strings. Contract: `docs/architecture/xtream-portal-compatibility.md` ("Start time is the panel's clock, not the viewer's"). ## Radio / Audio Player M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected: - The dedicated `AudioPlayerComponent` (`libs/ui/playback/src/lib/audio-player/`) renders instead of a video player - The audio player always uses the built-in inline player — external player settings (MPV/VLC) are ignored - The EPG panel is hidden (radio streams have no EPG data) - The layout uses a cinematic hero pattern: the station logo is blurred as a full-area backdrop with a vignette overlay, and the artwork card + controls float above it - Volume is shared with the video player via `localStorage` key `'volume'` - Keyboard shortcuts: ArrowUp/ArrowDown (volume +/-5%), M (mute toggle) - Radio detection in the video player template: `activeChannel.radio === 'true'` — this is a string comparison, not boolean Key files: - `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` — the audio player component - `libs/ui/playback/src/lib/audio-player/audio-player.component.scss` — cinematic hero styling - `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html` — template conditionals for radio vs video - `libs/shared/interfaces/src/lib/channel.interface.ts` — `radio: string` field on Channel interface ## M3U Playback Mode `isLikelyM3uVod` in `libs/shared/m3u-utils` recognizes video-file extensions and exact `/movie/`, `/movies/`, `/vod/`, `/series/` URL pathname segments, independently of TMDB and `Settings.m3uVodDetails`. The M3U host's `embeddedPlayback()` sets `isLive: false` for those entries or a catch-up URL; the movie detail host forwards the same payload. Movie metadata recognition still excludes episodes. Ordinary HLS/TS and unknown URLs without VOD evidence, DASH and radio retain their existing behavior. Actual seeking requires source support. Xtream/Stalker, external MPV/VLC payloads and session identity are unchanged. Contract: `docs/architecture/m3u-playlist-module.md` (M3U Playback Mode). ## M3U URL User-Agent - `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a route opened during refresh reads after its pending save. Mutation-internal reads keep using `getPlaylistById()` directly to avoid queue re-entry. - The URL import form accepts an optional User-Agent and stores it as `Playlist.userAgent`. Electron sends it on initial download, manual refresh, and startup auto-update. The self-hosted PWA sends it through the registered target `/parse` backend proxy for import and refresh; a matching backend is required, and browser playback-header restrictions still apply. - Reuse the existing source editor and channel-over-playlist playback header precedence. Contract: `docs/architecture/m3u-playlist-module.md` ("User-Agent for URL sources"). ## Shared Player Controls - Stream info popover: an `info` button in the top-right corner of the shared controls overlay shows live stream data — resolution + aspect ratio, measured frame rate, video/audio codec and bitrate, audio channels and sample rate, container, buffer, and dropped frames. Rendered only when the engine reports something (`capabilities.streamStats`), sampled once a second and only while the popover is open. Web engines read the `