# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. > The process sections below (Plan Mode, Documentation After Changes, Upgrade And Migration Compatibility, Regression Prevention, Agent Bootstrap, Electron CDP Debugging) are mirrored in `AGENTS.md`, which is the canonical copy for agent workflows. When updating one, keep the other in sync. ## Plan Mode - When Claude Code is in Plan Mode and produces a final ``, it must also save that finalized plan as a Markdown file in the repo-root `.plans/` directory. - Save only finalized plans. Do not write interim exploration, question turns, or draft revisions to `.plans/`. - Use the filename pattern `YYYY-MM-DD-short-topic.md` such as `.plans/2026-03-12-channel-filtering.md`. - If the intended filename already exists, append a numeric suffix such as `-2`, `-3`, and so on. ## Documentation After Changes - After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete. - Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries. - Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes. - Prefer updating an existing authoritative doc before creating a new one: 1. `README.md` for top-level developer or user workflows 2. `docs/architecture/` for architecture, ownership, and behavior contracts 3. the nearest module `README.md` for local usage or behavior - Keep this file (`CLAUDE.md`) itself up to date. It is a living document: whenever a change touches something it describes — monorepo structure (new/moved/renamed apps or libs), routes, database schema/tables, stores and their features, key components, commands, environment behavior, or coding conventions — update the affected `CLAUDE.md` sections as part of the same task, and keep the mirrored process sections in `AGENTS.md` in sync. - When adding a new feature area, check whether the Architecture or Key Features sections of `CLAUDE.md` describe the surrounding area; if they do, reflect the addition there instead of leaving the description stale. - Do not let `CLAUDE.md` drift: a stale path or route in this file poisons the context of every future agent session. If you notice an outdated claim while working, fix it (or flag it in the final summary) even if it is unrelated to the current task. - Repo docs are canonical even when they were originally drafted by an LLM. - Final task summaries should state whether docs were updated and which doc changed. ## Release Notes For User-Visible Changes - Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under `.changes/` in the same PR. Format, field table, and writing rules: `.changes/README.md`. - Name it `-.md`; `area` matches the conventional-commit scope. There is no version field — the release version is chosen at release time. - Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post. - `type: internal` records invisible maintenance. Internal notes stay collapsed in `CHANGELOG.md`, are omitted from the blog scaffold, and are removed from the authored public GitHub body by `extract-changelog-section.mjs --public`; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body. - `highlight: ` (max 60 characters, rejected on `type: internal`) marks a note as one of the release's two or three headline changes. Highlights lead the Telegram/Reddit announcement drafts, open the blog scaffold (a "What changed" table row plus a leading `##` section each, while the remaining features fold into themed sections and non-highlighted fixes collapse under a spoiler — `tools/release/release-notes-blog.mjs`), and are the input the highlight-card generator renders from. A release where everything is a highlight has none. - Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches `apps/**` or `libs/**`, apply the `no-release-note` label. - CI enforces this: the "Release note gate" job in `.github/workflows/ci.yml` fails PRs that change runtime code without an added `.changes/*.md` or the label (policy in `tools/release/check-release-note-gate.mjs`; tests/e2e/website/mock-server/docs paths are auto-exempt). - The `release-notes` skill covers writing notes; the `release-cut` skill covers the full release sequence. Canonical contract — surfaces, ordering constraints, the required draft asset set: `docs/architecture/release-pipeline.md`. - Validate before finishing: `pnpm run release:notes:validate`. - Announcement drafts and highlight cards are built from the same notes: `pnpm --silent run release:notes:telegram` and `pnpm --silent run release:notes:reddit` print paste-ready posts to stdout (Telegram is guaranteed to fit its 4096-character limit; `--silent` keeps pnpm's lifecycle banner out of a redirected post), and `pnpm run release:cards:generate` renders branded 1200×630 highlight cards plus a release hero into `dist/release-highlight-cards/v/`. All three read `highlight:` metadata that exists only in the note files, so they must run before `build-release-notes.mjs --consume`; the cards additionally need `release:screenshots` to have run. Nothing is posted or copied into the website tree automatically. - Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release. - `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication. - Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual. - Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`. A manifest shot may carry `browser: {url, viewport}` to frame a loopback page the app serves (the remote-control phone view) in a separate mobile-sized Chromium instead of the Electron window, behind the same network and content guards; the Xtream mock's `marketing`/`marketing2` scenarios serve movie, episode and live stream URLs from local bytes so download and playback shots never leave the machine. - Final task summaries should state whether a release note was added or why it was skipped. ## AppImage Manager Metadata AppManager full-download discovery uses `appImage.desktop.entry` URL fields. Electron Builder generates the version; `extraMetadata.desktopName=iptvnator` preserves Linux window identity without a shared `linux.desktop.entry` object (builder's nested merge would leak AppImage fields into Snap). This does not enable AppImageUpdate/zsync. Contract: `docs/architecture/release-pipeline.md` (AppImage external-manager metadata). ## Upgrade And Migration Compatibility - Users may skip releases. The application must apply all required migrations in dependency order when upgrading directly from an older release; never assume that users installed or launched every intermediate version. - Preserve migration paths for existing persisted data. Do not make deleting a database/profile or reinstalling the application a normal upgrade requirement. Any unavoidable intermediate-version requirement must be an explicitly documented exception. - Create required tables first, add missing columns before dependent indexes/triggers/queries, and make startup migrations safe to run again. `CREATE TABLE IF NOT EXISTS` does not update an existing table's columns. - For persistence changes, test real SQLite initialization with representative historical schemas and data, including skipped releases, the previous release, a fresh database, and repeated startup. Assert preservation of user data as well as the resulting schema; SQL mocks alone cannot verify upgrade compatibility. Cover equivalent persisted-state transitions for non-SQLite stores. - See `libs/shared/database/README.md` for SQLite migration ownership and validation guidance. ## Regression Prevention And Test Updates - Before the final summary for any feature, behavior change, bug fix, data-flow change, Electron IPC/database change, or user-visible UI workflow change, Claude Code must complete a test impact pass. Identify the affected projects and decide whether unit, integration, E2E, build, lint, or manual/CDP verification is required. - Bug fixes must normally include regression coverage that fails on the old behavior and passes with the fix. If automated coverage is not practical, document why in the final summary and include the strongest manual validation performed. - Feature work and behavior changes must update existing tests when assertions, fixtures, mocks, routes, or E2E flows are now stale, incomplete, or missing. Prefer extending the closest existing spec or E2E file before adding a new suite. - Default validation ladder: 1. Run targeted unit tests for directly affected projects with `pnpm nx test ` or existing scripts such as `pnpm run test:frontend`, `pnpm run test:backend`, or `pnpm run test:unit:ci` when the scope is broader. 2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior. 3. Use `pnpm nx show projects --withTarget test` and `pnpm nx show projects --withTarget e2e` when project ownership or available validation targets are unclear. 4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example `pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts` or `pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts`. - Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with `agent-browser` and the tracing flags documented below. - Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead. ## Project Overview IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals. **Dual Environment Support**: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts. ## Development Commands ### Agent Bootstrap ```bash pnpm install --frozen-lockfile pnpm nx show projects ``` - Run the install step in a fresh worktree before relying on Nx discovery, lint, test, or build commands. Without `node_modules`, local Nx modules are unavailable. - Re-run the install whenever the checkout moves — `git pull`, `git reset --hard`, a rebase, or a worktree branch being re-pointed. Git rewrites `pnpm-lock.yaml` but never re-links `node_modules`, so a tree installed at an older commit keeps serving the old dependency versions and tests fail locally while CI stays green. Check with `cmp pnpm-lock.yaml node_modules/.pnpm/lock.yaml`; any difference means the tree is stale, and a plain `pnpm install --frozen-lockfile` in that directory repairs it. Each worktree needs its own install — with no local `node_modules`, Nx aborts with `Could not find ".modules.yaml"`. - Use scoped path aliases from `tsconfig.base.json` such as `@iptvnator/services`, `@iptvnator/shared/interfaces`, and `@iptvnator/ui/components`. - Do not add new imports from legacy bare aliases such as `services`, `shared-interfaces`, `components`, `m3u-state`, or `database`. - Every Nx project should keep `scope:*`, `domain:*`, and `type:*` tags in `project.json`. - See `docs/architecture/nx-workspace-boundaries.md` for the current Nx tag and alias policy. - Keep `nx` and every official `@nx/*` package on the same exact version; run `pnpm run deps:nx:validate` after dependency updates. - Use the Node version in `.nvmrc` for development and CI. Angular 22 requires Node `^22.22.3 || ^24.15.0` and TypeScript `>=6.0 <6.1` in this workspace. - Vite `8.1.5`, resolved through Angular's build tooling, retains upstream precise matchers and adds bounded raw-code prefilters through `patches/vite@8.1.5.patch`. Keep the patch until upstream also preserves comment-bearing asset/worker expressions; run `pnpm run deps:vite:test` after related dependency updates. - `app-builder-lib` `26.15.7` (electron-builder's macOS signing) is patched in `patches/app-builder-lib@26.15.7.patch` with the upstream backport electron-userland/electron-builder#10172: `security set-key-partition-list -k` must receive the temporary keychain's own password, not the `.p12` import password. macOS runner images since `macos-26-arm64` 20260831 verify that password, and `Build on macos arm64` failed with `SecKeychainUnlock: The user name or passphrase you entered is not correct`. Keep the patch until electron-builder resolves an `app-builder-lib` containing the fix (26.16.1+), and run `pnpm run deps:electron-builder:test` after related dependency updates — the test fails when the patched version no longer matches the installed one. - `nx-electron@22.0.0` uses a local Nx 23 export-path patch and an explicit `webpack-node-externals` package extension. Scoped peer allowances for it and `ngx-indexed-db@22.0.0` live in `pnpm-workspace.yaml`; they are project compatibility bridges, not upstream support declarations. See `docs/architecture/nx-workspace-boundaries.md` before removing them. - A directory holding files consumed by other projects must be an Nx project. Nx builds its graph from TypeScript imports only, so a relative SCSS `@use` across project roots creates no edge and the imported file lands in no task hash — edits then return a cache hit instead of rebuilding. Shared partials live in `libs/ui/styles` (project `ui-styles`), and each consumer declares `"implicitDependencies": ["ui-styles"]`. Run `pnpm run styles:inputs:validate` after adding a cross-project stylesheet import. - Update Nx with `pnpm nx migrate nx@ --skipInstall`, regenerate the lockfile, run generated migrations when present, and validate before opening a PR. Major updates are always manual. Replace incomplete Dependabot security PRs with a coordinated update instead of editing the bot branch. - Repository-specific skills live under `.codex/skills/`. - Frontmatter descriptions are trigger-only and begin with `Use when`; keep each skill at or below 500 words. - Run `pnpm run skills:validate` after editing a committed skill or a literal path it documents. - Keep `.codex` and `.claude` copies of `release-notes` and `release-cut` byte-identical. ### Building and Serving ```bash # Serve the Angular web app only (development mode, baseHref="/") pnpm run serve:frontend # or nx serve web # Serve with PWA configuration (optimized, baseHref="/") pnpm run serve:frontend:pwa # or nx serve web --configuration=pwa # Serve the Electron app (starts both frontend and backend) pnpm run serve:backend # or nx serve electron-backend # Build frontend for Electron (baseHref="./") pnpm run build:frontend # or nx build web # Build frontend for PWA deployment (baseHref="/") pnpm run build:frontend:pwa # or nx build web --configuration=pwa # Build backend (Electron) pnpm run build:backend # or nx build electron-backend # Package the app (creates distributable without installers) pnpm run package:app # or nx run electron-backend:package # Create installers/executables pnpm run make:app # or nx run electron-backend:make ``` ### Windows Embedded MPV Pin Maintenance - PR, master, and tag builds resolve the Windows runtime only from `tools/embedded-mpv/windows-runtime-pin.json`; repository variables are not build inputs. - Validate the checked-in schema and provenance with `pnpm embedded-mpv:windows-runtime-pin:check`. - Prepare a manual rotation with `pnpm embedded-mpv:windows-runtime-pin:refresh -- --force`. The weekly `refresh-windows-embedded-mpv-runtime.yaml` workflow runs the same updater and opens a reviewable PR before upstream retention expires. - The PAT-backed refresh job must keep every third-party action pinned to a full commit. Do not mirror the upstream binary without complete corresponding source, build records, license notices, and a validated transitive license closure. ### Electron CDP Debugging - Start Electron in dev mode with: `nx serve electron-backend` - Package-script equivalent: `pnpm run serve:backend` - The workspace is configured to always launch Electron with: `--remote-debugging-port=9222` - Use CDP clients (Chrome DevTools Protocol tools) against: `127.0.0.1:9222` - When the task is Electron automation/debugging, use the `electron` skill - Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via `ELECTRON_OPEN_DEVTOOLS=1`. - If DevTools is open, `agent-browser --cdp 9222 ...` may attach to the DevTools page instead of the IPTVnator window (symptoms: `tab list` shows `about:blank`, empty snapshots, black screenshots). Inspect targets with `curl http://127.0.0.1:9222/json/list` and connect directly to the app page's `webSocketDebuggerUrl`. - The app holds a single-instance lock (`acquireSingleInstanceLock` in `apps/electron-backend/src/app/services/single-instance.ts`): a second launch against the same `userData` quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to `onSecondInstance`, which is how a playlist path handed to an already-running app reaches the open queue. For startup tracing or white-screen debugging: ```bash IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend ``` Useful narrower flags: - `IPTVNATOR_TRACE_IPC=1` traces renderer `window.electron.*` bridge calls - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity, bounded Embedded MPV runtime-probe stderr, and embedded MPV session status transitions (the input of the reconnect policy; never the stream URL) - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal - `IPTVNATOR_PERF_CAPTURE=1` enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset - `IPTVNATOR_PERF_WORKER_PROFILING=1` enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset Settings, portal request/response, and trace payloads must use `@iptvnator/shared/logging` or the redacting portal logger before reaching `console.*`; never log raw credentials while debugging. If the Nx daemon gets into a bad state before rerunning Electron: ```bash pnpm nx reset ``` Use global `agent-browser` (preferred): ```bash # Verify CDP targets agent-browser --cdp 9222 tab list # Switch to the app tab and inspect interactive elements agent-browser --cdp 9222 tab 1 agent-browser --cdp 9222 snapshot -i -c -d 4 # Capture debug artifacts agent-browser --cdp 9222 screenshot /tmp/iptvnator-cdp.png agent-browser --cdp 9222 trace start /tmp/iptvnator.trace.zip agent-browser --cdp 9222 wait 1500 agent-browser --cdp 9222 trace stop /tmp/iptvnator.trace.zip ``` If `agent-browser` is not in PATH, use: ```bash npx --yes agent-browser --cdp 9222 tab list ``` ### Testing ```bash # Run frontend tests pnpm run test:frontend # or pnpm nx test web # Run backend tests pnpm run test:backend # or pnpm nx test electron-backend # Run targeted E2E tests (Playwright) pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts # Run broad E2E suites only when the impact justifies it pnpm nx e2e web-e2e pnpm nx e2e electron-backend-e2e # Run tests with coverage when needed pnpm nx test web --configuration=ci ``` Before finishing behavior changes or bug fixes, follow `Regression Prevention And Test Updates` above and report the test impact decision in the final summary. ### Linting ```bash # Lint all projects (CI runs this on master; PRs lint affected projects) pnpm run lint # Lint a single project nx lint web nx lint electron-backend ``` CI lints affected projects on PRs (`nx affected`) and every project on master pushes (`.github/workflows/ci.yml`). This enforces the Nx module-boundary tags, the legacy bare-alias ban, and a `max-lines` ESLint rule. The limits and their rationale live in one place, `tools/eslint/max-lines-config.mjs`, which both `eslint.config.mjs` and the baseline generator import so the enforced rule and the generated list cannot drift: - **Production TypeScript: hard maximum 400 lines.** - **Tests: 1200.** `**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts` and everything under `apps/*-e2e/**` — a spec is a flat list of independent cases, so splitting one at the production limit yields arbitrary `-2.spec.ts` files, and length there signals coverage rather than the design debt the production limit catches. `.spec-data.ts` fixtures (flat case lists consumed only by a spec, e.g. the worker IPC contract table) grow with coverage the same way. - **Blank lines and comments are not counted** (`skipBlankLines`, `skipComments`), so a docblock is never the reason a file must be split. Pre-existing oversized files are baselined in `tools/eslint/max-lines-baseline.mjs`; regenerate the baseline with `node tools/eslint/generate-max-lines-baseline.mjs` after splitting a file. The generator decides who belongs on the list by running ESLint's own `max-lines` rule, not by counting lines itself — a private reimplementation would silently disagree with the rule and produce a baseline that turns CI red while looking correct. Never add new files to the baseline — the list must only shrink. A new file that genuinely cannot be split (for example a function serialized into another process) instead carries its own file-wide `/* eslint-disable max-lines -- */`; the generator skips those files, so a justified exemption never lands in the baseline. If such a directive later becomes unnecessary, ESLint reports it as an unused disable directive — remove it rather than leaving a stale justification behind. Project `lint` targets that shell out to eslint must quote the glob, e.g. `eslint "apps//**/*.ts"`. An unquoted `**` is expanded by the POSIX shell on Linux and macOS (which has no `globstar`, so it matches only a shallow subset of files) while Windows passes the literal pattern to ESLint, which expands it recursively — the two hosts then lint different file sets. The target still reports success either way, so a broken glob hides missing coverage instead of failing. After changing such a target, compare the linted file count against `find -name '*.ts' | wc -l`. ## Legacy Desktop Profile Migration `electron-profile-bootstrap.ts` selects the known v0.19 `electron-backend` profile before eager main-process imports only when current Chromium storage is unused. Existing profiles retain their settings and offer explicit recovery of missing sources from a disposable legacy snapshot. Playlist rows and a completion receipt commit atomically in the DB worker; original IndexedDB is retained, current payload rows are preserved, and completed imports never replay deleted sources. Contract and recovery limits: `docs/architecture/m3u-playlist-module.md` (Desktop upgrades from legacy profiles). Startup shows `AppStartupStatusComponent` until the initial route and source inventory are ready, including XMLTV reconciliation. Inventory reads retry once; failed reads show an explicit Retry action instead of an empty library. Successful inventory reads first await settings loading, then pending XMLTV reconciliation, and retry failed cleanup with its last committed URLs before exposing the workspace. See the same contract for startup readiness and error handling. ## Architecture ### Monorepo Structure (Nx Workspace) This is an Nx monorepo with the following structure: - **apps/web** - Angular application (frontend, shared by Electron and PWA) - **apps/electron-backend** - Electron main process - **apps/web-backend** - HTTP backend for the self-hosted PWA (`/parse`, `/parse-xml`, `/xtream`, `/stalker` CORS proxy endpoints). At startup it raises Node's happy-eyeballs per-attempt connection timeout to 2500 ms (`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 - **apps/remote-control-web** - Mobile remote-control web app served by the Electron backend - **apps/web-e2e** - Playwright E2E tests against the web app - **apps/electron-backend-e2e** - Playwright E2E tests against the Electron app - **apps/stalker-mock-server** - Mock Stalker/Ministra portal for dev and E2E - **apps/xtream-mock-server** - Mock Xtream Codes API for dev and E2E - **apps/website** - Astro + Tailwind landing page, blog (guides carry `faq:` frontmatter → FAQPage JSON-LD and open with `src/components/blog/ContentDisclaimer.astro`, whose `offline` variant is mandatory for posts about downloads or recordings; tags are a closed vocabulary in `src/lib/blog-tags.ts` enforced by the collection schema, each with a `/blog/tag//` hub), per-OS download landing pages (`/download/`, `/download/{windows,macos,linux}/`) plus the Docker page (`/download/docker/`) feature landing pages (`/features/`, registry in `src/lib/features.ts`) and comparison pages (`/compare/`, registry in `src/lib/comparisons.ts`; most compare IPTVnator's own options, and a page that names other software must carry a dated `ThirdPartyNote` — see "Pages that name other software" in `apps/website/README.md`); direct asset links are resolved at build time from the GitHub Releases API with a `package.json` fallback (`src/lib/downloads.ts`, see `apps/website/README.md`) - **libs/** - Shared libraries: - **epg/data-access** - EPG services, runtime bridge, program normalization - **m3u-state** - NgRx state management for M3U playlists - **playlist/import/feature** - Playlist import flows (file/URL/text upload, Xtream and Stalker import dialogs, and the "Auto-detect" method: paste a provider message, `detectProviderImportCandidates` in `libs/shared/interfaces` deterministically extracts URLs/credentials/MAC+device identity and prefills the matching form — detection only proposes, the target form's own validation and behavioral probes stay authoritative) - **playlist/m3u/feature-player** - M3U video player page and `/workspace/playlists/:id` routes - **playlist/shared/{ui,util}** - Shared playlist UI and utilities - **portal/xtream/{data-access,feature}** - XtreamStore, services, data sources; routed Xtream components - **portal/stalker/{data-access,feature}** - StalkerStore and routed Stalker components - **portal/catalog/feature** - Portal catalog UI - **portal/downloads/feature** - Download manager UI - **portal/shared/{data-access,ui,util}** - Cross-portal shared code: stateful collection services and VOD multi-source discovery/resolve/ranking live in `data-access`; reusable views live in `ui`; `util` is for pure contracts/helpers - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) - **shared/logging** - Dependency-free structured redaction for diagnostic logs - **shared/host-health** - Per-host circuit breaker for portal requests (`HostConnectivityGuard`), shared by the Electron main process and the web backend; transport-free, the owning app supplies the clock and owns the instance. Monotonic admission ids with per-endpoint failure boundaries distinguish parallel failures from later attempts even within one clock tick (#1438) - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities - **shared/marketing-fixtures** - Provider-neutral fictional movie metadata, live channel list and the generated channel-logo SVG renderer shared by the Xtream and Stalker marketing mocks (both serve `/assets/marketing/logo/.svg`) - **shared/testing** - Shared test helpers - **ui/components** - Reusable UI components (incl. channel list) - **ui/epg** - EPG UI (timeline ribbon, programme guide grid via `EPG_GUIDE_SOURCE`, progress panel, program dialogs) - **ui/playback** - Player UI (video/audio players) - **ui/pipes** - Angular pipes - **ui/remote-control** - Remote-control UI pieces - **ui/shared-portals** - Shared portal types (`LiveEpgPanelSummary`) - **ui/styles** - Shared styles/theme - **workspace/{shell,dashboard}** - Workspace shell (layout/navigation) and dashboard ### Frontend Architecture (Angular) **State Management**: Uses NgRx for playlist state management: - Store configuration in `apps/web/src/app/app.config.ts` - Playlist state, actions, effects, and reducers in `libs/m3u-state/` - Entity adapter pattern for managing playlists collection - Router store integration for route-based state **XtreamStore Architecture** (Signal Store with Feature Composition): The Xtream Codes module uses NgRx Signal Store with a layered architecture: ``` ┌─────────────────────────────────────────────────────────────────┐ │ PRESENTATION LAYER │ │ Components use XtreamStore (facade) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ FACADE LAYER │ │ XtreamStore │ │ (Composes feature stores, unified API) │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ withPortal · withContent · withSelection · withSearch · withEpg │ │ withPlayer · withFavorites · withRecentItems │ │ withPlaybackPositions │ └─────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ DATA SOURCE LAYER │ │ IXtreamDataSource │ │ ┌───────────────────┬───────────────────┐ │ │ ▼ ▼ │ │ ElectronDataSource PwaDataSource │ │ (DB-first + API) (API-only) │ └─────────────────────────────────────────────────────────────────┘ ``` File structure: ``` libs/portal/xtream/ ├── data-access/src/lib/ │ ├── stores/ │ │ ├── features/ │ │ │ ├── with-portal.feature.ts # Playlist & portal status │ │ │ ├── with-content.feature.ts # Categories & streams │ │ │ ├── with-selection.feature.ts # UI selection & infinite-scroll window │ │ │ ├── with-search.feature.ts # Search functionality │ │ │ ├── with-epg.feature.ts # EPG data │ │ │ ├── with-player.feature.ts # Stream URLs & player │ │ │ ├── with-playback-positions.feature.ts # Resume/playback positions │ │ │ └── index.ts │ │ ├── xtream.store.ts # Facade composing all features │ │ └── index.ts │ ├── services/ │ │ ├── xtream-api.service.ts # Xtream Codes API calls │ │ ├── xtream-url.service.ts # Stream URL construction │ │ ├── favorites.service.ts # Favorites persistence │ │ ├── epg-queue.service.ts # EPG fetch queueing │ │ ├── xtream-xmltv-fallback.service.ts # XMLTV fallback EPG │ │ └── index.ts │ ├── data-sources/ │ │ ├── xtream-data-source.interface.ts # Abstract interface + types │ │ ├── electron-xtream-data-source.ts # DB-first implementation │ │ ├── pwa-xtream-data-source.ts # API-only implementation │ │ └── index.ts # provideXtreamDataSource() factory │ ├── with-favorites.feature.ts # Favorites feature │ └── with-recent-items.ts # Recently viewed feature └── feature/src/lib/ # Routed components ├── xtream-feature.routes.ts # createXtreamRoutes(): /workspace/xtreams/:id tree ├── live-stream-layout/, vod-details/, serial-details/, ... └── global-search-results/ # Global search (Electron-only route) ``` Key patterns: - **Feature stores**: Each `with*.feature.ts` uses `signalStoreFeature()` for focused functionality - **Facade pattern**: `XtreamStore` composes all features, maintaining backward compatibility - **Data source abstraction**: `IXtreamDataSource` has SQLite-backed and API/in-memory implementations - **Factory injection**: `provideXtreamDataSource()` selects `ElectronXtreamDataSource` only when `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource`; otherwise it selects `PwaXtreamDataSource` - **Catalog lazy loading**: catalog grids scroll infinitely instead of paging. `withSelection` keeps a `visibleCount` render window over the in-memory catalog plus bounded per-selection scroll snapshots for detail/tab round-trips; the shared `InfiniteScrollDirective` (`libs/portal/shared/ui`) measures container overflow to auto-fill tall viewports (terminating on lack of container growth, not on a load count) and fires `loadMore` near the bottom. The search layout routes its results container through the same directive (`nearEnd*` inputs). Stalker feeds the same contract from server-paged appends: portal pages accumulate into one deduplicated list, `hasMoreContent` derives from accumulated length vs `total_items`, a failed append keeps loaded pages and offers a tail retry, and the facade maps page 0 to the skeleton and later pages to the tail spinner. No paginator remains anywhere in the app Xtream data strategies by runtime capability: | Capability | Strategy | | --------------------------------- | -------------------------------------------------------- | | **Complete Xtream SQLite bridge** | DB-first: check DB → fetch API if missing → cache to DB | | **Bridge unavailable** | API-only: fetch from API and keep session data in memory | **M3U Playlist Module Architecture**: The M3U playlist module handles traditional M3U/M3U8 playlists with support for 90,000+ channels. ``` ┌─────────────────────────────────────────────────────────────────────┐ │ VIDEO PLAYER PAGE │ │ libs/playlist/m3u/feature-player/src/lib/video-player/ │ ├─────────────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌───────────────────────────────────────────────┐│ │ │ Sidebar │ │ Video Player (ArtPlayer/Video.js) ││ │ │ ┌─────────┐ │ │ ││ │ │ │Channel │ │ ├───────────────────────────────────────────────┤│ │ │ │List │ │ │ EPG timeline ribbon (app-epg-timeline) ││ │ │ │Container│ │ │ horizontal, under the player ││ │ │ └─────────┘ │ └───────────────────────────────────────────────┘│ │ └─────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` The live EPG panel is a horizontal **timeline ribbon** under the player (`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`), not a right-side drawer (reworked in PR #1102). See `docs/architecture/m3u-playlist-module.md` for the timeline's controllers and scroll behavior. **Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`. **Cover grids** (Xtream/Stalker VOD + series catalogs, favorites/recent, dashboard rails): sized by `Settings.coverSize` (small/medium/large → `--cover-grid-min-width`/`--cover-rail-width`/`--cover-gap`, written to `` in `app.component.ts`, tokens in `apps/web/src/_cover-size.scss`). `Settings.showCoverTitles` (Settings → General, default on; only an explicit `false` opts out, coerced with the other default-on flags in `libs/services/src/lib/settings-opt-out.util.ts`) turns VOD/series grids into a posters-only wall: the title row is dropped and a `.cover-title-overlay` caption slides in on hover/`:focus-visible`, pinned open when the cover is missing or failed. `CoverTitlesService` (`libs/portal/shared/ui`) is the single resolver — opt-out AND a `(any-hover: hover)` pointer, so touch-only devices keep titles. Live channel grids, search results (search pages pass `[allowPostersOnly]="false"` to `app-content-card`; `app-grid-list` and `app-unified-grid-tab` keep titles while their `searchTerm` is non-blank), "recently added" rails and dashboard rails always keep their labels. Catalog and collection cards are keyboard buttons (`role="button"`, Enter/Space, focus ring). Contract: "Cover Grids" in `docs/architecture/iptvnator-ui-guidelines.md`. **Radio Channel Layout** (when `channel.radio === 'true'`): ``` ┌─────────────────────────────────────────────────────────────────────┐ │ ┌─────────────┐ ┌────────────────────────────────────────────────┐│ │ │ Sidebar │ │ Blurred backdrop (station logo) ││ │ │ │ │ ┌──────────┐ ││ │ │ │ │ │ Artwork │ ← cinematic hero layout ││ │ │ │ │ └──────────┘ ││ │ │ │ │ Station Name ││ │ │ │ │ [LIVE] badge ││ │ │ │ │ ⏮ ▶/⏸ ⏭ ← transport controls ││ │ │ │ │ 🔊 ━━━━━━━━━ ← volume slider ││ │ │ │ │ (no EPG panel) ││ │ └─────────────┘ └────────────────────────────────────────────────┘│ └─────────────────────────────────────────────────────────────────────┘ ``` Key radio behavior: - Detection: `channel.radio === 'true'` (string from M3U `radio` attribute) - The audio player always renders inline — `shouldShowInlinePlayer` is bypassed for radio - EPG panel is conditionally hidden in the template when radio is active - Volume is shared with video player via `localStorage` key `'volume'` - Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute - Component: `libs/ui/playback/src/lib/audio-player/audio-player.component.ts` **M3U Movie Recognition** (VOD detail instead of the EPG zone): an M3U entry recognized as a movie FILE swaps the player + EPG area for the portals' two-state VOD detail shell fed by TMDB, watch-first (activation still plays immediately; Esc reveals the Browse hero). Detection is synchronous URL-shape heuristics — movie container extension (`mkv`/`mp4`/…, never `ts`/`m3u8`/`mpd`) or an Xtream-style `/movie|movies|vod/` path segment; radio, DASH, `/series/` paths and episode-marker names (`S01E02`, "2 серия") fail toward the live layout (`isLikelyM3uMovie` in `libs/shared/m3u-utils`). Gated on TMDB enrichment being enabled AND `Settings.m3uVodDetails` (default on; checkbox in Settings → Metadata (TMDB)). Host: `m3u-vod-detail/` in `libs/playlist/m3u/feature-player` (shell + `PortalInlinePlayerComponent`, parent's unchanged `embeddedPlayback()` payload); external MPV/VLC users keep Browse. See "Movie Recognition (VOD Detail View)" in `docs/architecture/m3u-playlist-module.md`. M3U playback mode is independent of this metadata gate: `isLikelyM3uVod` recognizes video-file extensions and exact `/movie|movies|vod|series/` URL segments, including episodes. The M3U parent's `embeddedPlayback()` sets `isLive: false` for those entries or a catch-up URL, even with TMDB/details disabled; the detail host forwards that same payload. Ordinary HLS/TS and unknown URLs without VOD evidence, DASH and radio retain their existing behavior. Seeking requires a seekable source and duration. Xtream/Stalker, external MPV/VLC launch payloads and session identity are unchanged. Contract: `docs/architecture/m3u-playlist-module.md` (M3U Playback Mode). Channel List Component Structure (parent coordinator pattern): ``` libs/ui/components/src/lib/channel-list-container/ ├── channel-list-container.component.ts # Parent - shared state coordinator ├── all-channels-view/ # Virtual scroll + debounced search ├── groups-view/ # Expansion panels + infinite scroll ├── favorites-view/ # CDK drag-drop reordering ├── recent-view/ # Recently viewed channels └── channel-list-item/ # Individual channel display ``` Key patterns: - **EnrichedChannel**: Pre-computed EPG data attached to channels for performance - **Parent coordinator**: Manages shared signals (`channelEpgMap`, `progressTick`, `favoriteIds`) - **Virtual scrolling**: CDK virtual scroll for 90,000+ channel lists - **Infinite scroll**: IntersectionObserver in groups view loads 50 items at a time - **Global progress tick**: Single 30s interval instead of per-item intervals State management via NgRx (`libs/m3u-state/`): - `PlaylistActions`: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist - `ChannelActions`: setChannels, setActiveChannel, setAdjacentChannelAsActive - `EpgActions`: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag - `FavoritesActions`: updateFavorites, setFavorites, hydrateFavorites See `docs/architecture/m3u-playlist-module.md` for complete documentation. **Routing**: Lazy-loaded routes in `apps/web/src/app/app.routes.ts`. All user-facing routes are nested under the workspace shell (`/workspace/...`); `/` redirects into the workspace. - Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources` - M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player` - Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `discover`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` - Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `discover`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` - Global collections: `/workspace/global-favorites`, `/workspace/global-recent` - Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`) - Downloads: `/workspace/downloads` with focused `/workspace/downloads/:downloadId`; source-scoped equivalents are `/workspace/xtreams/:id/downloads/:downloadId` and `/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide the workspace context panel. - Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography **Service Architecture** (Factory Pattern): - Abstract `DataService` class in `libs/services/src/lib/data.service.ts` defines the contract - Two environment-specific implementations: - `ElectronService` (`apps/web/src/app/services/electron.service.ts`) - Uses IPC to communicate with Electron backend - `PwaService` (`apps/web/src/app/services/pwa.service.ts`) - Uses HTTP API and IndexedDB for standalone web version - Factory function `DataFactory()` in `apps/web/src/app/app.config.ts` determines which implementation to inject: ```typescript if (window.electron) { return inject(ElectronService); } return inject(PwaService); ``` **Data Storage (Environment-Specific)**: - **Electron**: SQLite database via Drizzle ORM (`better-sqlite3` driver) - Location: `~/.iptvnator/databases/iptvnator.db` - Full-featured relational database with foreign keys and indexes - Canonical schema and connection live in `libs/shared/database` - **PWA (Web)**: IndexedDB via `ngx-indexed-db` - Browser-based NoSQL storage - Same schema structure but implemented in IndexedDB - Limited by browser storage quotas **TypeScript File Size Rule**: Keep production TypeScript files under **300 lines**. Hard maximum is **350–400 lines**, and CI enforces the 400. Blank lines and comments do not count toward it, so documenting a file never costs you headroom. Tests (`**/*.spec.ts`, `**/*.spec-data.ts`, `**/*.e2e.ts`, `apps/*-e2e/**`) are held to 1200 instead — the guidance below is about production code. - When creating new files, design them to stay within this limit from the start. - When adding a feature to an existing file that would push it past 350 lines, **refactor first**: extract helpers, sub-services, or feature modules before adding the new code. - When you notice a file already exceeds 350 lines, **proactively suggest a refactoring** (or perform it if the change is straightforward) — even if the immediate task is small. Typical split strategies: - Angular components: extract child components, move logic to a dedicated service or store feature - Signal store features: split into smaller `with*` feature functions in separate files - Services: split by responsibility (e.g. separate API, transformation, and state concerns) - Utility files: group by domain and export from a barrel `index.ts` This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file. --- **Angular Coding Standards**: This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use the following: - **Component Queries**: Use `viewChild()`, `viewChildren()`, `contentChild()`, `contentChildren()` instead of `@ViewChild`, `@ViewChildren`, `@ContentChild`, `@ContentChildren` decorators ```typescript // ✅ Correct - Signal-based readonly menu = viewChild.required('menuRef'); readonly items = viewChildren('item'); // ❌ Incorrect - Old decorator syntax @ViewChild('menuRef') menu!: MatMenu; @ViewChildren('item') items!: QueryList; ``` **Important**: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it: ```html ``` - **Component Inputs/Outputs**: Use `input()` and `output()` functions instead of `@Input()` and `@Output()` decorators ```typescript // ✅ Correct - Signal-based readonly title = input.required(); readonly size = input(10); // with default value readonly clicked = output(); // ❌ Incorrect - Old decorator syntax @Input({ required: true }) title!: string; @Input() size = 10; @Output() clicked = new EventEmitter(); ``` - **Reactive State**: Use signal primitives for reactive state management ```typescript // ✅ Use signal(), computed(), effect(), linkedSignal() readonly count = signal(0); readonly doubled = computed(() => this.count() * 2); constructor() { effect(() => { console.log('Count changed:', this.count()); }); } ``` - **Host Bindings**: Use `@HostBinding()` and `@HostListener()` decorators (these don't have signal equivalents yet) ```typescript @HostBinding('class.active') get isActive() { return this.active(); } @HostListener('click') onClick() { /* ... */ } ``` - **Control Flow**: Use `@if`, `@for`, `@switch` instead of `*ngIf`, `*ngFor`, `*ngSwitch` ```typescript // ✅ Correct - Modern syntax @if (isLoggedIn()) {

