* fix(stalker): only mint a temporary link when the row asks for one `create_link` ran on every Stalker playback. The reference client — the portal's own `player.js`, mirrored by Kodi's pvr.stalker — mints a link only when the catalog row sets `use_http_tmp_link` or `use_load_balancing`; otherwise it plays the static `cmd` that `get_all_channels` / `get_ordered_list` already returned. Neither flag was read anywhere in the codebase, so every channel paid a round trip and gained a failure point the reference client does not have. One helper now owns the decision (`resolveStalkerStaticPlaybackUrl`), used by `fetchStalkerPlaybackLink()` for ITV/VOD/radio, by the download path, and by `StreamResolverService` for Favorites/Recently Viewed. Its guards are deliberately wider than the flags alone and can only route a row back onto the `create_link` path: no row to read flags from, a relative or query-only command (the VOD `has_files` rewrite), a non-HTTP scheme, or a loopback host. An episode always mints, since `series` selects it server-side. Radio joins the same decision, so a station the portal proxies now gets its link instead of playing a URL the portal never meant to serve. Temporary links live ~5 s, so the audit that came with this: favorites and recently-viewed persist the `cmd`, playback positions store ids, and the main-process context map stores headers keyed by origin+path — none replay a resolved URL. Downloads are the documented exception, and honouring the flags shrinks even that, since an unflagged movie now yields a permanent URL that survives retry. `forced_storage` and `play_token` stay unwired, with the reasoning recorded in the docs rather than left ambiguous. The mock's ITV/radio rows now carry both flags, and the new `static-channel-cmd` scenario (MAC 00:1A:79:00:00:0A) serves unflagged rows with a playable command so the e2e can assert that NO `create_link` request reaches the portal — verified to fail when the change is reverted, with a companion test proving the recorder sees a link when one is due. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): keep temporary-link flags across VOD normalization Codex P1 on #1364, and it is real. `buildStalkerSelectedVodItem()` narrows a raw portal row to an explicit whitelist, and the two flags were not on it. It feeds both `selectedItem()` — which the VOD playback path reads as `linkFlags` — and, through `createStalkerVodItem`, the download payload. So a flagged VOD row with an absolute HTTP `cmd` arrived looking unflagged and took the static path, playing the portal's non-final URL instead of minting a link. The direction of the failure is what makes it a P1: a dropped flag reads as "no temporary link needed", so the whitelist fails OPEN. Both flags now sit on `StalkerVodSource` / `StalkerSelectedVodItem` and on the whitelist, with the consequence spelled out at the normalizer so the next edit does not quietly undo it, and specs pinning all three normalizers plus a store-level test that a flagged VOD still mints. Also two things from re-reading my own diff: - The radio path called `resolveStalkerStaticPlaybackUrl` and then handed the same row to `fetchStalkerPlaybackLink`, which runs that exact check again. Two copies of one decision is the divergence this PR exists to remove, so the outer call and its now-unreachable guard are gone. - `portal-catalog-facade.ts` spells the flag shape out instead of importing `StalkerLinkFlagSource`; it now says why (`type:util`/`domain:portal-shared` may not depend on `type:data-access`/`domain:stalker`), so the obvious "reuse the type" cleanup does not get made and break the boundary lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): authenticate before serving a static collection stream Second Codex P1 on #1364, and a regression this PR introduced. `create_link` was also the request that warmed the portal session. Tokens live in memory only (`StalkerSessionService.tokenCache` is a plain Map), and the collection header builder reads the raw `getCachedToken()`. So a cold start from global Favorites or Recently Viewed — the portal never opened this session — took the static path, found no token, and handed a same-host gated stream headers with no `Authorization`: a 403 on exactly the streams the header contract exists for. The same raw accessor cannot tell a token negotiated for a pre-edit identity from a current one. `StreamResolverService` now calls `ensureToken()` before building a static playback. It is the right primitive: handshake + `get_profile` with no link minted, identity fingerprint validated, concurrent callers deduped, and an immediate null for simple portals — and calling it keeps this change out of `stalker-session.service.ts`, which PR 6 (#1354) is splitting. Best-effort by design: a static URL may point at a CDN that needs no credentials, so a failed handshake degrades to the token-less header set instead of costing the user their playback. Both halves are pinned by tests, and removing the call makes the cold-start test fail. The portal routes need no equivalent and do not get one: an item cannot be selected before its catalog has loaded, and every catalog load authenticates. That reasoning is now written down rather than assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): warm the session at the choke point; keep downloads authenticated Two more Codex findings on #1364, and the first one shows my previous commit message reasoned too broadly. P1 — I claimed the portal routes are "structurally warm" because an item cannot be selected before its catalog loads. That is true of the routed portal views, but not of the global collection detail, which calls `setCurrentPlaylist()` and `setSelectedItem()` straight from a persisted row with no catalog load in between and then goes through the STORE playback path. A VOD opened from Favorites on a cold start therefore still played a same-host gated stream with no Bearer token. Rather than extend the per-route argument, the warm-up moved to the one place every static return passes through: `fetchStalkerPlaybackLink()` now calls the session before short-circuiting, covering ITV, VOD, radio and downloads at once. `StreamResolverService` keeps its own call — its static branch does not go through that function — but both now share a single primitive, `ensureStalkerSession()` in `stalker-request.utils.ts`, so the two routes cannot drift on when a session is required. Still best-effort, still outside `stalker-session.service.ts` (PR 6 territory). P2 — downloads cannot use that escape hatch at all: the main-process stored header allowlist is User-Agent/Origin/Referer only, no Cookie or Authorization, so a static same-host URL 401s where a minted one worked. `startStalkerVodDownload` now classifies the candidate with the shared `isStalkerStreamCredentialSafe()` and withholds the row — forcing `create_link` — for anything portal-owned. A CDN-hosted movie keeps the permanent URL that survives retry; a portal-hosted one keeps the minted URL that carries its own token. Both fixes mutation-checked: each reverted change fails exactly one test. Docs corrected, including the overreaching "structurally warm" claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): record the cached-token revalidation trade-off Codex flagged that the static path no longer self-heals a retired token, since `ensureToken` returns a same-identity cache entry without a network call — whereas `create_link` used to refresh it through `makeAuthenticatedRequest`'s auth-failure retry. The mechanism it posits does not exist on stock Stalker: per the 4.9.35 reference, handshake tokens have no TTL, and not sending the watchdog does not invalidate auth (it only clears the admin panel's "online" flag). The real residual vector is another device calling `get_profile` on the same MAC, which is common enough on shared subscriptions to be worth naming. Revalidating on every static playback would cost exactly the round trip this change removes, so it is deliberately not done. Recorded as a known trade-off with its mitigation (a running watchdog still self-heals within a ping cycle) and handed to PR 6, where a refresh on an OBSERVED playback authorization failure belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): tighten the token-revalidation trade-off wording Greptile review feedback: the watchdog mitigation was the most important part of that paragraph and sat behind the caveat. It now follows the MAC-sharing vector directly, and the paragraph ends by naming what is actually left uncovered — a same-host static stream played while no watchdog is up — so a future reader can size the residual without re-deriving it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): prefer the live playlist row over a stale favorite snapshot Codex P1 on #1364, and mine. `resolveStalker` reads its portal coordinates as `item.stalkerPortalUrl ?? playlist?.portalUrl` — item first. The create_link branch quietly corrected for that afterwards by re-reading `applyOverride(playlist).portalUrl`, so the row won wherever it existed, which is what the comment right above it already promised: "when the row exists it wins over the item's snapshot of the portal URL (a repaired endpoint must beat a stale favorite)". The static branch I added returns before that correction, so it shipped the stale snapshot. Consequences after a playlist edit: a same-host static URL matching the OLD host gets the newly negotiated token and identity headers sent to the previous portal, and a MAC-only edit pairs the new token with the old MAC cookie — precisely the pairing `stalkerIdentityFingerprint` exists to prevent. Both branches now derive the coordinates once, row-first with the repair override applied, and fall back to the item's snapshot only for a playlist that no longer exists — which is the role `buildStalkerPlayback` already documents for it. Mutation-checked: restoring item-first precedence fails the new test alone. Also documents a local-only e2e hazard found while re-running the suite: `mode: 'serial'` orders tests within one project, but chromium/firefox/webkit run the file concurrently against the same mock server, so one project's beforeEach reset can drop a session another is mid-test on — which is what a lone auth-spec failure that passes on rerun actually is. CI never sees it; the Web E2E job runs --project=chromium alone, and that command is clean (22/22). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): warm the session against the repaired portal configuration Found while auditing my own static branch against the create_link path rather than waiting for the next review round. `executeStalkerRequest` applies the lazy-repair override on its first line, so the create_link path always talks to the configuration a completed repair proved good. The session warm-up I added did not: it handed `ensureToken` the caller's pre-repair row, so a portal whose endpoint or mode had been repaired would handshake against the configuration the repair had already rejected — stranding the session precisely on the portals repair exists to rescue. The override now happens inside `ensureStalkerSession`, mirroring `executeStalkerRequest`'s first line, so every caller inherits the rule instead of each having to remember it. Mutation-checked: dropping the override fails the new test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): fall back to create_link when a portal-owned static url has no session Codex P1 on #1364. `create_link` was also the request that could FAIL, and a failure is what triggers the lazy portal repair. A playlist still misclassified as token-free, or pointing at an unrepaired endpoint, used to self-heal on that failure and then play; the static path issues no request, so nothing fires and the stream just 401s. Its suggested remedy — routing a skipped warm-up through `repairPortal()` — cannot be taken literally: a skipped warm-up is the NORMAL case for the many legitimately token-free reseller panels, and probing each of them on every playback would cost far more than the round trip this PR removes. What is decidable without a request is whether we are about to serve a stream we already know will fail. `ensureStalkerSession` now reports whether the session can serve credentialed playback — true for a portal needing no token and for one holding a usable token, false for a full portal left without one — and both static call sites act on it: - foreign-host URL: served regardless, it never needed the session; - portal-owned URL with a usable session: served, as before; - portal-owned URL with no usable session: falls back to `create_link`, which mints a URL carrying its own token AND re-enters the only path that can observe a failure and repair. That covers the unrepaired-endpoint half exactly. The misclassified-as-simple half stays open by construction — no request means no evidence, and "simple portal" is indistinguishable from "misclassified" without one. It belongs with the other reactive-repair work already handed to PR 6: refresh and repair on an OBSERVED playback authorization failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): require flag evidence before trusting a row as unflagged Two Codex findings on #1364. P1 — legacy persisted snapshots. Favorites and Recently Viewed rows saved before this change went through `buildStalkerSelectedVodItem`'s whitelist, which dropped both flags, and `buildStalkerFavoritePayload` spreads that whitelisted object. So a legacy row is flagless because WE stripped it, not because the portal said no — and the helper was reading it as "explicitly unflagged". With an absolute HTTP `cmd` from a load-balanced portal that meant playing a non-final URL. There is no migration or provenance marker for those rows. A stock portal returns both flags on every row, so their PRESENCE is itself the provenance signal, and it is the only one available without a refetch. `resolveStalkerStaticPlaybackUrl` now requires at least one flag key to be present; absence reads as "no evidence" and routes back to `create_link`, which is the pre-PR behaviour. This costs the optimization on panels that omit the flags entirely — the honest price for not being able to tell them apart from our own stripped rows. Radio is the one documented exception. It has always played a directly usable command without `create_link`, so a flagless radio row keeps that rather than newly minting — a portal whose radio `create_link` never worked would otherwise lose playback it has today. ITV and VOD have no such history and stay conservative. P2 — loopback range. IPv4 reserves all of `127.0.0.0/8`, so `127.0.0.2` was being handed to the player as a real address. Classified by range now, with a test that `127.0.0.1.cdn.example` is still treated as the ordinary hostname it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): classify every portal-local IPv6 placeholder Codex P2 on #1364, same class as the 127.0.0.0/8 one. `http://[::]/ch/1234_` and the IPv4-mapped loopback forms slipped past the exact-name set and would have been handed to the player as real addresses. Checked how `URL` actually normalizes these rather than guessing at the spelling a portal might use: brackets are kept, `[0:0:0:0:0:0:0:1]` collapses to `[::1]`, and an IPv4-mapped address is rewritten to hex — `[::ffff:127.0.0.1]` arrives as `[::ffff:7f00:1]`. The guard now strips the brackets, matches `::1` and `::`, and decodes the mapped form by its high byte, so the whole of the mapped 127.0.0.0/8 range is covered along with the mapped unspecified address. The dotted tail is still accepted for any engine that leaves it alone. Routable hosts are unaffected, pinned by tests for `[2001:db8::1]` and `[::ffff:203.0.113.7]`. Mutation-checked: dropping `::` and the mapped-IPv4 decode fails five tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize hostname and scheme spelling before the static verdict Two Codex P2s on #1364, both about trusting how a portal spells things. `http://localhost./ch/1234_` — a trailing dot is the DNS root and resolves identically, but `URL` keeps it for names while dropping it for IP literals (`127.0.0.1.` arrives bare, `localhost.` does not). The exact-name check read that as a remote host and would have pointed the player at its own loopback. Stripped before classifying. `HTTP://cdn.example/a.ts` — RFC 3986 makes the scheme case-insensitive. The case-sensitive tests failed SAFE, minting a link instead, but that defeats the contract for a portal that spells it this way, and one whose `create_link` cannot resolve an already-playable row would break. There were five such tests, and only one was on the new static path: the other three live in `resolveStalkerPlaybackUrl`, the create_link RESPONSE resolver, where `ffrt3 HTTP://…` failed to split its solution prefix and a query-only reply was appended to the portal base instead of to the command. That is pre-existing, but it is the same bug in the same shared normalizer, and fixing only the half this PR introduced would leave exactly the divergence this PR keeps removing. All five now go through one `hasHttpScheme()`. The response resolver had only indirect coverage, so it gains a direct spec alongside the static-path tests. Mutation-checked: reverting the dot strip and the case-insensitive scheme fails ten tests and nothing else. Also carries a docblock fix noticed on a read-through: the guard list still pointed at `PORTAL_LOCAL_HOSTNAMES` after the logic moved into `isPortalLocalHostname`, which now covers considerably more than that set. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): normalize DNS root dots in the shared credential classifier Codex P2 on #1364, extending the `localhost.` fix into `isStalkerStreamCredentialSafe()`. It compared hostnames literally, so a portal on `portal.example` serving `https://portal.example./movie.mkv` classified its own stream as third-party. Wider than the download guard it was reported against: this predicate is the single rule BOTH the renderer playback-header builder and the Electron main-process fallback use to decide whether a stream may carry the mac cookie and Bearer token. A portal-owned stream spelled with the root dot was getting the credential-free profile and would 401 — pre-existing, and exactly the "only VLC works" class this contract exists to prevent. My PR added two new dependencies on the same predicate (the download static guard and the portal-owned fallback), which is how it surfaced. Both sides are normalized, so it stays symmetric, and it can only widen toward "same host" — never toward handing credentials to a different one. A test pins that `evil.portal.example.` is still rejected. Also carries the authority guard found by probing the same class myself rather than waiting for it to be reported: `http:///ch/1` has no authority and `URL` quietly reinterprets the first path segment as the host, so a malformed command reached the player as a nonsense address instead of going to the portal. `isPlayableHttpUrl()` now requires a non-empty authority. The other exotic spellings I probed were already covered — `URL` canonicalizes `127.1`, `2130706433` and `0x7f000001` to `127.0.0.1`, uppercases and expanded IPv6 normalize too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(stalker): classify the static url before authenticating Codex P2 on #1364. Both static call sites awaited the session warm-up and only then asked whether the stream needed portal credentials at all — so a movie or channel on a foreign CDN paid for a handshake whose result was immediately discarded. That is not free: non-`create_link` requests carry a 15 s timeout (`stalker.events.ts`), so a portal that is slow or offline stalled playback of a stream the CDN would have served instantly. Cold Favorites/Recently Viewed starts are exactly where this bites, since that is where the session is not warm already. Classification now runs first. Foreign host returns immediately, portal-owned still warms and still falls back to `create_link` without a usable session. Behaviour is otherwise unchanged; only the order and the wasted wait are gone. Two tests moved with it: the foreign-host case now asserts the portal is not contacted at all rather than merely not asked for a link, and the repaired-endpoint case had been written against a foreign-host command, which under the new ordering correctly never reaches the handshake it was meant to be testing — it uses a portal-owned command now. Mutation-checked: restoring warm-before-classify fails the foreign-host test alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): repoint two handshake tests at the path they claim to cover Self-audit, prompted by the previous round: the reorder exposed one test that was asserting through a path it no longer reached, so I checked the rest of that class rather than assume it was the only one. Two more had the same defect, both mine. `still returns the static url when the handshake fails` (both specs) mocked `ensureToken` to reject, but used a FOREIGN-host command. Now that classification runs before authentication, that command returns before the handshake is ever attempted — the rejection was never exercised and the test passed on the early return instead of the mechanism in its name. Worse, the foreign case is already covered by the test added alongside the reorder, so these were asserting nothing new. Both now use a portal-owned command, which is what actually reaches the handshake, and assert what a throw really produces: `ensureStalkerSession` swallows it, the verdict is false, and the row falls back to `create_link` rather than being served as a known 401. Each asserts `ensureToken` was in fact called, so neither can silently drift back into testing an early return. Docs corrected with them: the "best-effort degrades to the token-less header set" wording described behaviour the reorder removed. A foreign-host URL is now returned before any handshake, and a failed one routes to `create_link`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): make the simple-portal skip test prove portal mode Fourth test found passing through the wrong exit, from auditing all ten in the block rather than waiting to trip over another one. `skips the handshake for a simple portal` used a foreign-host command, so the classification step returned before the warm-up was reached. `ensureToken` was indeed not called — but because the host was foreign, not because the portal was simple, and the assertion could not tell those apart. The command is now portal-owned, so the skip can only come from the mode, and the test also pins the returned URL and that no request was made. Mutation-checked properly this time: removing the simple-portal early return from `ensureStalkerSession` now fails this test. Under the old command it would not have. Also records the pattern where the next person will meet it. The decision chain has several exits — no flag evidence, unresolvable command, `series` set, foreign host, unusable session — and more than one can satisfy the same assertion, so a foreign-host command silently stands in for "simple portal" or "handshake failed". Mutation testing does not catch that class: it proves a test is coupled to its target, not that it reached the mechanism it names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): key the radio fallback on flag evidence, not snapshot presence Codex P2 on #1364, and a divergence I introduced myself. `withStalkerPlayer`'s radio branch checks `hasStalkerLinkFlagEvidence(item)` before synthesizing the zero flags. `StreamResolverService` used `??`, which only falls back when the snapshot is absent entirely. A radio Favorite or Recent row persisted before the flags were carried HAS a snapshot — the old whitelist just stripped the flags out of it — so the `??` selected that flagless object, the helper found no evidence, and the collection route began minting for exactly the rows that used to play directly. That breaks portals whose radio `create_link` is unsupported, which is the case the radio exception exists for. The two paths now apply the identical rule. The divergence came from fixing them in different rounds and is precisely the class this PR keeps closing, so the comment on each side now points at the other. The existing radio test carries no `stalkerItem` at all, so it exercises the missing-snapshot arm and stayed green throughout — the same "passes through a different exit" pattern documented in the section above. The new test supplies a present-but-flagless snapshot. Mutation-checked: restoring the presence check fails it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): treat every reserved localhost name as portal-local Codex P2 on #1364, the fourth in this class. RFC 6761 §6.3 reserves `localhost` AND every name ending in `.localhost` for the loopback interface, and resolvers honour it — so `http://stream.localhost/ch/1234_` reached the player's own machine instead of being sent to the portal to resolve. Closed the class rather than adding one more name: the suffix is matched, and `localhost.localdomain` goes in with it as the conventional `/etc/hosts` alias for 127.0.0.1 on most Linux systems. Together with the earlier rounds the predicate now covers `localhost` and `*.localhost`, `localhost.localdomain`, `127.0.0.0/8`, `0.0.0.0`, `::1`, `::`, the IPv4-mapped forms `URL` rewrites to hex, and a terminal DNS root dot on any of them. Only the suffix is reserved, so the guard must not over-match: tests pin that `localhost.cdn.example` and `notlocalhost` remain ordinary routable names and keep playing statically. Mutation-checked: dropping the suffix rule and the localdomain alias fails four tests and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
107 KiB
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, 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
<proposed_plan>, 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.mdsuch 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:
README.mdfor top-level developer or user workflowsdocs/architecture/for architecture, ownership, and behavior contracts- the nearest module
README.mdfor 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 affectedCLAUDE.mdsections as part of the same task, and keep the mirrored process sections inAGENTS.mdin sync. - When adding a new feature area, check whether the Architecture or Key Features sections of
CLAUDE.mddescribe the surrounding area; if they do, reflect the addition there instead of leaving the description stale. - Do not let
CLAUDE.mddrift: 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
<area>-<short-slug>.md;areamatches 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: internalrecords invisible maintenance. Internal notes stay collapsed inCHANGELOG.md, are omitted from the blog scaffold, and are removed from the authored public GitHub body byextract-changelog-section.mjs --public; GitHub's generated commit list remains separate, so an internal-only release can have an empty authored body.- 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/**orlibs/**, apply theno-release-notelabel. - CI enforces this: the "Release note gate" job in
.github/workflows/ci.ymlfails PRs that change runtime code without an added.changes/*.mdor the label (policy intools/release/check-release-note-gate.mjs; tests/e2e/website/mock-server/docs paths are auto-exempt). - The
release-notesskill covers writing notes; therelease-cutskill covers the full release sequence. - Validate before finishing:
pnpm run release:notes:validate. - Pushes to
masterandv*can publish Docker images. Av*tag build creates a draft GitHub release. - 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.
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:
- Run targeted unit tests for directly affected projects with
pnpm nx test <project>or existing scripts such aspnpm run test:frontend,pnpm run test:backend, orpnpm run test:unit:ciwhen the scope is broader. - Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
- Use
pnpm nx show projects --withTarget testandpnpm nx show projects --withTarget e2ewhen project ownership or available validation targets are unclear. - 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.tsorpnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts.
- Run targeted unit tests for directly affected projects with
- 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-browserand 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
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. - Use scoped path aliases from
tsconfig.base.jsonsuch 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, ordatabase. - Every Nx project should keep
scope:*,domain:*, andtype:*tags inproject.json. - See
docs/architecture/nx-workspace-boundaries.mdfor the current Nx tag and alias policy. - Keep
nxand every official@nx/*package on the same exact version; runpnpm run deps:nx:validateafter dependency updates. - 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
@useacross 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 inlibs/ui/styles(projectui-styles), and each consumer declares"implicitDependencies": ["ui-styles"]. Runpnpm run styles:inputs:validateafter adding a cross-project stylesheet import. - Update Nx with
pnpm nx migrate nx@<target> --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:validateafter editing a committed skill or a literal path it documents. - Keep
.codexand.claudecopies ofrelease-notesandrelease-cutbyte-identical.
Building and Serving
# 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
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
electronskill - 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 listshowsabout:blank, empty snapshots, black screenshots). Inspect targets withcurl http://127.0.0.1:9222/json/listand connect directly to the app page'swebSocketDebuggerUrl. - The app holds a single-instance lock (
acquireSingleInstanceLockinapps/electron-backend/src/app/services/single-instance.ts): a second launch against the sameuserDataquits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, setIPTVNATOR_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 toonSecondInstance, which is how a playlist path handed to an already-running app reaches the open queue.
For startup tracing or white-screen debugging:
IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend
Useful narrower flags:
IPTVNATOR_TRACE_IPC=1traces rendererwindow.electron.*bridge callsIPTVNATOR_TRACE_DB=1traces DB worker requests and DB progress eventsIPTVNATOR_TRACE_SQL=1traces SQLite statements in both main and worker connectionsIPTVNATOR_TRACE_WINDOW=1traces BrowserWindow navigation/load lifecycleIPTVNATOR_TRACE_PLAYER=1traces external-player activity and bounded Embedded MPV runtime-probe stderrIPTVNATOR_TRACE_RENDERER_CONSOLE=1mirrors renderer console logs into the Electron terminalIPTVNATOR_PERF_CAPTURE=1enables 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 unsetIPTVNATOR_PERF_WORKER_PROFILING=1enables 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:
pnpm nx reset
Use global agent-browser (preferred):
# 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:
npx --yes agent-browser --cdp 9222 tab list
Testing
# 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
# 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,**/*.e2e.tsand everything underapps/*-e2e/**— a spec is a flat list of independent cases, so splitting one at the production limit yields arbitrary-2.spec.tsfiles, and length there signals coverage rather than the design debt the production limit catches. - 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 -- <why> */; 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/<project>/**/*.ts". An unquoted ** is expanded by the POSIX
shell on Linux and macOS (which has no globstar, so it matches only a
shallow subset of files) while Windows passes the literal pattern to ESLint,
which expands it recursively — the two hosts then lint different file sets.
The target still reports success either way, so a broken glob hides missing
coverage instead of failing. After changing such a target, compare the linted
file count against find <project> -name '*.ts' | wc -l.
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,/stalkerCORS proxy endpoints) - 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 and blog
- 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)
- playlist/m3u/feature-player - M3U video player page and
/workspace/playlists/:idroutes - 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 inui;utilis 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/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 shared by the Xtream and Stalker marketing mocks
- shared/testing - Shared test helpers
- ui/components - Reusable UI components (incl. channel list)
- ui/epg - EPG UI (timeline ribbon, multi-EPG, 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 & pagination
│ │ │ ├── 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.tsusessignalStoreFeature()for focused functionality - Facade pattern:
XtreamStorecomposes all features, maintaining backward compatibility - Data source abstraction:
IXtreamDataSourcehas SQLite-backed and API/in-memory implementations - Factory injection:
provideXtreamDataSource()selectsElectronXtreamDataSourceonly whenRuntimeCapabilitiesService.supportsXtreamSqliteDataSource; otherwise it selectsPwaXtreamDataSource
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.
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 M3Uradioattribute) - The audio player always renders inline —
shouldShowInlinePlayeris bypassed for radio - EPG panel is conditionally hidden in the template when radio is active
- Volume is shared with video player via
localStoragekey'volume' - Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute
- Component:
libs/ui/playback/src/lib/audio-player/audio-player.component.ts
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, parsePlaylistChannelActions: setChannels, setActiveChannel, setAdjacentChannelAsActiveEpgActions: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlagFavoritesActions: 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 inlibs/playlist/m3u/feature-player - Xtream Codes:
/workspace/xtreams/:id(children:live,vod,series,search,actor/:personId,recently-added,favorites,recent,downloads) —libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts - Stalker portal:
/workspace/stalker/:id(children:itv,vod,radio,series,favorites,recent,search,actor/:personId,downloads) —libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts - Global collections:
/workspace/global-favorites,/workspace/global-recent - Global search:
/workspace/search(Electron-only; a guard redirects the PWA to/workspace/sources) - Downloads:
/workspace/downloadswith focused/workspace/downloads/:downloadId; source-scoped equivalents are/workspace/xtreams/:id/downloads/:downloadIdand/workspace/stalker/:id/downloads/:downloadId. Focused download details hide the workspace context panel. - Settings:
/workspace/settings(/settingsredirects there)
Service Architecture (Factory Pattern):
- Abstract
DataServiceclass inlibs/services/src/lib/data.service.tsdefines the contract - Two environment-specific implementations:
ElectronService(apps/web/src/app/services/electron.service.ts) - Uses IPC to communicate with Electron backendPwaService(apps/web/src/app/services/pwa.service.ts) - Uses HTTP API and IndexedDB for standalone web version
- Factory function
DataFactory()inapps/web/src/app/app.config.tsdetermines which implementation to inject:if (window.electron) { return inject(ElectronService); } return inject(PwaService);
Data Storage (Environment-Specific):
- Electron: SQLite database via Drizzle ORM (
better-sqlite3driver)- Location:
~/.iptvnator/databases/iptvnator.db - Full-featured relational database with foreign keys and indexes
- Canonical schema and connection live in
libs/shared/database
- Location:
- 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, **/*.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,@ContentChildrendecorators// ✅ Correct - Signal-based readonly menu = viewChild.required<MatMenu>('menuRef'); readonly items = viewChildren<ElementRef>('item'); // ❌ Incorrect - Old decorator syntax @ViewChild('menuRef') menu!: MatMenu; @ViewChildren('item') items!: QueryList<ElementRef>;Important: When using signals in templates with properties that expect non-signal values, unwrap the signal by calling it:
<!-- ✅ Correct - Unwrap the signal --> <button [matMenuTriggerFor]="menu()">Open Menu</button> <!-- ❌ Incorrect - Signal not unwrapped --> <button [matMenuTriggerFor]="menu">Open Menu</button> -
Component Inputs/Outputs: Use
input()andoutput()functions instead of@Input()and@Output()decorators// ✅ Correct - Signal-based readonly title = input.required<string>(); readonly size = input<number>(10); // with default value readonly clicked = output<string>(); // ❌ Incorrect - Old decorator syntax @Input({ required: true }) title!: string; @Input() size = 10; @Output() clicked = new EventEmitter<string>(); -
Reactive State: Use signal primitives for reactive state management
// ✅ 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)@HostBinding('class.active') get isActive() { return this.active(); } @HostListener('click') onClick() { /* ... */ } -
Control Flow: Use
@if,@for,@switchinstead of*ngIf,*ngFor,*ngSwitch// ✅ Correct - Modern syntax @if (isLoggedIn()) { <p>Welcome!</p> } @for (item of items(); track item.id) { <li>{{ item.name }}</li> } // ❌ Incorrect - Old syntax <p *ngIf="isLoggedIn">Welcome!</p> <li *ngFor="let item of items; trackBy: trackById">{{ item.name }}</li>
Backend Architecture (Electron)
Main Entry: apps/electron-backend/src/main.ts
- Bootstraps Electron app and initializes database
- Registers event handlers for IPC communication
- Holds a single-instance lock (
app/services/single-instance.ts), requested after theuserDataoverride 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=1opts out for local debugging. The guard also forwards that launch's argv and working directory, soiptvnator playlist.m3uagainst 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.tsis a backwards-compat re-export shim):playlists- Playlist metadata (M3U, Xtream, Stalker)categories- Content categories (live, movies, series)content- Streams/VOD/series itemsfavorites- User favoritesrecentlyViewed- Watch historyepgChannels,epgPrograms- Persisted EPG dataepgChannelMappings(epg_channel_mappings) - Manual EPG channel mappings (defined inepg-mapping.schema.ts, re-exported byschema.ts)playbackPositions- Resume positionsdownloads- Download manager stateappState- 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 invod-source-pins.schema.ts, re-exported byschema.ts)
- Connection:
libs/shared/database/src/lib/connection.tscreateTables()auto-creates tables on init (CREATE TABLE IF NOT EXISTS)- Provides full read-write access for
electron-backendand a read-only mode - A root
drizzle.config.tsconfigures 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.electronAPI viacontextBridge - All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)
- The canonical TypeScript contract is
ElectronBridgeApiinlibs/shared/interfaces/src/lib/electron-api.interface.ts;global.d.ts,apps/web/src/typings.d.ts, andmain.preload.tsmust reference this shared type instead of maintaining separate method lists.
- Exposes
- Event handlers:
apps/electron-backend/src/app/events/database.events.ts- Database CRUD operationsplaylist.events.ts- Playlist import/updateplaylist-open.events.ts- Playlist files handed over by the OS (argv, file association, macOSopen-file); the queue itself lives inservices/playlist-open-request.tsepg.events.ts- EPG IPC registration; freshness/fetch orchestration lives inepg-fetch.service.ts, manual channel-mapping resolution and CRUD inepg-mapping.service.ts, worker lifecycle inepg-worker.service.ts, DB lookups inepg-query.service.tsxtream.events.ts- Xtream Codes APIstalker.events.ts- Stalker portal APIplayer.events.ts- External player IPC registration; MPV/VLC lifecycle logic lives inmpv-session.service.ts,vlc-session.service.ts, and sharedexternal-player-*helperssettings.events.ts- App settingselectron.events.ts- App version, etc.
Workers (apps/electron-backend/src/app/workers/):
- EPG parsing:
epg-parser.worker.ts; main-process worker lifecycle is coordinated fromapps/electron-backend/src/app/events/epg-worker.service.ts - Non-EPG SQLite work:
database.worker.ts(seedocs/architecture/sqlite-db-worker.md) - Playlist refresh:
playlist-refresh.worker.ts; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledgingPLAYLIST_CANCEL_REFRESH(seedocs/architecture/m3u-playlist-module.md)
Key Features
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:
- Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer
- mpegts.js
1.8.0errors from all three built-in players cross one version-locked structured evidence boundary. It retains only exact public type/detail pairs, pair-derived stage/failure, terminal disposition, and a validated HTTP 4xx/5xx status; raw messages and arbitraryinfonever reach stored or rendered diagnostics. HTTP/network failures avoid false decoder recommendations, while exact format, codec, truncated-stream, and MediaSource failures retain actionable fallback guidance. This diagnostic layer remains separate from the sharedPlayerControllercontrols contract. - DASH + ClearKey (M3U module):
.mpdchannels play through a lazily loaded Shaka Player source engine inside the HTML5 and ArtPlayer components (no new player in settings). ClearKey keys come from#KODIPROP:inputstream.adaptive.*lines, post-processed intoChannel.drmbyextractDrmFromRaw()inlibs/shared/m3u-utils(hooked increatePlaylistObject(), covering all import paths). DASH channels always play inline:isDashChannel()bypasses the external-player setting (radio precedent) and routes Video.js/MPV/VLC/ embedded-MPV users to the HTML5 player viaplayerOverride(ArtPlayer keeps ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope, need the castLabs Electron fork) surface a DRM playback diagnostic instead of crashing. ClearKey EME works in stock Electron. Engine:libs/ui/playback/src/lib/shaka-engine/. Its Shaka5.2.2diagnostic boundary version-locks public severity/category/code evidence, ignores recoverable error events, treats rejected loads as terminal lifecycle outcomes, preserves exact public DASH text-parser category/code evidence with unknown stage/failure, and never retains or renders raw messages orerror.data. A failed browser-support preflight stays unknown but keeps external fallback for clear DASH; KODIPROP DRM still suppresses it. Details indocs/architecture/m3u-playlist-module.md("DASH + ClearKey Playback"). - External players: MPV, VLC (via IPC to Electron backend)
- Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an
NSOpenGLView; Windows uses in-process libmpv with--widagainst an app-owned childHWND; Linux spawns an out-of-processmpv --wid=<x11-window>controlled over a JSON IPC socket (X11/XWayland only, requires systemmpvon PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, soEmbeddedMpvNativeServiceholds an ElectronpowerSaveBlocker(prevent-display-sleep) whenever any session's status isplaying, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (embedded-mpv-bounds.util.ts: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds whendevicePixelRatiochanges. Service:apps/electron-backend/src/app/services/embedded-mpv-native.service.ts; full architecture:docs/architecture/embedded-mpv-native.md. - Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux
x64 + Windows; enabled via
Settings > Playback > Embedded MPV: frame-copy engine(restart required) orIPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen (CGL on macOS, EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the preload frame pump uploads them to<canvas data-embedded-mpv-frame>. Sharedapp-player-controlsowns the DOM UI; native-view retains the legacy dock. On Linux, onlyiptvnator_mpv_helpermay link libmpv; Electron, its shipped libraries, the addon, and frame reader must not. Pristine afterPack/unpacked layouts scan Electron libraries recursively; extracted Snap payloads exclude only the package-managerlib/**andusr/lib/**trees overlaid into the same root. Every other directory remains recursive, and Electron-library symlinks still fail closed.electron-backend/native{,/**/*}is excluded fromapp.asar;afterPackalone owns the profile-normalized unpacked native tree, and package checks reject every archived/electron-backend/native/**entry. Packaged addon, frame-reader, and helper discovery uses only package-ownedapp.asar.unpackedpaths; cwd/dist candidates remain development-only. Official x64 packages use three separate profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and Flatpak bundles the same closure. Flatpak is an isolated packaging pass and keepsiptvnatoras the real Electron ELF so Electron Builder'selectron-wrapperpasses it directly to Zypak. Other Linux targets retain the conditionaliptvnatorwrapper andiptvnator.bin. Mixed Flatpak/non-Flatpak target sets fail before mutation. Exact system dependencies are DEB=libmpv2,libegl1,libgl1,libgbm1, RPM=mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm, and Pacman=mpv,libglvnd,mesa. The DEB contract is verified on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy provideslibmpv1. ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the fail-closed packaged manifest/file/hash gate and bounded--runtime-probe; any failure keeps the sandbox enabled, records a stable reason, and falls back to native-view without crashing. Snap iscore22/strict and uses an exact privateshared-memoryplug plus thegraphics-core22content plug at a real empty mode-0755$SNAP/graphics, with externalmesa-core22as the default provider. Its only provider-data layouts bind/usr/share/libdrmfrom$SNAP/graphics/libdrmand symlink/usr/share/drirc.dto$SNAP/graphics/drirc.d. Installed-Snap CI requires controlled unavailable status after disconnect, then reconnects and requires success. The helper linkslibGL.so.1, and probe/playback share a sanitized loader environment in which ambient audit, preload, library, graphics-driver, and shell-startup overrides are removed; the validated private closure plus trusted host GL, graphics-content, core22 base x64, and exact GNOME-platform roots have explicit precedence. The core22 base stays ahead of GNOME so the olderlibedit.so.2requiringlibtinfo.so.5cannot shadow the base ABI. The extracted-artifact verifier removes the identical unsafe loader/graphics/ shell set before direct helper smoke while preserving selectors such asLIBGL_ALWAYS_SOFTWARE. Snap fixes the wrapperPATH, removes exportedBASH_FUNC_*functions, and launches probe/playback through the regular executable$SNAP/graphics/bin/graphics-core22-provider-wrapper; a missing or disconnected provider returnssnap-graphics-provider-unavailablebefore helper spawn. The packaging-only--embedded-mpv-runtime-probeapp switch runs the complete packaged gate before BrowserWindow startup and emits one availability JSON line. A nonzero helper exit keeps top-level reasonhelper-probe-failed;helperReasonis present only for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optionalhelperDetailmust be 1–1024 printable ASCII characters. Invalid detail suppresses both helper fields. Every probe uses an explicit 16 MiB aggregate captured-output ceiling independent of tracing. WithIPTVNATOR_TRACE_PLAYER=1, non-empty helper stderr is emitted separately as one JSON-escaped stderr line with a 16,384-characterstderrlimit and an explicittruncatedfield; trace-write failure cannot change availability. Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded channel. The exact packaged Flatpak/appcontext reconstructs only Freedesktop Platform 24.08's immutable__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS; its CI smoke invokes that application-level probe instead of the helper directly. The packaged x64 Playwright smoke runs its fixture-contract target first and passes Chromium--ignore-gpu-blocklistso CI llvmpipe exposes WebGL2; this does not bypass the runtime gate, and--no-sandboxremains root-only. Bundled Linux packages carry hash-validatedembedded-mpv-notices.json,THIRD_PARTY_NOTICES.txt, andlicenses/**. CI caches the staged runtime plus immutable source inputs, never finished notices or the compliance tarball; it regenerates those notices and the VCS-metadata-freelinux-frame-copy-runtime-sources.tar.xzfor the current checkout while preserving the exact pinned six recursive libplacebo submodule records. Each record is canonicalfull-commit safe/path; clone-depth dependentgit describeannotations are discarded and never form part of the provenance identity. Its source index carries the globally sorted libplacebo directory/file/symlink inventory; file hashes, sizes, executable bits, link targets, aggregates, and canonical tree digest must match the trusted pinned checkout. The archive has an exact member/type layout and itsmetadata/archive-sha256.txtrecords must match the actual source archives. Concatenated tar/xz streams are inspected past every end marker. Every bundled x64 package manifest binds the final archive's SHA-256 and repository revision; system and marker-only packages do not carry that binding. Snap Store publication runs only from a publicv*GitHub release that already contains the Snap assets and exactly one source archive. Before any upload, the workflow hashes and checks the archive's exact member/type set and size bounds, verifies its clean tag revision, pinned sources including the six recursive submodule records and exact libplacebo tree digest, legal payload, and exact released tooling, then performs bounded extraction and static validation for every Snap. That public-release boundary independently revalidates the exact strictmeta/snap.yamlgraphics/shared-memory contract and enumeratesresources/app.asar, rejecting any archivedelectron-backend/native/**payload before publication. Its bounded ASAR header reader uses only Node built-ins and released local tooling, so the clean tag checkout does not requirenode_modules. Exactly one x64 Snap must have matchingsourceArchiveandsourceRuntime; any non-x64 Snap remains marker-only. Checkout and artifact-transfer actions are pinned to full commits; checkout does not persist credentials, and repository credentials are scoped to download steps. A secretless verification job copies assets through no-follow descriptors, checks them before and after inspection, writes an exact receipt, fully reverifies a root-owned read-only snapshot, and transfers only that data through the pinned artifact service while passing the receipt digest separately through a job output. The dependent publish job uses a boundedubuntu-latestrunner with no checkout or release-tag code, verifies that digest plus the exact receipt, asset hashes, and file-only layout, root-seals the data again, and installs Snapcraft directly. Its final fixed shell step alone receives the Store credential, resolves no PATH command, executes no released code, and exposes that credential only to each exact/snap/bin/snapcraft upload --release=edgeprocess. Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke; GitHub Actions never promotes automatically. On Windows, package validation requires the exact MPV DLL named by the helper's PE import table beside the executable. Backend adapter:apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts; shared-controls adapter:libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts; helper:apps/electron-backend/native/helper/; canonical packaging/runtime contracts:docs/architecture/embedded-mpv-native.mdandtools/embedded-mpv/README.md. - Shared player-controls layer:
libs/ui/playback/src/lib/player-controls/exports the engine-neutralPlayerControllercontract, standaloneapp-player-controls, a generic web-video adapter/helper, and component-scopedWEB_PLAYER_SHARED_CONTROLSrollout token. In fullscreen,app-player-controlsshows a pointer-transparent media-title overlay at the top while controls are revealed (mediaTitleinput: movie/channel/series name, plus anS01E03second line for episodes; series names flow from the detail views throughPortalInlinePlayerComponent.seriesTitleandWebPlayerViewComponent.mediaTitle). PersistedSettings.webPlayerSharedControlsis default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected.WebPlayerViewComponentsnapshots the preference into the immutable token for each new player host. The parent/workspaceroute awaits the initialSettingsStoreload, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls throughEmbeddedMpvControlsAdapter, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine.showControls=falsedetaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer:HtmlVideoPlayerComponentprovides a component-scopedWebVideoControlsAdapter, while its neutralweb-video-supportbridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup.HtmlVideoElementSessionowns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer:VjsPlayerComponentprovides a component-scopedWebVideoControlsAdapter; its bridge rebinds the current Tech video afterplayerreset, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer:ArtPlayerComponentprovides a component-scopedWebVideoControlsAdapter;ArtPlayerSourceSessionowns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayedcustomTypecallbacks, whileArtPlayerVideoSessionowns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership.WebPlayerViewComponent.resolvedIsLivesupplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation.Settings.showCaptionsis deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (WebVideoSourceTracksfor HTML5/ArtPlayer,VjsLegacyTracksfor Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives viasetSubtitleTrack), while vendor chrome is source-default — the preference seeds each new source and is released once the media reportsplaying, so the engine's own caption menu keeps working. Mode selection is the optionalplaybackStartedprobe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (subtitleTrack = -1) rather than hiding, sincesubtitleDisplaywould override the vendor menu, and DASH is seeded byShakaVideoSession.start()after the manifest loads.WebPlayerViewComponentreads it fromSettingsStoreinstead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract:docs/architecture/player-controls-contract.md. - Shared web picture-in-picture stays inside that default-off rollout.
PlayerControllerexposes capabilitypictureInPicture, statepictureInPictureActive/canPictureInPicture, and commandtogglePictureInPicture(). HTML5, Video.js, and ArtPlayer use standard element PiP from the adapter's attached video; shared ArtPlayer keeps vendorpip: false, while preference-off native/vendor paths remain unchanged. The capability-gated button sits before fullscreen and uses active enter/exit semantics; entry is disabled until metadata, and the action is disabled while an operation is pending. Embedded MPV reports capability/state false with a no-op command and has no popup/mini-window. WebVideoControlsAdaptersupplies its current video and binding generation toWebVideoPictureInPictureController; the controller reads the video'sownerDocument, while browser enter/leave events remain authoritative. Exact-owner exit stays available if request support changes. Request/exit invocation remains synchronous for user activation, one operation is serialized, and binding generation plus exact video identity protects replacement and teardown from stale completion. Video.js Tech reset and ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a retained target preserve PiP. Standard PiP shows the browser/OS video surface without Angular control chrome, with browser-dependent subtitles. AirPlay, Cast, Document PiP, a PiP keyboard shortcut, and Embedded MPV popup/native support are out of scope.
Download Manager:
- Fresh Xtream movie and series-episode downloads propagate the playlist's
User-Agent, Referer, and Origin, defaulting User-Agent to the same
provider-compatible
XTREAM_CLIENT_USER_AGENTused by API requests and stream probes. Retry, resume, and missing-file recovery also add the fallback to legacy Xtream rows that have no stored User-Agent. Because download rows survive source deletion, a headerless legacy row whose playlist is already absent receives the same IPTV-player fallback; a known Stalker row remains unchanged. Allowlisted connection resets after bytes reach disk retain the partial and show a credential-safeDOWNLOAD_NETWORK_INTERRUPTEDcode only when the response supplied a strong ETag or Last-Modified validator. Retry then continues with Range/If-Range; without a validator it starts from byte zero and overwrites the unverified partial instead of risking mixed-representation corruption. - The desktop-only manager shares one global download store across the global, Xtream-scoped, and Stalker-scoped routes. Completed movie and grouped-series cards use the global Small/Medium/Large cover-grid tokens; missing completed files move to Needs attention instead of remaining in Ready to watch.
- Series details route individual and selected-season episode downloads through
the provider-neutral
SeasonDownloadCoordinator. It reserves per-episode pending identities synchronously, submits season candidates sequentially and best-effort through the existingDOWNLOADS_STARTpath, performs one final authoritative refresh after added or stable duplicate submissions, and reports added, skipped, and failed counts. Xtream and Stalker adapters remain responsible for provider URLs, headers, and metadata; the backend still runs one active transfer with a FIFO queue.DOWNLOADS_STARTremains the sole start IPC. A reserved completed-missing match triggers one authoritative preflight refresh before provider preparation. Download-list loads are serialized as one active IPC plus one coalesced trailing refresh; a preflight assigned to that trailing refresh cannot be starved by later progress broadcasts. A restored Stalker file can therefore become a stable skip without a portal request. The IPC's stablereason: 'already-in-progress'andreason: 'already-downloaded'results are counted as skipped, and no batch IPC is introduced. The latter comes from an asynchronous main-process filesystem recheck before a completed-missing row can be reset, so a file restored after the renderer snapshot is not orphaned or downloaded again. The recheck has a one-second caller deadline that starts before shared-slot acquisition; timeout or probe failure leaves the row untouched and reports a failed submission so the season loop can continue. Completed-file list callers use the same deadline and report a timeout as missing for that snapshot. The underlying filesystem operation remains coalesced and charged against the four-probe cap until it settles, so later callers have independent bounded waits without duplicating stalled native work. OnlyENOENTandENOTDIRprove absence; permission, I/O, and other filesystem errors remain unknown and cannot clear a completed row. Before a completed-missing, failed, or canceled row clears its retained path, the start IPC asynchronously removes any.partthrough a separate, same-path-coalesced, four-operation cap. A one-second admission deadline rejects queued work before unlink starts; started work is awaited so it cannot mutate after a failure response. Non-absence errors keep the row's ownership intact;ENOENTandENOTDIRsafely proceed. Episode and season download actions require an authoritative global list. A successful snapshot remains authoritative while a later background refresh is in flight; a latest refresh failure leaves loading/empty-state resolution intact but disables starts until another snapshot succeeds. Overlapping download-list callers join one serialized trailing refresh, so responses commit in request order and frequent progress events cannot perpetually postpone a waiting series action. - Episode ownership uses normalized
episode.idas the canonicalxtreamIdfor both providers; Stalker playback identifiers only resolve the URL. Exact(playlistId, contentType, xtreamId)matches are authoritative, while complete playlist/series/season/episode coordinates are a fail-closed legacy fallback that migrates reusable rows to the canonical id. Numeric season zero, including fallback key"0", remains a valid Specials coordinate for both providers. Stalker persistsepisode_identity_scopeseparately for regular/series, embedded VODseries[], and lazy Ministra VODis_series. Known different scopes do not match; a pre-scope coordinate row is ambiguous and blocked, while an exact canonical legacy row remains authoritative. Renderer lookup preserves that ambiguity or conflicting ownership as a distinct ineligible state, so neither the episode action nor the season count treats it as a row-less download. SQLitenulland optionalundefinedcoordinates both mean an incomplete canonical legacy row, matching the backend resolver. Pending and active rows plus completed available/unknown rows are skipped; failed, canceled, completed-missing, and unambiguous row-less episodes remain eligible. - Ready movie and grouped-series cards open a focused local detail. Movies play
the finalized local file; series list only locally available episode rows and
every episode action targets its own downloaded file. Focused routes disable
route search and use
contextPanel: 'none'. - Downloads capture a versioned metadata snapshot from the rendered Xtream or Stalker movie/episode detail at start time, including already-merged TMDB fields. Legacy, sparse, stale, or wrong-language snapshots are safely backfilled from row/provider metadata and optional TMDB enrichment when the focused detail opens.
View in portalresolves a concrete Xtream category/item route. Stalker accepts a recently-viewed shape only when its raw movie/series mode matches the download, and prefers an exact numeric category from the download snapshot. Without that shape, only a movie carrying an exact category can form a metadata-only target; unproven episode and legacy-movie handoffs stay unavailable. The normal detail uses one-shotprovider-onlypresentation: it exposes provider content/playback it can resolve while hiding Offline/local/download actions.- Download rows and local files survive source deletion. The global offline library remains visible with no playlists; only provider handoff is disabled until the source exists again.
- If a finalized file disappears while a focused detail is open, the authoritative download list refreshes and returns to the manager. A failed redirect leaves an actionable missing-file state with Back and Retry.
- Canonical contract:
docs/architecture/download-manager.md; provider handoff:docs/architecture/portal-detail-navigation.md.
VOD/Series Detail Pages (two-state layout):
- Xtream and Stalker detail pages use the shared
PortalDetailShellComponent(libs/ui/components/src/lib/portal-detail-shell/) with two states: Browse (hero with poster/metadata/actions, episodes below) and Watch (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes) - The inline player (
PortalInlinePlayerComponent) renders a full-width theater stage (.player-shell__viewport): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-inplayerAmbientModesetting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style) - For inline series playback on wide windows the stage instead docks the player left and shows an "Up Next" episode rail in the leftover column (
app-up-next-railinlibs/ui/playback/src/lib/portal-inline-player/): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by theplayerUpNextRailsetting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill - Watch state derives from
inlinePlayback() !== nullonly; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host'sgoBack()) - Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse
get_vod_infodata keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve frommovie_dataor the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the providerxtream_idcarried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back throughinfo,movie_data, then catalog fields. - A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to
Play episode N; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort. - Stalker preserves this contract for regular
/series, embedded VODseries[], and lazy Ministra VODis_seriesitems;is_seriesis normalized only fromtrue,1, or'1'. Quick-start translation parameters must reach the CTA, and inline/external episode handoffs must include the parent series id plus resolved season and episode numbers. Lazy VOD episode tracking IDs scope the parent series, provider episode, season key, and episode number; the previous season/episode hash is only a compatibility alias. Exact scoped positions win, while compatible legacy rows are considered only for the current parent and must match any stored season/episode coordinates. The scoped row is persisted through the strict failure-propagating boundary before confirmed legacy cleanup, so a failed save keeps the old row; compatibility is lazy and performs no schema migration or bulk rewrite. - Hosts pass hero chips/meta/actions as
*appDetailTags/*appDetailMeta/*appDetailActionstemplates; the shell stamps them into both the hero and the About block - Seasons are tabs (
SeasonTabsComponent, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the sameseasonSelectedlazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come fromget_series_info(Xtream) or TMDB (Stalker) - Dashboard hero/Continue Watching clicks for an Xtream series carry a one-shot resume target through the global-recent inline-detail handoff; after series metadata and playback positions load, the exact saved episode starts at its stored position. A failed positions load leaves the target unconsumed and the handoff detail-only, so a transient storage error never starts the episode from the beginning. Ordinary global-recent grid clicks remain detail-only.
- See
docs/architecture/embedded-inline-playback.md("Two-State Detail Layout")
VOD Multi-Source (alternative sources for a movie):
- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a
.source-captionline reporting where playback is coming from. The chip opens a 660px anchored CDK-overlay popover (libs/ui/components/src/lib/vod-sources/; notMatMenu, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlayminHeightremains above; filter chips (All / Available / HD+ / language-prefix select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (VodSourceProbeCacheService). Both chips are handed the samematchKindandvodAutoFailoverand both write the setting back. The details-page chip badge counts TOTAL copies across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct playlists viaalternativePlaylistCount, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover). - Scope v1 is Xtream ↔ Xtream, movies only, Electron only. Stalker never reaches the
contenttable and M3U is a JSON blob whose search forcescontent_type:'live'; both are additive later sinceVodSourceCandidate.portalTypealready carries all three. In the PWA every entry point is gated off by a bridgetypeofcheck and the chip renders nothing. - Metadata provenance is the core contract. Every field is
{value, provenance}whereapi/probeare facts (plain tag),parsedis a title-regex guess (tag prefixed~, warn colour), and absent renders no tag at all plus acheckchip.factualOnly()invod-source-metadata.util.tsis the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision.VodSourceProbeStatusseparatesfail(contacted and refused) fromunknown(timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel width because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carryingapiprovenance. The route's OWN row is never resolved, so it takes its facts from theget_vod_infothe page already loaded (providerVodMetadataOf, shared with the resolver) and picks them up viarefreshRouteFacts()even when they arrive without changing the movie identity — otherwiseaudioDiffersFactuallyhas nothing on one side and the dub warning cannot fire on a route-to-alternative switch. - Discovery (
DB_FIND_TITLE_SOURCES, trigram FTS overcontent_title_fts) is lazy and returns only what thecontenttable can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded in SQL and duplicates collapse there too (GROUP BY cat.playlist_id, c.xtream_idbefore the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*') ordered by title length with no row limit — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers:normalizeTitleKeysstrips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an exact match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded byLOWER()(ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), socaseInsensitiveGlobPatternfolds the case in JS and emits one[lowerUpper]class per character — returningnull, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (ß→SS). The movie's own year comes fromreleaseTagYear(bracketed or trailing only), neverextractYear: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (keepContentId), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check becausecontentstores nocontainer_extensionandconstructVodUrlreturns''without one — each alternative costs a liveget_vod_infoagainst the foreign playlist's credentials. - Switching = one
inlinePlayback.set({...next, startTime}), never null-then-set, so the player and engine survive and re-seek. The carried position is read before the 15s persistence throttle, andVodDetailsPlaybackServiceuses a one-shotresumeSettledlatch so a resuming engine'stimeupdateat ~0 cannot overwrite the resume point.handleInlineTimeUpdatereturns that verdict and the route feeds multi-source the requestedstartTimeuntil the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (seedResumeSeconds, one-way: a live value always wins). Portal failures in the multi-source path log through the redactingcreateLogger/redactSensitiveData— an Xtream error message carries the stream URL, and that URL is built out of the username and password. - Pins are keyed portal-agnostically (
tmdb:{id}elsetitle:{base}:{year}else the yearlesstitle:{base}:,vod_source_pinstable); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (pinKeysFor):lookuppasses every alias most-trusted-first,writeholds only keys naming exactly one film, andloadedrecords where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under every key inwrite(setVodSourcePin(db, pin, retireKeys, aliasKeys): one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enrichedtmdb:key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin id rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, soVodDetailsPlaybackBindings.activeSourcefeeds oneownsContent()predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys:vodMultiSourceMovieKey(title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, whilevodMultiSourceSessionKey(playlistId:contentId) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them. - Claims in the present tense (the "Playing from" caption and the source row's
Playingbadge) are gated onVodDetailsRouteComponent.playbackLive, never onisActive— discovery marks a source active before anything plays and it stays active after the player closes. Inline that means atimeupdatehas arrived (inlinePlayback()is only the request to play); external it means the session is pastlaunching. A merely selected row readsCurrent. - Pins are included in playlist backup as the optional
sourcePinscollection, carried under the playlist they point at;matchKeysurvives untouched and only the playlist id is remapped on restore (older archives simply lack the field). - Auto-failover is
Settings.vodAutoFailover, opt-in and off by default, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (reportsPlaybackFailures()); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (triedSourceIdsonly grows), so it terminates structurally — but SELECTION is not an attempt:setActiveSourceonly selects,markPlayingspends the turn, andrunFailoverretires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one —switchToreports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (throughplaylistDisplayLabel, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state a spoken language as fact —audioLanguage, neveraudio. The latter holds the codec whenever the fact came from the API, and a codec cannot answer that question: AAC and AC3 routinely carry the same dub while two AC3 tracks can carry different ones, so comparing codecs fired on identical-language re-encodes and stayed silent on real dub changes. Few panels tag a language, so the warning is usually silent — which is the honest state. - HEAD probe reuses the main-process handler extracted to
apps/electron-backend/src/app/events/stream-probe.ts(STREAM_PROBE_URL;XTREAM_PROBE_URLstill delegates there for catchup), and carries the playlist's ownuserAgent/referer/origin(StreamProbeHeaders) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled. - See
docs/architecture/vod-multi-source.md
Radio Player:
- Dedicated audio player for channels with
radio="true"M3U attribute - Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls
- Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio
- EPG panel is hidden for radio channels (radio streams have no EPG data)
- Volume synced with video player via shared
localStoragekey'volume' - Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)
- Component:
libs/ui/playback/src/lib/audio-player/audio-player.component.ts
EPG (Electronic Program Guide):
- XMLTV format support
- Background parsing in worker thread
- Stored in database for quick lookup
- Manual EPG mapping (Electron only): right-click a channel in any list (M3U views, Xtream portal list, Stalker ITV sidebar, global favorites) → "Map EPG channel" attaches it to an uploaded-XMLTV channel; stored in
epg_channel_mappingskeyed by the M3U lookup key or a playlist-scoped portal key (xtream:{playlistId}:{id}/stalker:{playlistId}:{id}, helpers inlibs/shared/interfaces/src/lib/epg-mapping-key.util.ts); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog:libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/
TMDB Metadata Enrichment (opt-in):
- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with
ru-RUso exact-title matching works - "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (
libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts,normalizeTitleKeys); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (CrossPortalSimilarServiceinlibs/services, batchedDB_MATCH_TITLES, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation - Season/episode enrichment: opening a season lazily fetches
/tv/{id}/season/{n}and overlays real episode names, overviews and stills viamergeEpisodesWithTmdb(Xtream:XtreamStore.enrichSelectedSerialSeason; Stalker: overlay in the series view'smappedSeasons); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (resolveEnrichmentSeasonNumberinlibs/shared/interfaces/src/lib/season-marker.util.ts) - Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched
DB_MATCH_TITLESrequest; Electron-only,dashboardRails.tmdbTrendingtoggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) —DashboardTrendingServiceinlibs/workspace/dashboard/data-access,DashboardHeroTmdbServiceinlibs/workspace/dashboard/feature; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title —extractStalkerItemTmdbHints(libs/shared/interfaces) reads title/original title/year/tmdb id off a stored Stalker entry; amovieverdict retries astvwithout the id (the default answer earns a retry, and an id is valid only for its own media type), while atvverdict — reached only on positive series evidence — gets no retry back tomovie. Stalker items never reach thecontenttable, so their backdrop rides in the stored entry (info.tmdb_backdrop) rather thancontent.backdrop_url, and the activity mappers surface it asbackdrop_url - Series detail views show a TMDB production-status chip (
tmdb_status, e.g. Ended / Returning) — TMDB sendsstatusin English regardless of request language, so it is normalized to a token bynormalizeSeriesStatusand rendered viaseriesStatusLabelKeytranslations; person pages showdeathdayalongsidebirthday - Actor pages: cast avatar chips are clickable (TMDB person id) and open
actor/:personIdinside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (tmdb_directorsviaenrichedDirectors/enrichedCreatorsintmdb-credits.ts) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (?q=); the in-portal search page shows a Back button (SearchLayoutComponent.showBackButton→Location.back()) so users can return to the actor page; shared UI inlibs/ui/shared-portals(ActorViewComponent) - Actor page "All portals" scope (Electron only): batched
DB_MATCH_TITLESworker op (trigram FTS over all imported Xtream playlists,apps/electron-backend/src/app/database/operations/title-match.operations.ts);normalizeTitleis shared renderer/worker vialibs/shared/interfaces/src/lib/title-normalization.util.ts - Opt-in via
Settings > Metadata (TMDB)(sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (DEFAULT_TMDB_API_KEYinlibs/services/src/lib/tmdb/tmdb-config.ts— an empty placeholder in the repo by design; the real key lives in theTMDB_API_KEYGitHub Actions secret and is injected at CI build time bytools/tmdb/inject-tmdb-key.mjs) - Match confidence: a provider
tmdb_idis a strong hint, not gospel — its payload is weighed against the item (assessProviderId: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (badProviderId:<id>row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
- Cached in SQLite
tmdb_metadata(Electron, via DB worker opsDB_GET/SET_TMDB_METADATA, plusDB_GET_TMDB_CACHE_STATS/DB_CLEAR_TMDB_METADATAbehind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through themigration:tmdb-search-lookup-v2-cache-cleanup:v1app-state marker. - Service layer:
libs/services/src/lib/tmdb/; store glue:libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.tsandlibs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts(hooked inwithStalkerSelection().setSelectedItem) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About
- See
docs/architecture/tmdb-metadata-enrichment.md
Portal Account Info:
- Both portal types expose an account-info dialog through the same entry points: header playlist switcher (bottom section for the active playlist + per-row ⋮ menu), dashboard source card ⋮ menu, and the command palette. Gates use the shared predicates in
libs/shared/interfaces/src/lib/portal-account-playlist.utils.ts;WorkspaceShellHeaderService.openAccountInfoFor()picks the dialog by playlist type. - Xtream:
AccountInfoComponent(libs/portal/xtream/feature/src/lib/account-info/), queriesget_account_infolive. - Stalker:
StalkerAccountInfoComponent(libs/portal/stalker/feature/src/lib/stalker-account-info/), cached-first — renders the import-timestalkerAccountInfosnapshot instantly, thenStalkerAccountInfoServicerefreshes, routing by the observed portal MODE rather than the URL shape (full mode: handshake+get_profile; simple mode: best-effortaccount_info/get_main_info, nestedjs.account_infoenvelope or flat fields), and re-routing when a lazy repair changes the mode mid-request. Details:docs/architecture/stalker-portal.md("Account Info Dialog"). - Dashboard source cards carry a passive subscription-expiry chip (amber within 7 days, error-toned once expired); account details remain behind ⋮ → Account info.
DashboardSourceExpiryService(libs/workspace/dashboard/data-access/) gathers the facts: Xtream fromPortalStatusService.checkPortalStatusDetails()(the switcher's cached status check, now carryingexp_date), Stalker from the persistedstalkerAccountInfosnapshot — it lives in the playlist payload, not on meta rows, so each Stalker source costs one memoized full-playlist read.
Favorites and Recently Viewed:
- Per-playlist favorites and global favorites
- Recently viewed tracks watch history
Internationalization:
- Uses
@ngx-translatewith 19 language files inapps/web/src/assets/i18n/
Development Notes
Environment Detection and Dual-Mode Architecture
The app determines whether it's running in Electron or as a PWA by checking:
window.electron; // truthy in Electron, undefined in browser
Why Dual Mode? IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:
- Electron: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access
- PWA: Lightweight web version that runs in any browser without installation
Environment-Specific Behavior:
app.config.ts-DataFactory()selects DataService implementation based on environmentapp.routes.ts- Same/workspace/...route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA- Storage layer switches automatically:
- Electron → SQLite/Drizzle ORM →
~/.iptvnator/databases/iptvnator.db - PWA → IndexedDB → Browser storage
- Electron → SQLite/Drizzle ORM →
- External player support (MPV/VLC) only available in Electron
- File system operations only available in Electron (uploading playlists from disk)
Base Href Configuration: The app uses different base href values depending on the build target:
- Development & PWA:
baseHref="/"(fromindex.html)- Used by:
pnpm run serve:frontend,pnpm run build:frontend:pwa - For web servers with proper routing
- Used by:
- Electron Production:
baseHref="./"(overridden in build config)- Used by:
pnpm run build:backend,pnpm run make:app - Required for
file://protocol in Electron
- Used by:
Build configurations in apps/web/project.json:
production: Electron build withbaseHref="./"pwa: Web deployment withbaseHref="/"development: Dev mode withbaseHref="/"from index.html
Factory Pattern Implementation: The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.
Build Commit In About:
CI injects the git commit into apps/web/src/environments/build-commit.ts via tools/build/inject-build-commit.mjs (same placeholder pattern as the TMDB key inject); Settings > About then shows "<version> (<short-sha>)". The semver version itself deliberately stays untouched — a -sha suffix would flip electron-updater into prerelease mode and leak into installer/artifact version fields. Local/dev builds keep the placeholder empty and show the plain version.
Testing Strategy
- Unit tests: Jest with
jest-preset-angularandng-mocks - E2E tests: Playwright testing the web app and Electron app
- Backend tests use standard Jest
- Bug fixes should add focused regression coverage unless there is a documented reason not to.
- Use the impact-based validation policy in
Regression Prevention And Test Updatesto choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification.
Nx Commands
Use nx CLI for better performance:
pnpm nx run <project>:<target>
# Example: pnpm nx run web:build
# Example: pnpm nx run electron-backend:serve
To run multiple projects:
pnpm nx run-many --target=test --all
Electron Build Process
The Electron backend depends on the web app being built first:
electron-backend:builddepends onweb:build- Output goes to
dist/apps/electron-backend(backend) anddist/apps/web(frontend) - Packaging combines both into distributable
Database Migrations
No formal migration system yet. Schema changes are applied via raw SQL in the createTables() function in libs/shared/database/src/lib/connection.ts using CREATE TABLE IF NOT EXISTS. One-off data migrations run guarded by keys stored in the appState table.
Common Patterns
IPC Communication:
- Define handler in appropriate events file (e.g.,
database.events.ts) - Register with
ipcMain.handle()in the event bootstrap function - Expose in preload script via
contextBridge.exposeInMainWorld() - Call from Angular via
window.electron.<methodName>()
Adding New Playlist Source:
- Add type to
libs/shared/interfaces/src/lib/playlist.interface.ts - Create event handler in
apps/electron-backend/src/app/events/ - Add the import flow in
libs/playlist/import/feature/(add-playlist dialog + per-source import components) and surface it on the dashboard (libs/workspace/dashboard/) if needed - Update database schema if needed
State Management:
- Use NgRx for global application state (M3U playlists,
libs/m3u-state) - Use NgRx Signal Store with
signalStoreFeature()composition for portal/feature state (XtreamStore, StalkerStore) - Use NgRx signals for reactive data streams
General Guidelines for working with Nx
- For navigating/exploring the workspace, invoke the
nx-workspaceskill first when it is available - it has patterns for querying projects, targets, and dependencies. If it is unavailable, usepnpm nx show projects,pnpm nx graph, and projectproject.jsonfiles directly. - When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through
nx(i.e.nx run,nx run-many,nx affected) instead of using the underlying tooling directly - Prefix nx commands with the workspace's package manager (e.g.,
pnpm nx build,npm exec nx test) - avoids using globally installed CLI - You have access to the Nx MCP server and its tools, use them to help the user
- For Nx plugin best practices, check
node_modules/@nx/<plugin>/PLUGIN.md. Not all plugins have this file - proceed without it if unavailable. - NEVER guess CLI flags - always check nx_docs or
--helpfirst when unsure
Scaffolding & Generators
- For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the
nx-generateskill FIRST before exploring or calling MCP tools
When to use nx_docs
- USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
- DON'T USE for: basic generator syntax (
nx g @nx/react:app), standard commands, things you already know - The
nx-generateskill handles generator discovery internally - don't call nx_docs just to look up generator syntax