Welcome!

} @for (item of items(); track item.id) {
  • {{ item.name }}
  • } // ❌ Incorrect - Old syntax

    Welcome!

  • {{ item.name }}
  • ``` ### Backend Architecture (Electron) **Main Entry**: `apps/electron-backend/src/main.ts` - Bootstraps Electron app and initializes database - Registers event handlers for IPC communication - Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen - Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded. **Database**: - **ORM**: Drizzle ORM with `better-sqlite3` (local SQLite file) - **Location**: `~/.iptvnator/databases/iptvnator.db` (avoids spaces in path) - **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim): - `playlists` - Playlist metadata (M3U, Xtream, Stalker) - `categories` - Content categories (live, movies, series) - `content` - Streams/VOD/series items. Besides the catalog fields it carries what a detail view learned and handed back: `backdrop_url`, plus the TMDB identity (`tmdb_id`, `release_year`, `original_title`) that lets an activity row repeat the detail view's lookup instead of rebuilding a weaker one from the display title - `favorites` - User favorites - `recentlyViewed` - Watch history - `epgChannels`, `epgPrograms` - Persisted EPG data - `epgChannelMappings` (`epg_channel_mappings`) - Manual EPG channel mappings (defined in `epg-mapping.schema.ts`, re-exported by `schema.ts`) - `playbackPositions` - Resume positions - `downloads` - Download manager state - `recordings` - Live-TV recording lifecycle + start-time channel/EPG snapshot (defined in `schema.ts` beside `downloads`) - `appState` - Key-value app state (also tracks one-off data migrations) - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language) - `vodSourcePins` (`vod_source_pins`) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in `vod-source-pins.schema.ts`, re-exported by `schema.ts`) - **Connection**: `libs/shared/database/src/lib/connection.ts` - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`) - Provides full read-write access for `electron-backend` and a read-only mode - A root `drizzle.config.ts` configures Drizzle Kit tooling (points at the schema via the compat shim) **IPC Communication**: - **Preload script**: `apps/electron-backend/src/app/api/main.preload.ts` - Exposes `window.electron` API via `contextBridge` - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.) - The canonical TypeScript contract is `ElectronBridgeApi` in `libs/shared/interfaces/src/lib/electron-api.interface.ts`; `global.d.ts`, `apps/web/src/typings.d.ts`, and `main.preload.ts` must reference this shared type instead of maintaining separate method lists. - **Event handlers**: `apps/electron-backend/src/app/events/` - `database.events.ts` - Database CRUD operations - `playlist.events.ts` - Playlist import/update - `playlist-open.events.ts` - Playlist files handed over by the OS (argv, file association, macOS `open-file`); the queue itself lives in `services/playlist-open-request.ts` - `epg.events.ts` - EPG IPC registration; freshness/fetch orchestration lives in `epg-fetch.service.ts`, manual channel-mapping resolution and CRUD in `epg-mapping.service.ts`, source orchestration in `epg-worker.service.ts`, per-import lifecycle in `epg-fetch-operation.ts`, worker bootstrap/shutdown and clear protocol in `epg-worker-runtime.ts`, DB lookups in `epg-query.service.ts` - `xtream.events.ts` - Xtream Codes API - `stalker.events.ts` - Stalker portal API - `connectivity-guard.events.ts` - `CONNECTIVITY_GUARD_RESET`: forgets the connection failures recorded for a portal host. Both portal handlers above run every request through the per-host circuit breaker (rules in `@iptvnator/shared/host-health`, process-wide instance in `util/host-connectivity-guard.ts`; the web backend runs the same breaker over its proxy routes) — after 2 consecutive connection-level failures (no HTTP response; `ETIMEDOUT`/`ENOTFOUND`/`ECONNREFUSED`/… but never `ECONNRESET`) requests to that endpoint fail immediately for 30 s. The key is `URL.origin`, not `URL.host`, which would give `http://panel` and `https://panel` one shared record and let a dead TLS listener fast-fail the working HTTP one instead of hanging the full 30 s/15 s axios timeout again, with one half-open trial request afterwards. Any HTTP response (4xx and 5xx included) clears the record. The refusal is a real `Error` whose wording is a renderer contract (`buildHostConnectivityFastFailMessage` in `libs/shared/interfaces`): it must carry no `HTTP Error `, no timeout wording and none of the auth phrases, or Stalker endpoint discovery misclassifies it and lazy portal repair fires against a host just declared dead. Discovery probes are exempt via the `skipConnectionGuard` payload flag (bypass + no failure counting, but successes still clear the record). Every user-driven retry/refresh that issues portal requests must reset BEFORE its first request, or the affordance fast-fails and looks broken; automatic and first-load paths deliberately do not reset. Current senders: Xtream content-gate Retry, Stalker catalog append retry (`retryContentPage`), Stalker search-page retry, `StalkerItvCacheService.refresh()` (Live TV refresh), both account-info dialogs' Retry, the destructive Xtream refresh (`XtreamRefreshFlowService`, before it deletes the cached catalog — one flow shared by both entry points, `PlaylistRefreshActionService.refreshXtream()` and `RecentPlaylistsComponent.refreshXtreamPlaylist()`, which supply only a progress reporter), `StalkerPortalDiscoveryService.discover()`, and `PortalStatusService` on `skipCache`. Kill switch: `IPTVNATOR_DISABLE_CONNECTIVITY_GUARD=1`. Contract: `docs/architecture/host-connectivity-guard.md` - `player.events.ts` - External player IPC registration; MPV/VLC lifecycle logic lives in `mpv-session.service.ts`, `vlc-session.service.ts`, and shared `external-player-*` helpers - `settings.events.ts` - App settings - `electron.events.ts` - App version, etc. **Workers** (`apps/electron-backend/src/app/workers/`): - EPG parsing: `epg-parser.worker.ts`; main-process worker lifecycle is coordinated from `apps/electron-backend/src/app/events/epg-worker.service.ts` - Non-EPG SQLite work: `database.worker.ts` (see `docs/architecture/sqlite-db-worker.md`). Catalog deletes and inserts commit in row-budgeted transactions of ~5,000 rows (`database/operations/catalog-deletion.ts`: per-category row counts → category groups → set-based `DELETE`s scoped to the captured category ids, never playlist-wide, since the worker interleaves requests between commits and a newer import's categories must survive an older refresh; never 100-row autocommit batches, which flush FTS5 segments and re-append index pages to the WAL on every commit, and never one giant transaction, which would starve the main-process and EPG-worker connections past their 5 s `busy_timeout`). Progress events are throttled to one per 100 ms per operation with summed `increment`s (`operation-progress-throttle.ts`); phase starts, totals reached and terminal events are never held back - Playlist refresh: `playlist-refresh.worker.ts`; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging `PLAYLIST_CANCEL_REFRESH` (see `docs/architecture/m3u-playlist-module.md`) ### Xtream Category Management The Electron Live TV, Movies, and Series category dialog applies Select/Deselect to search results while a filter is active and to the whole type otherwise. Button states use the matching group; "Total selected" counts the whole catalog. Save persists the complete draft, Close discards it, and refresh restores hidden categories by provider ID and type. See `docs/architecture/category-management.md`. ### Key Features #### Xtream Connection Test Add/Edit source Test HTTPS and HTTP discloses plaintext credential use before the click and can replace an unavailable HTTPS base with a verified active HTTP base in the form. Only initial refused-port or TLS wrong-version evidence permits the same-host attempt; HTTP errors, certificate failures and redirect failures do not. Add/Save persists `serverUrl`, and the routed session observes the metadata change. Passive checks never change the protocol. Separate XMLTV and already-issued media/download URLs stay independent. Contract: `docs/architecture/xtream-portal-compatibility.md` ("Explicit protocol discovery"). #### Xtream Live Auto Format The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit HLS+TS account evidence, using the canonical URL builder and original headers. The same web player may try TS once after an owned initial terminal HTTP failure, before `playing`; the old transport unmounts before the guarded render callback starts TS. No player preference or playlist cache changes. Manual formats, unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without a terminal diagnostic also need manual TS. Contract and full support matrix: `docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure). #### Xtream Catch-Up Server Timezone The `{Y-m-d:H-M}` segment of a timeshift URL is read by the panel in ITS timezone (`server_info.timezone`), never the viewer's (issue #1562). `withPortal.checkPortalStatus()` normalizes it with `resolveXtreamServerTimezone()` (`libs/shared/interfaces`, an ICU-resolvable name, else a `UTC±HH:MM` derived from the `time_now`/`timestamp_now` clock pair) and persists it on the playlist row through `IXtreamDataSource.rememberServerTimezone` — Electron: one conditional `json_set` UPDATE (`DB_SET_PLAYLIST_SERVER_TIMEZONE`) guarded by the row's current connection; PWA: `PlaylistsService.transformPlaylistMeta` — because the Favorites / Recent resolver reads the STORED row, not the store, and the worker interleaves requests, so no read may precede the write. `DB_GET_PLAYLIST` projects it back from the row payload, and a server URL change drops it until the next account-info check. The same value converts timestamp-less EPG `start`/`end` strings. Contract: `docs/architecture/xtream-portal-compatibility.md` ("Start time is the panel's clock, not the viewer's"). #### M3U URL User-Agent - `PlaylistsService.getPlaylist()` joins the per-playlist mutation queue so a route opened during refresh reads after its pending save. Mutation-internal reads keep using `getPlaylistById()` directly to avoid queue re-entry. - The URL import form accepts an optional User-Agent and stores it as `Playlist.userAgent`. Electron sends it on initial download, manual refresh, and startup auto-update. The self-hosted PWA sends it through the registered target `/parse` backend proxy for import and refresh; a matching backend is required, and browser playback-header restrictions still apply. - Reuse the existing source editor and channel-over-playlist playback header precedence. Contract: `docs/architecture/m3u-playlist-module.md` ("User-Agent for URL sources"). **Playlist Support**: - M3U/M3U8 files (local or URL) - Xtream Codes API (`username`, `password`, `serverUrl`) - Stalker portal (`macAddress`, `url`) **Stalker playback links**: `create_link` runs only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise the static `cmd` plays directly. One helper decides (`resolveStalkerStaticPlaybackUrl` in `libs/portal/stalker/data-access/.../stalker-link-semantics.utils.ts`), applied by `fetchStalkerPlaybackLink()` for ITV/VOD/radio and by `StreamResolverService` for Favorites/Recently Viewed. It falls back to `create_link` for anything it cannot resolve alone: no row to read flags from, a relative/query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host; an episode (`series` set) always mints, since the parameter selects the episode server-side. Temporary links live ~5 s, so no resolved URL is persisted or replayed — favorites and recently-viewed store the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path. Downloads are the one exception (they must retry a URL). `forced_storage`/`play_token` are deliberately unwired. Contract: `docs/architecture/stalker-portal.md` ("Playback Link Resolution"). **Opening a playlist from the OS** (Electron only): a `.m3u`/`.m3u8` path passed on the command line, opened through a file association, or delivered by macOS' `open-file` event is normalized to an absolute path in the main process (`services/playlist-open-request.ts`) and queued there. The renderer (`apps/web/src/app/services/playlist-open-request.service.ts`) subscribes to the `OPEN_FILE` push **before** calling `announcePlaylistOpenListener`, which is what makes the main process flush. `OPEN_FILE` is the only way out of the queue, and a request stays there until the renderer confirms receipt via `acknowledgePlaylistOpenRequest` — `webContents.send()` returns before the listener runs, and a reload or dead render process keeps the `WebContents` alive, so a successful push is not proof of delivery. Anything unacknowledged is replayed to the next renderer that announces itself. The renderer imports them on a single promise chain so a burst arrives in a deterministic order. `addPlaylist$` in `libs/m3u-state` uses `concatMap` (not `switchMap`) for the same reason: each action carries a different playlist, so a newer add must never cancel an older one's write, EPG fetch and navigation. The import itself reuses the normal file path (`updatePlaylistFromFilePath` → `PlaylistActions.addPlaylist`), so persistence, playlist-scoped EPG, and the navigation to the new playlist all behave exactly like a dialog import. The OS-level registration that makes those paths reachable is `fileAssociations` in `electron-builder.json` — one entry per extension, each with its own `mimeType`. Electron Builder derives all three platform registrations from it: macOS `CFBundleDocumentTypes` (which is what makes `open-file` fire from Finder), the NSIS registry entries, and, on Linux, the desktop entry's `MimeType` plus `/usr/share/mime/packages/iptvnator.xml` for deb/rpm/pacman. Two traps: it assigns the derived `MimeType` _after_ spreading `linux.desktop.entry`, so declaring `MimeType` there is silently overwritten and must not be used; and it appends `%U` to `Exec`, so Linux file managers hand over percent-encoded `file://` URIs rather than paths — `createPlaylistOpenRequest` decodes them before the extension check. `%U` is also the _plural_ exec code, so a multi-file selection arrives as one launch with one argument per file; `extractPlaylistOpenRequestsFromArgv` returns all of them and `enqueueAll` queues the batch, because stopping at the first match would silently drop the rest of the selection. Adding an exec code to `linux.executableArgs` would suppress the `%U` but also pass that code to the app as a real argument, so it is not an option. **Video Players**: - The Embedded MPV native-view dock follows app theme tokens as a solid app surface, including Material icon-button disabled states. Over-video loading, stalled and feedback overlays keep a paired light-on-dark palette. Video viewports remain black in windowed and fullscreen modes. Shared EPG panels use the library-local app-token palette in `libs/ui/epg/src/lib/_epg-theme.scss`. Theme/contrast contract: `docs/architecture/iptvnator-ui-guidelines.md`. - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer. The HTML5 player and ArtPlayer pick their source engine from one URL rule, `resolvePlaybackUrlSourceKind()` in `libs/playback/util` (`mpd` → Shaka, `m3u8`/`m3u` → hls.js, `ts`/`m2ts`/extension-less → mpegts.js, every other container → native `