Files
iptvnator/CLAUDE.md
T
4grayandClaude Opus 5 063662028a feat(portals): find the same movie in your other playlists (#1286)
* feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop stale source resolutions from committing

Addresses three defects Greptile found in the multi-source review.

**Concurrent switches committed out of order.** Selecting a second source
before the first resolution returned let the slower request overwrite the
newer selection and repoint Undo at itself. `switchTo` now takes a sequence
number and drops its result if a newer switch already committed.

**Stale switches crossed movie sessions.** Navigating to another film while a
resolution was in flight let the continuation activate the old film's source
inside the new controller — and restart it from that session's zero resume
position. The controller is now snapshotted per operation and the movie
session is revalidated after every await. `check()` had the same hazard across
its two awaits and is guarded the same way.

**Short titles skipped discovery entirely.** The trigram tokenizer cannot index
tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH
expression and the query was discarded before SQLite was consulted — the chip
could never appear for those films. Discovery now falls back to a bounded scan
when FTS structurally cannot serve the title; the existing two-tier normalized
confirmation still rejects loose hits like "Upgrade".

Each fix carries a regression test; all three were mutation-checked by removing
the guard and confirming exactly those tests fail. The previous test asserting
that short titles return nothing encoded the bug and has been replaced.

The host spec passed 400 lines, so its fixtures moved to a shared module and
the race suite into its own file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): make the pin decide playback and keep failover going

Second round of Greptile review findings.

**A pin had no behavioural effect.** Loading a stored pin only decorated the
row: Play still started the route's playlist and failover ranking ignored
`isPinned`, so "make this the main source" survived a restart as an icon and
nothing else. The primary action now starts from the pinned source when one is
set, and the pin outranks everything else in failover ranking.

**Failover stopped at the first unresolvable candidate.** An expired account or
a failing `get_vod_info` on the top-ranked source ended the attempt, and since
production calls `failover()` only once — on the original playback failure — a
healthy lower-ranked source was never reached. It now continues through untried
candidates. `switchTo` reports why it stopped so the loop can tell "could not
resolve, try the next one" from "something newer owns the screen"; without that
distinction a superseded switch would have spun forever, because only the
former marks the candidate tried.

**Identity ignored enrichment.** The key was `playlistId:contentId:title`, so
when `get_vod_info` added a TMDB id and release year to an unchanged title the
host saw no change, never reloaded, and kept yearless discovery and title-only
pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every
field that affects matching.

**A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or
501 to HEAD yet serve the media over GET. The probe now retries once with the
ranged GET the main process already supported, instead of caching a working
source as failed and penalising it during failover.

Greptile also flagged a missing token check after the resolve await in
`switchTo`; that guard landed in 4db3a2fd and sits on the line directly below.
Answered on the thread rather than changed.

The host service passed 400 lines again, so the pin, probe, switch-notice and
current-row concerns moved into focused modules beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(portals): record the behaviour the review rounds changed

The architecture doc and CLAUDE.md described the feature as first written, not
as it now behaves: pins were documented as a stored preference without saying
they decide playback, failover was described as stopping at the first
unresolvable candidate, the probe as HEAD-only, and discovery as pure FTS with
no mention that short titles cannot be tokenized at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): invalidate the session while the movie identity is empty

The staleness guard added in 4db3a2fd bumped the session only inside `load()`,
which leaves a window the guard does not cover: route navigation empties the
movie identity first, and `load()` for the replacement runs only once a title
is knowable again. A resolution completing in that interval still carried a
session number that matched, so it passed the check and started the previous
movie's source over the page the user was navigating to.

The binding effect now bumps the session as soon as the identity goes null, so
anything already in flight is invalidated at the moment the old movie stops
being the one on screen rather than when the next one finishes loading.

`lastMovieKey` is deliberately left alone: returning to the same movie should
not re-run discovery, and the controller's state is still correct — only the
in-flight operations needed invalidating.

Regression test added and mutation-checked: removing the bump fails exactly
that test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop the source list from losing the real alternatives

Six review findings, all in how multi-source decides what to show and what
it is playing.

Discovery: the current playlist is now excluded in SQL rather than after the
fact, so its own duplicate rows can no longer spend the whole row budget
before a single alternative is read. The short-title scan matches the token
as a word instead of a substring and orders by title length in a wider
window, so "Titanic" and "The Italian Job" cannot push the real "It" out of
it.

Session: metadata enrichment re-runs discovery for the film already on
screen. That is a refresh, not a new session — a second identity key
(playlistId:contentId) now separates the two, so the source the user
switched to keeps playing and stays named, the tried set stays burned, the
position survives and a switch in flight still commits.

Resume: the multi-source controller no longer records the engine's pre-seek
timeupdate at ~0. The playback service's one-shot latch now reports whether
the position can be believed, and until it can, the requested start time
stands in — so a switch during the initial seek does not restart the film.

UI: the in-player sources picker gets the same auto-failover setting and
match kind as the detail page's, instead of always rendering the default and
dropping the toggle. The caption counts distinct playlists, not stream
variants, since the popover groups a portal's copies under that portal.

Session mechanics and the pin toggle move into their own modules to keep the
host service inside the line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the playing row when the refined year rejects it

Follow-on from keeping the session across a rediscovery. The rerun can
legitimately drop the row that is playing: enrichment supplies the release
year, and the year gate then rejects a copy the yearless search had admitted
— "Dune" 1984 while the user is watching the 2021 film.

Off the list is right; it is not the same film. Off the screen is not. It is
what is streaming, so it stays as a row and keeps the playing badge, rather
than letting the caption name a playlist that is not sending any bytes.

Also covers the new session key directly in the identity spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a pin write from landing on the next movie

Two findings from the review of the previous round.

A pin write is an IPC round-trip, and the user can navigate during it. The
continuation then applied one film's answer to another film's controller —
and because unpinning returns "nothing pinned", it would clear the pin the
new movie had just loaded and its Play action would quietly stop starting
from the preferred source. It now commits only while the same film is still
on screen, like every other async path here.

The short-title scan drops its row limit. FTS keeps its window because it
ranks by relevance, so what it keeps is what matters; a scan cannot rank, so
a window there silently decides which valid sources the user is allowed to
see. It also bought nothing: the GLOB cannot use an index, so SQLite reads
every row either way and the limit only truncated the answer. What bounds
the scan is its predicate — reaching it means the whole title is one or two
characters.

The switch-notice type moves to the module that builds it, which also
removes a circular type import between the two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep external playback, the pin and the resume point honest

Five findings from the round-5 review.

An external player launched for an alternative carries that playlist's ids,
so the page disowned its own session: the primary button never became Stop,
stopping found nothing to stop, and another click opened a second player.
Multi-source now tells playback which source is actually active, and the
matcher accepts either that or the route's own stream.

Stop also has to beat the pin. The primary action consults the pin first —
that is what makes a pin decide where playback starts — but while a session
is running the same button reads Stop, and consulting the pin there made the
control do the opposite of its label.

A pinned source started from the Resume button resolved at zero, because
nothing reports a live position until the first timeupdate. The controller is
now seeded from the persisted position, one-way: a live value always wins,
since the stored one lags it and applying it would rewind.

A pin whose write failed was still shown as pinned, promising a preference
that reopening the movie would not have.

Portal failures in this path logged raw errors. An Xtream error message
carries the stream URL, and that URL is built out of the username and
password, so they now go through the redacting logger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): make every alias of a pin agree, and stop losing rows

Four findings from the latest review pass.

A pin lookup accepts several aliases of the same movie, but a write only
touched the most-trusted one — so after enrichment the title alias still
pointed at whatever was pinned before, and a reopen that read it (because
TMDB had not landed yet, or its request failed) started the source the user
had just replaced. Writes now go to every alias.

That alias set was also missing one. Enrichment supplies the year as well as
the id, so a pin set before either existed is stored yearless; the candidate
list skipped that form entirely and orphaned the row.

Discovery could lose whole playlists: one playlist listing a film in dozens
of categories produces identically ranked rows that fill the window before
another playlist is read. The collapse now happens in SQL, before the limit,
rather than in TypeScript afterwards where the missing rows are already gone.

And an abandoned source pick finishing late cleared the spinner from the row
the user was actually waiting on.

Removes `isExhausted()` from the host service — no caller outside its own
tests, where the assertion above it already proved the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): probe like playback, and stop the pin answering for a remake

Five findings from the latest review pass.

Writing a pin to every alias — last round's fix for stale aliases — was
wrong in the other direction: `title:{base}:` is shared by every remake, so
a known-year decision stored there answers for a different film. Pin Dune
(2021), open Dune (1984) before its year arrives, and it would start the
2021 source. A write now clears every alias and stores only the canonical
key, which retires the stale ones without making any of them ambiguous.

The probe checked a bare URL while playback sends the playlist's User-Agent,
Referer and Origin. A panel that requires them answers 401/403, so a stream
that plays perfectly was reported dead and penalised in failover ranking.

The switch toast interpolated the raw playlist name. Users routinely name a
playlist after the URL they pasted, so that line could put credentials over
the video; the notice now carries the same safe label the rows use.

External players have no timeupdate, so their polled position IS the live
one. Feeding it through the seed — which stops at the first value — froze
the resume point where playback started, and a switch an hour in rewound to
the beginning.

And auto-failover concluded "nowhere to go" when a stream failed before
discovery answered, stranding the user on the error screen.

Moves `switchTo` into the session module, which is where the rest of the
switch mechanics already live, and splits the route spec along the same
rendering/behaviour seam the other suites use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): re-check the movie after waiting, and follow the alternative

Three findings, two of them regressions from the previous round.

Awaiting a pending discovery before failover let the user navigate during
that wait: the continuation then ran against whatever controller was current
and could answer one film's playback failure by starting another film's
alternative. Both waits — failover and pinned Play — now re-check that the
same movie still owns the screen.

Pinned Play also needed the wait it did not have. Pressing Play while the
pin lookup was still out concluded "nothing is pinned" and started the
route's own source, making a persisted preference depend on worker latency.

And the position bridge still accepted only the route's ids, so an external
player running an alternative had every progress update discarded: the
resume point stayed where playback began and a switch an hour in rewound the
lot. The session matcher and the bridge now share one ownership predicate,
since a page that shows Stop for a session whose progress it throws away is
the bug in two halves.

The test for the external case previously set the position signal directly,
which bypassed the very filter that was broken; it now drives the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a remake matching, and let a pin survive its own playlist

Three findings from the latest review pass.

`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.

Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.

And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.

Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep a pinned play, a same-playlist copy and Check honest

Five findings from the latest review pass.

Reading a pinned source's own position is a database round-trip, and the user
can navigate across it — the continuation then handed one film's source id to
whichever movie now owned the screen. Guarded, like every other await here.

Allowing a pinned copy to live in the current playlist made "is this the
route's own source?" a two-part question, and the ownership check still asked
only about the playlist: an external session for that copy was disowned, so
Stop vanished and its progress was dropped.

The yearless title alias is shared by every remake, so clearing every alias
before a write could delete a different film's pin. Writes and unpins now
touch only keys that name one film — plus the ambiguous row this session
actually read, which is the one the user is looking at and the one whose
absence would make an unpin come back.

Restart left the seeded position in the controller, so a failure before the
first timeupdate resolved the next source back at it.

And the alternative rows on the playback-error screen had a Check button
wired to nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop a rediscovery restoring the pin it started with

A same-movie rediscovery read the pin, then held that snapshot across its
source lookup and applied it afterwards. A pin made while the lookup was out
was therefore overwritten by the older value: the row and the primary Play
action named a source the database no longer held.

The snapshot is now applied as soon as it is read, so a later write simply
wins on ordering rather than needing to be detected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): write the pin before retiring it, and keep the badge honest

Three findings from the latest review pass.

Repinning cleared the old rows and then wrote the new one, so a write that
failed after the clear left nothing persisted while the row still showed the
old pin. The order is reversed: the new key is stored first and the stale
ones retired only once it landed. Lookups are most-trusted-first, so a
leftover alias never outranks what was just written.

Starting a source from the picker, or letting a pin decide the primary Play,
never recorded the movie as recently viewed — unlike every other way of
playing it.

And closing an alternative's player and pressing Play started the route
stream while the controller still marked the alternative active, so the
picker and caption named a source that was not running.

Moves the discovery pass into the session module beside the switch and
failover mechanics, and splits the pin spec along the persistence/playback
seam, both to stay inside the file-size rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop claiming playback, a cached answer and a resolution

Three findings, all of them the same rule: never state as fact something the
app has not established.

The "Playing from …" caption appeared as soon as discovery marked a source
active — before Play was pressed, and again after the player was closed. It
now requires a player that is actually running.

Probe answers were cached by URL alone, but the request now carries the
playlist's headers. Two playlists sharing a stream URL could therefore be
told the other's answer, marking a source dead without ever asking it.

And any width below 900 was labelled 480p, published with `api` provenance:
a 640x360 stream stated 480p as a fact, and a 720x576 PAL source likewise.
Widths below HD only resolve with the height — 720 is NTSC 480p or PAL 576p
— so an unrecognised shape now carries no quality tag at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): match the sub-HD formats, and drop the caption on failure

Two follow-ups to the previous round, both the same rule again.

The 800-wide band still answered from the width alone, so 800x600 and
800x450 were labelled 480p — published with `api` provenance, so read as a
measurement. Sub-HD formats are now matched against known shapes with the
same 5% tolerance the height path uses, and anything unrecognised carries no
tag at all.

And "Playing from ..." survived a playback failure: the inline host stays
mounted while the diagnostic is on screen, so the page named a source for a
stream it had just reported it could not play. The caption now clears on
failure and returns when the engine produces time again.

Splits the route playback spec along the "what it does" / "what it claims"
seam and lifts the repeated active-source stub into one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let the height veto a width match, and hold the failure state

Two follow-ups to the previous round, both in code it introduced.

A width that matched exactly one sub-HD format ignored the height entirely,
so 640x480 came back as 360p — a measurement the numbers contradict. The
height now vetoes, but only in the direction that can be wrong: cropping
removes lines, so a SHORTER frame is a letterboxed master of that format and
the width still names it, while a taller one is a different shape and gets
no tag. That keeps the reason width is preferred in the first place.

And picking a source off the error screen cleared the failure state before
the switch resolved, so an alternative that could not be resolved left the
diagnostic on screen while the caption went back to claiming playback. The
flag now clears only once a switch actually starts something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): release the resume latch when the target cannot be reached

Carrying a position into a shorter cut of the same film — two hours into a
90-minute source — leaves the engine unable to ever report that time, so the
one-shot latch never released: every position save was suppressed for the
rest of the session, and the impossible start time kept being reported to
multi-source for the next switch.

The latch now also opens when a known duration puts the requested point out
of reach, while a reachable one still waits as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): say "Playing" only while something is playing

`isActive` means "the source a switch or Play would use". Discovery sets it
the moment the page opens and it survives closing the player, so it could not
back the two claims the UI made in the present tense: the "Playing from"
caption and the source row's Playing badge. Both appeared on a page where
nothing had started, and came back after the player was closed.

`playbackLive` is now that statement, and both read it. Inline it needs a
timeupdate — `inlinePlayback()` is only the REQUEST to play, non-null while
the engine is still opening the stream and still non-null after it fails —
and external it needs the session past `launching`. A row that is merely
selected reads "Current" (new key, filled for all 19 locales).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): start a never-watched pinned source from the beginning

Positions are keyed by (playlist, stream). When the pin points at a copy the
user has never opened, the lookup returns nothing and the controller was left
holding the ROUTE copy's position — so Play dropped them 42 minutes into an
unstarted film, and the first save wrote that timecode back under the pinned
source's key, making it permanent.

The spec asserted the old behaviour, so it is flipped rather than extended; a
second case covers the host that supplies no lookup at all, where "never
watched" was never established and the position must be left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): describe the copy the primary button will actually play

Two gaps found by review.

A pin makes the primary button play a copy the page never loaded a position
for — positions are keyed by (playlist, stream). The label, timecode and
Restart affordance still came from the route copy's row, so the button could
read "Resume 42:18" and start an unwatched copy at zero, or read "Play" and
jump into the middle of one already watched. `createPrimaryActionPosition`
lets the pinned copy's row govern, including when that row is absent: never
watched is an answer, not a fallback to someone else's progress.

A manual source switch also mounts a DIFFERENT stream in the same host while
marking the new source active at once, so the previous stream's timeupdate was
still vouching for it — the caption and the badge claimed the new source while
it was still opening. That path now clears the latch like Play and Restart do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the route's own resume point, and honour a closed pin

Two more from review, both variations on "selected is not playing".

`vodPlaybackPosition` followed whichever copy last reported — so after an
alternative played, Resume and its label described that copy's row while
starting the route's stream, jumping it to a timecode nobody reached in it.
It now splits: `vodPlaybackPosition` stays the last position seen (the
progress bar and the switch handoff want the stream on screen), and
`routePlaybackPosition` holds the route copy's own row for everything that
acts on the route's stream.

`pinnedSourceAwaitingPlay` skipped the pin whenever its row was active, but
`isActive` means selected — the pinned row stays selected after its player is
closed, so the next Play went to the route copy and ignored the stored
preference until the page was reopened. It now takes `playbackLive` too.

The host service crossed the 400-line cap on the way, so the four derived
alternative counts moved into `vod-multi-source-counts.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep the primary button honest across navigation and pins

Four follow-ups from review, all consequences of splitting the position
signals.

- Route reuse (the Similar rail) cleared only `vodPlaybackPosition`, so the
  button kept the previous movie's Resume label — and start point — until the
  new lookup landed. Both signals and the playback latch now reset together.
- The primary button's fall-through past an unresolvable pin reached the
  service directly, skipping the bookkeeping a route start needs: the
  controller kept the alternative's timecode and the old stream's timeupdate
  still vouched for the new one. It now goes through the route's own wrappers,
  and Resume seeds the controller with the ROUTE copy's position.
- `alternativePlaylistCount` counted the playlist being watched whenever it
  held a second copy, so "also found in 2 other playlists" could mean one.
- The pinned copy's stored row went stale the moment the user watched it; its
  live position now wins while it is the one playing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): do not spend a source's failover turn on mere selection

`setActiveSource` marked the source tried, but discovery calls it the moment
the page opens and a pin or the picker can call it before anything plays. So
opening a movie burned the route copy's turn: if a pinned alternative then
failed, failover skipped a healthy untouched source — and with only one
alternative, reported the options exhausted.

Selection and attempt are now separate. `setActiveSource` selects;
`markPlaying` also spends the turn, and only the three places that really
start playback call it. `runFailover` additionally retires whatever is on
screen before picking, so the failing source is spent however it got there —
relying on the start paths alone would leave one hole per path, and the cost
of missing it is a ping-pong between two sources.

One existing spec asserted the old behaviour (a route copy burned by a switch
it never played); it now plays first, so it still covers what it meant to —
that the tried set survives a rediscovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(portals): carry VOD source pins through playlist backup

The new pins table was invisible to backup: exporting a playlist and
re-importing it on a new machine silently dropped every "main source" choice,
with nothing in the archive to say the choice had ever been made.

Pins now ride along under the playlist they point AT — carrying them anywhere
else would restore a preference for a portal the archive never contained.
`matchKey` names the film rather than the portal, so it survives untouched and
only the playlist id is remapped to the imported copy.

`sourcePins` is the one optional collection in the Xtream user state: archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage. Only a wrong type is rejected, and pins without a
usable match key or content id are dropped, since writing one would occupy the
unique key of a film it does not describe.

Adds `DB_LIST_VOD_SOURCE_PINS` through the usual six seams (operation, worker
case, event, preload, bridge contract, service).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(portals): follow the normalized restore state's new collection

`normalizeXtreamPendingRestoreState` now always emits `sourcePins`, like every
other collection it canonicalizes, so three specs that assert the exact
normalized shape had to follow. Adds coverage for the sanitizing itself: a pin
without a usable match key or content id is dropped, and a non-string
`updatedAt` is discarded rather than carried.

Caught by CI, not locally — the earlier full run served `playlist-shared-ui`
from the Nx cache, so it reported green on a stale result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(portals): lift the VOD route's orchestration out of the component

The details route had grown to 864 lines — the repository's hard maximum is
400, and while the file predates the rule, a baselined exemption is not a
budget to spend.

Three component-provided services now hold what the component was
accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch,
the caption, the primary button's position, source actions and the failover
toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and
`VodDetailsDownloadsService`. The component keeps its public API, so the
template and the existing specs are untouched. 864 -> 566 lines.

The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal`
read `route.snapshot.params`, which is stale once the router reuses this
component for detail-to-detail navigation (the Similar rail) — so a download
started from a film reached that way fetched the previous one. They now read
the same route-params signal everything else uses, with a regression test.

Also from review: pins are applied on the FRESH-import path too. A new
playlist has no content when the archive is read, so its user state is parked
and replayed after the import — the merge path I wired first never ran there,
and every pin was dropped. A failed pin write now propagates instead of being
ignored: the backup entry is reported failed, and the parked state is kept so
a transient failure can be retried rather than silently losing the preference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): read array-shaped codecs, bound short-title scans, honour alias clears

Three from review.

`info.video`/`info.audio` are declared — and sent by the mock server and many
panels — as string arrays, but the resolver only read the ffprobe object shape.
Every array response therefore lost the provider's codec, so those source rows
showed no codec fact and the "dub may differ" warning could never fire.
`readStreamInfo` now accepts both, and states nothing when the provider stated
nothing.

The FTS-empty fallback scan matched only the FIRST token, which is fine for a
one-word short title but not for "I Am": every catalog row containing the word
"i" came back for TypeScript to throw away — a full scan of a large catalog on
the single database worker, just to open a detail page. Every token must now
appear.

`writePin` reported success when the canonical write landed but retiring the
old alias failed. Lookups read aliases before the canonical key, so reopening
the movie before enrichment would start the source the user just replaced,
with the icon promising otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): write a pin and retire its aliases in one transaction

Split across two calls, a half-failure had no honest outcome. Reporting
success left a surviving alias to win the next lookup and start the source the
user had just replaced; reporting failure — which the previous round changed
it to — left the canonical row durable while the UI showed a pin that was no
longer the stored one. Review was right both times, which is the tell that the
two-step shape was the problem.

`setVodSourcePin` now takes the keys to retire and does both inside one
`db.transaction()`, with the synchronous `.run()` form the better-sqlite3
driver requires there (issue #1137's lesson). `retireKeys` rides through the
worker op, the IPC contract, the preload bridge and the service, so there is
one call and one outcome.

Also corrects the architecture doc: the scan path is reached whenever no token
clears the trigram minimum, not only when the whole title is one or two
characters — the claim the previous commit's code change had already falsified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): tell a superseded pinned play from an unusable pin

Double-clicking Play while a pinned source resolves put both handlers into
`playPinnedSource()`. The second supersedes the first, so the first returned
`false` — which the route read as "no usable pin" and answered by starting the
route source over the playback the second click had just begun.

`playPinnedSource` now reports `played` / `superseded` / `unavailable`, and
only `unavailable` falls through. This is the same distinction `runFailover`
already draws between "keep going" and "stop, something newer owns the screen";
the pinned path simply never had it.

The host crossed the 400-line cap again on the way, so the pinned-play errand
(wait out an in-flight discovery, re-check the session, start the source) moved
into the pin module beside `playPinned`, and the pin-toggle commit went with
it. 388 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): restart honours the pin, and a switch replaces the player

Three from review, all in the pinned-playback seam.

A pinned copy watched through resolved to its stored seconds, so the button
read Play — the label uses the in-progress rule — and then started near the
end. Both now go through one `isResumablePosition`, so the label and the start
point cannot disagree.

Restart sat beside a Resume that honours a foreign pin, but called `playVod`
and started the ROUTE copy — silently switching the user's playlist. It now
restarts whatever the primary button acts on, falling back to the route source
only when there is no usable pin.

Switching sources left a running external player alone. With MPV or VLC and
instance reuse off the backend spawns a second detached process, so both
sources kept playing and Stop owned only the newer one.

Also merges master, and puts the five host specs on a shared harness — they
each carried the same 31-line TestBed, which is what pushed two of them over
the file-size cap as cases were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): find short Unicode titles, and stop two pickers racing

Five from review.

Greptile's P1: a short non-ASCII title was undiscoverable. SQLite's `LOWER()`
and GLOB classes are ASCII-only, so "он" never matched a stored "Он" and the
source simply never appeared. ASCII tokens keep the word-boundary GLOB; a
non-ASCII token falls back to a substring test against both the folded and the
as-typed form, which the normalized confirmation afterwards makes safe.

A probe now retries the ranged GET for 400 and 403, not just 405/501 — those
are what a WAF returns for an unexpected HEAD on a URL it serves happily over
GET, and calling that source dead also ranked it below worse ones.

Three races, all the same shape as ones fixed earlier in this branch:
- a pinned play awaiting its resume lookup did not notice a source picked
  across it, and finished last, replacing the user's choice;
- two overlapping switches both saw the same external session, both awaited
  its close, and both launched — two detached players again;
- the primary button showed the ROUTE copy's Resume while the pinned copy's
  row was still loading, so a click started somewhere else entirely.

Also puts the races spec on the shared host harness, which is what keeps it
inside the file-size rule now that it carries two more cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): close the player we launched, not the one we now own

Three follow-ups, two of them to last round's own fixes.

The external-session close was defeated in exactly the case it was written
for: `switchToSource` marks the DESTINATION active before handing playback
over, so by the time the service ran, the process still playing no longer
looked like ours and was left running beside its replacement. The service now
remembers the ids it launched with, independently of what is active.

The ASCII/Unicode branch was decided from the NORMALIZED token, which folds
diacritics — "Ça" arrived as "ca", looked like plain ASCII, and took the GLOB
path while the stored title still read "Ça". Decided from the raw token now.

Backup restore upserted archived pins but never removed the playlist's
existing ones, so a present-but-empty collection left stale preferences alive
— unlike the playback positions cleared beside it. An absent collection (an
older archive) still means "no opinion" and is left alone.

Four files crossed the size cap on the way; the split ones now share
`title-sources.spec-data.ts` and `playlist-backup.xtream-fixtures.ts`, and the
external-session ownership moved to its own module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): absent is not empty, and every start claims the generation

Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a failed close must not leave the page claiming a dead source

When `closeSession()` rejected, `startResolvedPlayback` rejected with it and
never launched — while `switchToSource` had already marked the destination
active and reported the switch as successful. The page then named a source
that nothing was playing.

The close failure is logged and the replacement starts anyway. A close that
rejects usually means the session was already gone, and a possibly-lingering
process is the lesser of the two evils: the alternative is a UI that lies
about what is on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(portals): split the external-playback handoff out of the service

Both the service and its spec crossed the 400-line cap with the close-failure
handling, so the handoff — deciding which process is ours, closing it, and
surviving a close that rejects — now lives in
`vod-details-external-session.ts` with its own spec file.

Two tests had to start awaiting: replacing a running external player is a
round-trip, and the handoff now yields once even when there is nothing to
close, so the new playback is mounted a microtask later than before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style(portals): format the extracted external-session module

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): fold diacritics in the title index

Cross-playlist matching compares normalized titles ("Amélie" -> "amelie")
against an index built from the raw title, and the trigram tokenizer does not
fold diacritics by default. Every accented title was therefore invisible to
the FTS path: two identical `Amélie` entries produced no candidates at all.
That is the broadest of the Unicode gaps review found, and it predates the
short-title work.

The tokenizer is fixed at CREATE time, so existing databases recreate and
rebuild the index once behind a migration marker. `remove_diacritics` needs
SQLite 3.45+, so support is probed on a temp table first: an older runtime
keeps its working index untouched and the migration is not recorded as done,
leaving a later version free to upgrade it.

Case folding for non-ASCII remains impossible in stock SQLite — "ОН" cannot
find "Он" by any available predicate — and is documented as the known limit
rather than patched around again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): clear a playlist's pins by playlist, not by key list

Restoring over a playlist reused the keyed clear, which caps its input at
MAX_KEYS_PER_LOOKUP to bound an IN clause. A playlist with more than eight
pinned movies therefore kept the surplus while the call still reported
success, and the restore then wrote the archive's pins on top — leaving the
union of two states, which is neither the one the user asked for.

Clearing is now a dedicated delete-by-playlist operation with no key list to
truncate, and it refuses a blank playlist id rather than deleting everything.
A failure fails the entry instead of being swallowed: `listForPlaylist`
returns `[]` on error and `clear` returns `false`, so ignoring the result made
a failed read indistinguishable from "there was nothing to clear".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): keep a pin readable under every identity of its film

Two defects in the pin/position subsystem, both reported in review.

A pin was stored under the movie's most-trusted key alone and its other
keys retired. But a movie's identity GROWS: the film keyed `tmdb:438631`
today was `title:dune:2021` before enrichment, and reopening it cold asks
for the poorer key first. The preference was therefore ignored until
enrichment landed — and permanently when enrichment is off or never
answers. The decision is now written under every key in `write` (never
the yearless form, which every remake shares), one upsert per key plus
the leftover retirement in the same transaction. `setVodSourcePin` also
reports failure for a pin with no usable key instead of claiming a write
it never made.

The primary button asked whether the pinned copy's position had loaded
by testing presence rather than identity, so re-pinning left it wearing
the previous copy's timecode until the new lookup returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(portals): record the key-addressing limit a pin write cannot close

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): fold non-ASCII case in the scan, and read years as tags

I was wrong about SQLite twice over, and both errors cost matches.

GLOB character classes are NOT ASCII-only. `patternCompare` reads them as
UTF-8 code points, so `'Он' GLOB '*[Оо][Нн]*'` is true — only `LOWER()` is
ASCII-only. The scan tier now folds the case in JavaScript, where Unicode
case mapping is real, and hands SQLite one class per character. A short
Cyrillic or Greek title stored in a different case is found instead of
being silently absent from the Sources chip. The builder returns `null`,
leaving the substring tests as the whole answer, for a token holding a
GLOB metacharacter (GLOB has no escape character) or a case mapping that
changes length. The FTS tier is untouched and still cannot fold — that
needs a stored normalized-title column.

The movie's own year came from `extractYear`, which reads a year from
anywhere in the title. That is right where a year is a search hint, wrong
where it is an identity: `2001: A Space Odyssey` was treated as a 2001
film, so every genuine 1968 copy failed the year gate and the movie had
no alternatives at all — and its pin key moved the moment enrichment
supplied the real year. `releaseTagYear` accepts only bracketed and
trailing forms; the repo's own TRAILING_YEAR_PATTERN already documented
this exact hazard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): cover a letter spelled two ways in lower case

Greek Σ lowercases to σ, but a word-final sigma is written ς and is
equally a lowercase of it, so a class built only from the character in
hand knew one spelling of two. Each class now also carries the uppercase
form's own lowercase, which reaches the other one.

One-way on purpose: σ → Σ → σ never arrives at ς. Left so because ς is
only correct at the end of a word, which is exactly where the request's
last character sits — the pair that occurs in real titles is covered, and
closing the other direction needs a fold table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let an exact title keep a number that is part of its name

Both match tiers weighed the same year, taken from a trailing four-digit
tail or a bracketed tag. On the exact tier that rejects the very copy it
was meant to confirm: reaching it means both titles are the SAME string,
so the trailing digits belong to both, and comparing them against a
release year out of metadata makes "Blade Runner 2049" disagree with its
own stated 2017 — the genuine alternative disappears at the moment
enrichment lands, which is when the user has most reason to expect it.

The exact tier now reads the bracketed form only. Brackets are never part
of a name, so "Dune (1984)" is still rejected against 2021. The base tier
is unchanged: it has just stripped a trailing year, and that year is the
only thing separating "Dune 1984" from "Dune 2021".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(database): verify the title index folds, rather than trust the marker

`createTables` declares content_title_fts with the plain trigram
tokenizer, and the diacritics migration declares it again with folding.
Two sources of truth for one tokenizer: if the table ever went missing
after the marker was written, `CREATE TABLE IF NOT EXISTS` would restore
the unfolded form and the migration would skip it on the marker alone.

The upgrade now reads the live table's own DDL from sqlite_master and
rebuilds unless it really folds. A degraded index is invisible from the
outside — discovery just stops finding "Pokémon" for "pokemon" — so the
record has to be checked against the thing it describes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): give the route's own row the facts the page already has

Two provenance defects found in review.

The current-source row is never resolved — nothing needs to fetch a URL
for the stream already playing — so it carried no provider metadata at
all, while every alternative got its facts from the resolve preceding
playback. `audioDiffersFactually` requires a fact on BOTH sides, so the
"dub may differ" warning was structurally unreachable on the commonest
switch there is: route to alternative. It could only ever fire between
two alternatives that had both been resolved. The row now carries what
`get_vod_info` already told the page, via a `providerVodMetadataOf`
mapper shared with the resolver so the two cannot describe one movie
differently.

Quality bucketed every width from 900 to 1199 as 576p, so a 960x540
stream — an ordinary 540p encode — was published as "576p" with `api`
provenance: a measurement its own pixels contradict, from the one field
that is supposed to mean the provider said so. That range holds two
standard formats, so it is matched now rather than bucketed, exactly as
the sub-HD sizes already were. A width matching no known format yields
no tag and a check chip, which is the honest answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): let the height veto a width-derived quality, and refresh route facts

Both of these are gaps I saw and chose not to close last round; a
reviewer was right that neither survives its own reasoning.

The shape check only ran below 1200, so the HD ranges kept publishing
wrong-but-confident labels: 1440x1080 is anamorphic 1080 and 1600x900 is
900p, and both were "720p" with `api` provenance — the provenance that
means the provider said so. Ranges are fine up there, the standard widths
really are far apart, but only once a known height can veto the answer.
Same rule the matched formats already used: a shorter frame is a
letterboxed master, a taller one is a different shape and gets no tag.

And the route row picked up provider facts only when discovery reran. On
a sparse panel `get_vod_info` can answer with no year and no TMDB id, so
the movie key is unchanged, nothing reruns, and the row keeps stating
nothing — leaving `audioDiffersFactually` one-sided and the dub warning
unreachable on exactly the switch it exists for. It now takes those facts
on without rediscovering, merged onto the existing row so a probe result
already sitting there survives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a codec is not a dub, and two waits needed a switch guard

Three findings from review.

The "dub may differ" warning compared audio CODECS. AAC and AC3 routinely
carry the same dub, and two AC3 tracks can carry different ones, so it
fired on every identical-language re-encode and stayed silent on the dub
changes it exists for — wrong in both directions, which is worse than
absent, because a warning people learn to ignore is not a warning. Worse,
the previous commit made it reach the common route-to-alternative switch
for the first time, so the false claim was about to get louder.

It now reads a new `audioLanguage`, taken from the track's language tag
and never from the codec. `audio` stays as a display fact. Few panels tag
a language, so the warning is usually silent — the same answer the rest
of this feature gives when it does not know.

`failover()` validated only the session across its wait for a discovery
in flight. The session moves when the FILM does, so a source the user
picked — or the route stream they restarted — during that wait was then
treated as the thing that failed and switched away from. It claims and
rechecks a switch generation, as the pinned path already did.

And the scan's ASCII branch could not find "Ça" from a folded "ca", while
the non-ASCII branch found "Ca" from "Ça" — so whether two playlists
could see each other depended on which one was open. Each ASCII letter
now carries its accented forms, derived by decomposition rather than
tabulated, so it cannot drift from the normalizer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(portals): pin what the declared audio shape can and cannot say

The array shape the mock server and many panels send carries a codec and
no language, so the dub warning is silent for every source arriving that
way. Asserted rather than assumed, alongside the ffprobe shapes that do
carry one — otherwise a later reader sees an unused field and wires the
codec back into the warning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): a failed pin read must not export as "no pins"

Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(portals): stop two backup paths from deleting pins they never read

Two data-loss paths, both P1, both mine.

A web export wrote `sourcePins: []`. Pins are Electron-only, so out
there we cannot read them — which is not the same as knowing there are
none, and restore treats the collection as authoritative. A backup made
in the browser was therefore an instruction to delete every pin the
moment it was imported on the desktop. There are three answers here, not
two: pins exist, there are none, and "could not look". The last omits
the field, exactly as an archive written before pins existed does. The
same rule now covers Electron with the bridge method missing.

I had written a test asserting the unreachable-store case resolves to an
empty list "because a backup made there is complete". That reasoning was
wrong: empty was true of what the runtime could see, never of the
playlist.

Restore also cleared the playlist's pins and then wrote the archive's one
by one. A write failing partway left the previous pins already gone and
only a prefix applied — a state belonging to neither, reported as a
failure the user could not undo. `DB_REPLACE_VOD_SOURCE_PINS` does the
clear and every write in one transaction, so the playlist ends up as the
archive describes it or exactly as it was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:53:42 +02:00

92 KiB
Raw Blame History

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.md such as .plans/2026-03-12-channel-filtering.md.
  • If the intended filename already exists, append a numeric suffix such as -2, -3, and so on.

Documentation After Changes

  • After implementing a meaningful change, Claude Code must assess whether canonical repo docs need updates before considering the task complete.
  • Meaningful changes include new or changed user-visible behavior, architecture or data-flow changes, non-obvious maintenance workflows, new setup/debugging steps, and new subsystem contracts or boundaries.
  • Skip doc updates for trivial refactors with unchanged behavior, formatting-only edits, and isolated test-only changes.
  • Prefer updating an existing authoritative doc before creating a new one:
    1. README.md for top-level developer or user workflows
    2. docs/architecture/ for architecture, ownership, and behavior contracts
    3. the nearest module README.md for local usage or behavior
  • Keep this file (CLAUDE.md) itself up to date. It is a living document: whenever a change touches something it describes — monorepo structure (new/moved/renamed apps or libs), routes, database schema/tables, stores and their features, key components, commands, environment behavior, or coding conventions — update the affected CLAUDE.md sections as part of the same task, and keep the mirrored process sections in AGENTS.md in sync.
  • When adding a new feature area, check whether the Architecture or Key Features sections of CLAUDE.md describe the surrounding area; if they do, reflect the addition there instead of leaving the description stale.
  • Do not let CLAUDE.md drift: a stale path or route in this file poisons the context of every future agent session. If you notice an outdated claim while working, fix it (or flag it in the final summary) even if it is unrelated to the current task.
  • Repo docs are canonical even when they were originally drafted by an LLM.
  • Final task summaries should state whether docs were updated and which doc changed.

Release Notes For User-Visible Changes

  • Any change a user could notice — new behavior, changed behavior, bug fix, performance win, breaking change — must add one note file under .changes/ in the same PR. Format, field table, and writing rules: .changes/README.md.
  • Name it <area>-<short-slug>.md; area matches the conventional-commit scope. There is no version field — the release version is chosen at release time.
  • Write the body for a user, not a reviewer: "the player now remembers volume between episodes", not "hoist volume state into the session". Max 400 characters; depth belongs in the release blog post.
  • Skip the note for test-only changes, docs, CI/workflow plumbing, and pure refactors with no behavior change. When skipping on a PR that touches apps/** or libs/**, apply the no-release-note label.
  • CI enforces this: the "Release note gate" job in .github/workflows/ci.yml fails PRs that change runtime code without an added .changes/*.md or the label (policy in tools/release/check-release-note-gate.mjs; tests/e2e/website/mock-server/docs paths are auto-exempt).
  • The release-notes skill covers writing notes; the release-cut skill covers the full release sequence.
  • Validate before finishing: pnpm run release:notes:validate.
  • 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:
    1. Run targeted unit tests for directly affected projects with pnpm nx test <project> or existing scripts such as pnpm run test:frontend, pnpm run test:backend, or pnpm run test:unit:ci when the scope is broader.
    2. Run affected E2E coverage when changing user-visible workflows, routing, persistence, playback, portals, settings, import flows, or Electron-only behavior.
    3. Use pnpm nx show projects --withTarget test and pnpm nx show projects --withTarget e2e when project ownership or available validation targets are unclear.
    4. Prefer specific atomized E2E targets before broad suites when they cover the changed behavior, for example pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts or pnpm nx run electron-backend-e2e:e2e-ci--src/search.e2e.ts.
  • Electron-specific changes affecting IPC, SQLite, packaged runtime, external players, native file access, or Electron-only routes require Electron E2E coverage where available, or CDP/manual verification with agent-browser and the tracing flags documented below.
  • Final task summaries must list tests added or updated, validation commands run with results, and any skipped validation with the reason. For docs-only changes, state that unit/E2E validation was not required and verify the changed Markdown instead.

Project Overview

IPTVnator is a cross-platform IPTV player application built with Angular and Electron, supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.

Dual Environment Support: The application is designed to work in both Electron and as a Progressive Web App (PWA). The architecture uses a factory pattern to inject environment-specific services at runtime, ensuring the same codebase works in both contexts.

Development Commands

Agent Bootstrap

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.json such as @iptvnator/services, @iptvnator/shared/interfaces, and @iptvnator/ui/components.
  • Do not add new imports from legacy bare aliases such as services, shared-interfaces, components, m3u-state, or database.
  • Every Nx project should keep scope:*, domain:*, and type:* tags in project.json.
  • See docs/architecture/nx-workspace-boundaries.md for the current Nx tag and alias policy.
  • Repository-specific skills are committed under .codex/skills/. Claude Code only discovers skills under .claude/skills/, so release-notes and release-cut are mirrored there and the two copies must be kept in sync; every other entry in .claude/skills/ is personal and stays gitignored. If an agent does not load skills directly, treat those files as concise ownership docs.

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 electron skill
  • Do not auto-open DevTools during normal CDP automation. In development, DevTools is opt-in via ELECTRON_OPEN_DEVTOOLS=1.
  • If DevTools is open, agent-browser --cdp 9222 ... may attach to the DevTools page instead of the IPTVnator window (symptoms: tab list shows about:blank, empty snapshots, black screenshots). Inspect targets with curl http://127.0.0.1:9222/json/list and connect directly to the app page's webSocketDebuggerUrl.
  • The app holds a single-instance lock (acquireSingleInstanceLock in apps/electron-backend/src/app/services/single-instance.ts): a second launch against the same userData quits immediately and focuses the running window. To attach a second CDP-enabled instance to the same profile, set IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 — knowing that only one of the two processes will own the renderer's IndexedDB, so settings written by the other are lost. Before focusing, the guard forwards the second launch's argv to onSecondInstance, which is how a playlist path handed to an already-running app reaches the open queue.

For startup tracing or white-screen debugging:

IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend

Useful narrower flags:

  • IPTVNATOR_TRACE_IPC=1 traces renderer window.electron.* bridge calls
  • IPTVNATOR_TRACE_DB=1 traces DB worker requests and DB progress events
  • IPTVNATOR_TRACE_SQL=1 traces SQLite statements in both main and worker connections
  • IPTVNATOR_TRACE_WINDOW=1 traces BrowserWindow navigation/load lifecycle
  • IPTVNATOR_TRACE_PLAYER=1 traces external-player activity and bounded Embedded MPV runtime-probe stderr
  • IPTVNATOR_TRACE_RENDERER_CONSOLE=1 mirrors renderer console logs into the Electron terminal
  • IPTVNATOR_PERF_CAPTURE=1 enables development/test-only, redacted M3U and Xtream preload IPC request/completion markers plus count-only M3U acquire/parse/normalize, Xtream main network/JSON-transform/success-response-ready/cancel-dispatch, and renderer store phase capture; renderer wrappers emit only while the benchmark installs its Symbol hook, benchmark tooling sets the flag explicitly, and production launches must leave it unset
  • IPTVNATOR_PERF_WORKER_PROFILING=1 enables development/test-only, request-scoped worker receive/work/response-post timestamps, thread CPU, event-loop utilization/delay, count-only playlist serialization/SQLite write/read/deserialization plus Xtream category/content/cache-clear/delete/in-source-search phase events, profiling-only worker cancel-receipt acknowledgements, valid-sample-counted isolate peak memory, and the database worker's idle-only one-shot post-GC heap probe; overlapping database requests are explicitly invalidated instead of misattributed, the performance benchmark sets the flag automatically, and production launches must leave it unset

Settings, portal request/response, and trace payloads must use @iptvnator/shared/logging or the redacting portal logger before reaching console.*; never log raw credentials while debugging.

If the Nx daemon gets into a bad state before rerunning Electron:

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.ts and everything under apps/*-e2e/** — a spec is a flat list of independent cases, so splitting one at the production limit yields arbitrary -2.spec.ts files, and length there signals coverage rather than the design debt the production limit catches.
  • 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, /stalker CORS 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/:id routes
    • playlist/shared/{ui,util} - Shared playlist UI and utilities
    • portal/xtream/{data-access,feature} - XtreamStore, services, data sources; routed Xtream components
    • portal/stalker/{data-access,feature} - StalkerStore and routed Stalker components
    • portal/catalog/feature - Portal catalog UI
    • portal/downloads/feature - Download manager UI
    • portal/shared/{data-access,ui,util} - Cross-portal shared code (incl. the VOD multi-source discovery/resolve/ranking layer in data-access/src/lib/multi-source/)
    • 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 │ │ withPlayer │
└────────────┘ └────────────┘ └────────────┘ └────────────┘ └────────────┘
        │                           │              │
        └───────────────────────────┼──────────────┘
                                    ▼
┌─────────────────────────────────────────────────────────────────┐
│                    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.ts uses signalStoreFeature() for focused functionality
  • Facade pattern: XtreamStore composes all features, maintaining backward compatibility
  • Data source abstraction: IXtreamDataSource interface with environment-specific implementations
  • Factory injection: provideXtreamDataSource() selects Electron or PWA implementation at runtime

Data strategies by environment:

Environment Strategy
Electron DB-first: Check DB → fetch API if missing → cache to DB
PWA API-only: Always fetch from API, store 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 M3U radio attribute)
  • The audio player always renders inline — shouldShowInlinePlayer is bypassed for radio
  • EPG panel is conditionally hidden in the template when radio is active
  • Volume is shared with video player via localStorage key 'volume'
  • Keyboard: ArrowUp/Down adjusts volume by 5%, M toggles mute
  • Component: libs/ui/playback/src/lib/audio-player/audio-player.component.ts

Channel List Component Structure (parent coordinator pattern):

libs/ui/components/src/lib/channel-list-container/
├── channel-list-container.component.ts   # Parent - shared state coordinator
├── all-channels-view/                     # Virtual scroll + debounced search
├── groups-view/                           # Expansion panels + infinite scroll
├── favorites-view/                        # CDK drag-drop reordering
├── recent-view/                           # Recently viewed channels
└── channel-list-item/                     # Individual channel display

Key patterns:

  • EnrichedChannel: Pre-computed EPG data attached to channels for performance
  • Parent coordinator: Manages shared signals (channelEpgMap, progressTick, favoriteIds)
  • Virtual scrolling: CDK virtual scroll for 90,000+ channel lists
  • Infinite scroll: IntersectionObserver in groups view loads 50 items at a time
  • Global progress tick: Single 30s interval instead of per-item intervals

State management via NgRx (libs/m3u-state/):

  • PlaylistActions: loadPlaylists, addPlaylist, removePlaylist, parsePlaylist
  • ChannelActions: setChannels, setActiveChannel, setAdjacentChannelAsActive
  • EpgActions: setActiveEpgProgram, setCurrentEpgProgram, setEpgAvailableFlag
  • FavoritesActions: updateFavorites, setFavorites, hydrateFavorites

See docs/architecture/m3u-playlist-module.md for complete documentation.

Routing: Lazy-loaded routes in apps/web/src/app/app.routes.ts. All user-facing routes are nested under the workspace shell (/workspace/...); / redirects into the workspace.

  • Dashboard: /workspace/dashboard; sources overview: /workspace/sources
  • M3U player: /workspace/playlists/:id (children: favorites, recent, :view) — routes in libs/playlist/m3u/feature-player
  • Xtream Codes: /workspace/xtreams/:id (children: live, vod, series, search, actor/:personId, 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/downloads
  • Settings: /workspace/settings (/settings redirects there)

Service Architecture (Factory Pattern):

  • Abstract DataService class in libs/services/src/lib/data.service.ts defines the contract
  • Two environment-specific implementations:
    • ElectronService (apps/web/src/app/services/electron.service.ts) - Uses IPC to communicate with Electron backend
    • PwaService (apps/web/src/app/services/pwa.service.ts) - Uses HTTP API and IndexedDB for standalone web version
  • Factory function DataFactory() in apps/web/src/app/app.config.ts determines which implementation to inject:
    if (window.electron) {
        return inject(ElectronService);
    }
    return inject(PwaService);
    

Data Storage (Environment-Specific):

  • Electron: SQLite database via Drizzle ORM (better-sqlite3 driver)
    • Location: ~/.iptvnator/databases/iptvnator.db
    • Full-featured relational database with foreign keys and indexes
    • Canonical schema and connection live in libs/shared/database
  • PWA (Web): IndexedDB via ngx-indexed-db
    • Browser-based NoSQL storage
    • Same schema structure but implemented in IndexedDB
    • Limited by browser storage quotas

TypeScript File Size Rule:

Keep production TypeScript files under 300 lines. Hard maximum is 350–400 lines, and CI enforces the 400. Blank lines and comments do not count toward it, so documenting a file never costs you headroom. Tests (**/*.spec.ts, **/*.e2e.ts, apps/*-e2e/**) are held to 1200 instead — the guidance below is about production code.

  • When creating new files, design them to stay within this limit from the start.
  • When adding a feature to an existing file that would push it past 350 lines, refactor first: extract helpers, sub-services, or feature modules before adding the new code.
  • When you notice a file already exceeds 350 lines, proactively suggest a refactoring (or perform it if the change is straightforward) — even if the immediate task is small.

Typical split strategies:

  • Angular components: extract child components, move logic to a dedicated service or store feature
  • Signal store features: split into smaller with* feature functions in separate files
  • Services: split by responsibility (e.g. separate API, transformation, and state concerns)
  • Utility files: group by domain and export from a barrel index.ts

This rule exists to keep the codebase navigable and reviewable. A 150-line file is always preferable to a 500-line file.


Angular Coding Standards:

This project uses modern Angular signal-based APIs and patterns. ALWAYS use the following:

  • Component Queries: Use viewChild(), viewChildren(), contentChild(), contentChildren() instead of @ViewChild, @ViewChildren, @ContentChild, @ContentChildren decorators

    // ✅ 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() and output() 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, @switch instead 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 the userData override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local debugging. The guard also forwards that launch's argv and working directory, so iptvnator playlist.m3u against a running app opens the playlist instead of being discarded.

Database:

  • ORM: Drizzle ORM with better-sqlite3 (local SQLite file)
  • Location: ~/.iptvnator/databases/iptvnator.db (avoids spaces in path)
  • Schema (libs/shared/database/src/lib/schema.ts — canonical; apps/electron-backend/src/app/database/schema.ts is a backwards-compat re-export shim):
    • playlists - Playlist metadata (M3U, Xtream, Stalker)
    • categories - Content categories (live, movies, series)
    • content - Streams/VOD/series items
    • favorites - User favorites
    • recentlyViewed - Watch history
    • epgChannels, epgPrograms - Persisted EPG data
    • epgChannelMappings (epg_channel_mappings) - Manual EPG channel mappings (defined in epg-mapping.schema.ts, re-exported by schema.ts)
    • playbackPositions - Resume positions
    • downloads - Download manager state
    • appState - Key-value app state (also tracks one-off data migrations)
    • tmdbMetadata - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language)
    • vodSourcePins (vod_source_pins) - VOD multi-source per-movie preferred playlist, keyed by a portal-agnostic match key (defined in vod-source-pins.schema.ts, re-exported by schema.ts)
  • Connection: libs/shared/database/src/lib/connection.ts
    • createTables() auto-creates tables on init (CREATE TABLE IF NOT EXISTS)
    • Provides full read-write access for electron-backend and a read-only mode
    • A root drizzle.config.ts configures Drizzle Kit tooling (points at the schema via the compat shim)

IPC Communication:

  • Preload script: apps/electron-backend/src/app/api/main.preload.ts
    • Exposes window.electron API via contextBridge
    • All IPC channels defined here (playlist operations, EPG, database CRUD, external players, etc.)
    • The canonical TypeScript contract is ElectronBridgeApi in libs/shared/interfaces/src/lib/electron-api.interface.ts; global.d.ts, apps/web/src/typings.d.ts, and main.preload.ts must reference this shared type instead of maintaining separate method lists.
  • Event handlers: apps/electron-backend/src/app/events/
    • database.events.ts - Database CRUD operations
    • playlist.events.ts - Playlist import/update
    • playlist-open.events.ts - Playlist files handed over by the OS (argv, file association, macOS open-file); the queue itself lives in services/playlist-open-request.ts
    • epg.events.ts - EPG IPC registration; freshness/fetch orchestration lives in epg-fetch.service.ts, manual channel-mapping resolution and CRUD in epg-mapping.service.ts, worker lifecycle in epg-worker.service.ts, DB lookups in epg-query.service.ts
    • xtream.events.ts - Xtream Codes API
    • stalker.events.ts - Stalker portal API
    • player.events.ts - External player IPC registration; MPV/VLC lifecycle logic lives in mpv-session.service.ts, vlc-session.service.ts, and shared external-player-* helpers
    • settings.events.ts - App settings
    • electron.events.ts - App version, etc.

Workers (apps/electron-backend/src/app/workers/):

  • EPG parsing: epg-parser.worker.ts; main-process worker lifecycle is coordinated from apps/electron-backend/src/app/events/epg-worker.service.ts
  • Non-EPG SQLite work: database.worker.ts (see docs/architecture/sqlite-db-worker.md)
  • Playlist refresh: playlist-refresh.worker.ts; explicit cancellation is main-process-owned and terminates the one-shot worker before acknowledging PLAYLIST_CANCEL_REFRESH (see docs/architecture/m3u-playlist-module.md)

Key Features

Playlist Support:

  • M3U/M3U8 files (local or URL)
  • Xtream Codes API (username, password, serverUrl)
  • Stalker portal (macAddress, url)

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
  • DASH + ClearKey (M3U module): .mpd channels play through a lazily loaded Shaka Player source engine inside the HTML5 and ArtPlayer components (no new player in settings). ClearKey keys come from #KODIPROP:inputstream.adaptive.* lines, post-processed into Channel.drm by extractDrmFromRaw() in libs/shared/m3u-utils (hooked in createPlaylistObject(), covering all import paths). DASH channels always play inline: isDashChannel() bypasses the external-player setting (radio precedent) and routes Video.js/MPV/VLC/ embedded-MPV users to the HTML5 player via playerOverride (ArtPlayer keeps ArtPlayer). Unsupported license types (Widevine/PlayReady — out of scope, need the castLabs Electron fork) surface a DRM playback diagnostic instead of crashing. ClearKey EME works in stock Electron. Engine: libs/ui/playback/src/lib/shaka-engine/; details in docs/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 --wid against an app-owned child HWND; Linux spawns an out-of-process mpv --wid=<x11-window> controlled over a JSON IPC socket (X11/XWayland only, requires system mpv on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so EmbeddedMpvNativeService holds an Electron powerSaveBlocker (prevent-display-sleep) whenever any session's status is playing, and releases it on pause, dispose, or shutdown. Renderer bounds are CSS pixels; the service converts them to native units in the main process (embedded-mpv-bounds.util.ts: × page zoom everywhere, × display scale on Windows/Linux whose child windows are positioned in physical pixels; frame-copy bounds stay unscaled), and the session controller re-syncs bounds when devicePixelRatio changes. Service: apps/electron-backend/src/app/services/embedded-mpv-native.service.ts; full architecture: docs/architecture/embedded-mpv-native.md.
  • Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux x64 + Windows; enabled via Settings > Playback > Embedded MPV: frame-copy engine (restart required) or IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen (CGL on macOS, EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the preload frame pump uploads them to <canvas data-embedded-mpv-frame>. Shared app-player-controls owns the DOM UI; native-view retains the legacy dock. On Linux, only iptvnator_mpv_helper may link libmpv; Electron, its shipped libraries, the addon, and frame reader must not. Pristine afterPack/unpacked layouts scan Electron libraries recursively; extracted Snap payloads exclude only the package-manager lib/** and usr/lib/** trees overlaid into the same root. Every other directory remains recursive, and Electron-library symlinks still fail closed. electron-backend/native{,/**/*} is excluded from app.asar; afterPack alone owns the profile-normalized unpacked native tree, and package checks reject every archived /electron-backend/native/** entry. Packaged addon, frame-reader, and helper discovery uses only package-owned app.asar.unpacked paths; cwd/dist candidates remain development-only. Official x64 packages use three separate profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and Flatpak bundles the same closure. Flatpak is an isolated packaging pass and keeps iptvnator as the real Electron ELF so Electron Builder's electron-wrapper passes it directly to Zypak. Other Linux targets retain the conditional iptvnator wrapper and iptvnator.bin. Mixed Flatpak/non-Flatpak target sets fail before mutation. Exact system dependencies are DEB=libmpv2,libegl1,libgl1,libgbm1, RPM=mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm, and Pacman=mpv,libglvnd,mesa. The DEB contract is verified on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy provides libmpv1. ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the fail-closed packaged manifest/file/hash gate and bounded --runtime-probe; any failure keeps the sandbox enabled, records a stable reason, and falls back to native-view without crashing. Snap is core22/strict and uses an exact private shared-memory plug plus the graphics-core22 content plug at a real empty mode-0755 $SNAP/graphics, with external mesa-core22 as the default provider. Its only provider-data layouts bind /usr/share/libdrm from $SNAP/graphics/libdrm and symlink /usr/share/drirc.d to $SNAP/graphics/drirc.d. Installed-Snap CI requires controlled unavailable status after disconnect, then reconnects and requires success. The helper links libGL.so.1, and probe/playback share a sanitized loader environment in which ambient audit, preload, library, graphics-driver, and shell-startup overrides are removed; the validated private closure plus trusted host GL, graphics-content, core22 base x64, and exact GNOME-platform roots have explicit precedence. The core22 base stays ahead of GNOME so the older libedit.so.2 requiring libtinfo.so.5 cannot shadow the base ABI. The extracted-artifact verifier removes the identical unsafe loader/graphics/ shell set before direct helper smoke while preserving selectors such as LIBGL_ALWAYS_SOFTWARE. Snap fixes the wrapper PATH, removes exported BASH_FUNC_* functions, and launches probe/playback through the regular executable $SNAP/graphics/bin/graphics-core22-provider-wrapper; a missing or disconnected provider returns snap-graphics-provider-unavailable before helper spawn. The packaging-only --embedded-mpv-runtime-probe app switch runs the complete packaged gate before BrowserWindow startup and emits one availability JSON line. A nonzero helper exit keeps top-level reason helper-probe-failed; helperReason is present only for an exact protocol-v1 line carrying a fixed allowlisted reason, and its optional helperDetail must be 1–1024 printable ASCII characters. Invalid detail suppresses both helper fields. Every probe uses an explicit 16 MiB aggregate captured-output ceiling independent of tracing. With IPTVNATOR_TRACE_PLAYER=1, non-empty helper stderr is emitted separately as one JSON-escaped stderr line with a 16,384-character stderr limit and an explicit truncated field; trace-write failure cannot change availability. Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded channel. The exact packaged Flatpak /app context reconstructs only Freedesktop Platform 24.08's immutable __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS; its CI smoke invokes that application-level probe instead of the helper directly. The packaged x64 Playwright smoke runs its fixture-contract target first and passes Chromium --ignore-gpu-blocklist so CI llvmpipe exposes WebGL2; this does not bypass the runtime gate, and --no-sandbox remains root-only. Bundled Linux packages carry hash-validated embedded-mpv-notices.json, THIRD_PARTY_NOTICES.txt, and licenses/**. CI caches the staged runtime plus immutable source inputs, never finished notices or the compliance tarball; it regenerates those notices and the VCS-metadata-free linux-frame-copy-runtime-sources.tar.xz for the current checkout while preserving the exact pinned six recursive libplacebo submodule records. Each record is canonical full-commit safe/path; clone-depth dependent git describe annotations are discarded and never form part of the provenance identity. Its source index carries the globally sorted libplacebo directory/file/symlink inventory; file hashes, sizes, executable bits, link targets, aggregates, and canonical tree digest must match the trusted pinned checkout. The archive has an exact member/type layout and its metadata/archive-sha256.txt records must match the actual source archives. Concatenated tar/xz streams are inspected past every end marker. Every bundled x64 package manifest binds the final archive's SHA-256 and repository revision; system and marker-only packages do not carry that binding. Snap Store publication runs only from a public v* GitHub release that already contains the Snap assets and exactly one source archive. Before any upload, the workflow hashes and checks the archive's exact member/type set and size bounds, verifies its clean tag revision, pinned sources including the six recursive submodule records and exact libplacebo tree digest, legal payload, and exact released tooling, then performs bounded extraction and static validation for every Snap. That public-release boundary independently revalidates the exact strict meta/snap.yaml graphics/shared-memory contract and enumerates resources/app.asar, rejecting any archived electron-backend/native/** payload before publication. Its bounded ASAR header reader uses only Node built-ins and released local tooling, so the clean tag checkout does not require node_modules. Exactly one x64 Snap must have matching sourceArchive and sourceRuntime; any non-x64 Snap remains marker-only. Checkout and artifact-transfer actions are pinned to full commits; checkout does not persist credentials, and repository credentials are scoped to download steps. A secretless verification job copies assets through no-follow descriptors, checks them before and after inspection, writes an exact receipt, fully reverifies a root-owned read-only snapshot, and transfers only that data through the pinned artifact service while passing the receipt digest separately through a job output. The dependent publish job uses a bounded ubuntu-latest runner with no checkout or release-tag code, verifies that digest plus the exact receipt, asset hashes, and file-only layout, root-seals the data again, and installs Snapcraft directly. Its final fixed shell step alone receives the Store credential, resolves no PATH command, executes no released code, and exposes that credential only to each exact /snap/bin/snapcraft upload --release=edge process. Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke; GitHub Actions never promotes automatically. On Windows, package validation requires the exact MPV DLL named by the helper's PE import table beside the executable. Backend adapter: apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts; shared-controls adapter: libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts; helper: apps/electron-backend/native/helper/; canonical packaging/runtime contracts: docs/architecture/embedded-mpv-native.md and tools/embedded-mpv/README.md.
  • Shared player-controls layer: libs/ui/playback/src/lib/player-controls/ exports the engine-neutral PlayerController contract, standalone app-player-controls, a generic web-video adapter/helper, and component-scoped WEB_PLAYER_SHARED_CONTROLS rollout token. In fullscreen, app-player-controls shows a pointer-transparent media-title overlay at the top while controls are revealed (mediaTitle input: movie/channel/series name, plus an S01E03 second line for episodes; series names flow from the detail views through PortalInlinePlayerComponent.seriesTitle and WebPlayerViewComponent.mediaTitle). Persisted Settings.webPlayerSharedControls is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. WebPlayerViewComponent snapshots the preference into the immutable token for each new player host. The parent /workspace route awaits the initial SettingsStore load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through EmbeddedMpvControlsAdapter, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. showControls=false detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: HtmlVideoPlayerComponent provides a component-scoped WebVideoControlsAdapter, while its neutral web-video-support bridge is shared with ArtPlayer and owns HLS/Shaka(DASH)/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. HtmlVideoElementSession owns native video-event lifecycle, persisted volume, and start-time/time/ended propagation. Video.js is the third guarded consumer: VjsPlayerComponent provides a component-scoped WebVideoControlsAdapter; its bridge rebinds the current Tech video after playerreset, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: ArtPlayerComponent provides a component-scoped WebVideoControlsAdapter; ArtPlayerSourceSession owns HLS/DASH(Shaka)/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed customType callbacks, while ArtPlayerVideoSession owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/Shaka/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. WebPlayerViewComponent.resolvedIsLive supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so 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.showCaptions is deliberately outside this rollout gate: it is engine state, so the preference-off players apply it through the same helpers without an adapter (WebVideoSourceTracks for HTML5/ArtPlayer, VjsLegacyTracks for Video.js), re-applying it as the engine adds or switches text tracks. The two modes differ in how long it is enforced: shared controls are authoritative for the session (user intent arrives via setSubtitleTrack), while vendor chrome is source-default — the preference seeds each new source and is released once the media reports playing, so the engine's own caption menu keeps working. Mode selection is the optional playbackStarted probe the legacy owners pass to all three helpers (HLS, native text tracks, Shaka); in that mode the HLS helper deselects (subtitleTrack = -1) rather than hiding, since subtitleDisplay would override the vendor menu, and DASH is seeded by ShakaVideoSession.start() after the manifest loads. WebPlayerViewComponent reads it from SettingsStore instead of a host input so every host (M3U, Xtream/Stalker live layouts, portal detail inline player) inherits it. Contract: docs/architecture/player-controls-contract.md.
  • Shared web picture-in-picture stays inside that default-off rollout. PlayerController exposes capability pictureInPicture, state pictureInPictureActive/canPictureInPicture, and command togglePictureInPicture(). HTML5, Video.js, and ArtPlayer use standard element PiP from the adapter's attached video; shared ArtPlayer keeps vendor pip: false, while preference-off native/vendor 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.
  • WebVideoControlsAdapter supplies its current video and binding generation to WebVideoPictureInPictureController; the controller reads the video's ownerDocument, while browser enter/leave events remain authoritative. Exact-owner exit stays available if request support changes. Request/exit invocation remains synchronous for user activation, one operation is serialized, and binding generation plus exact video identity protects replacement and teardown from stale completion. Video.js Tech reset and ArtPlayer rebuild rebind with exact-owner cleanup; HTML5 source changes on a retained target preserve PiP. 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.

VOD/Series Detail Pages (two-state layout):

  • Xtream and Stalker detail pages use the shared PortalDetailShellComponent (libs/ui/components/src/lib/portal-detail-shell/) with two states: Browse (hero with poster/metadata/actions, episodes below) and Watch (hero collapses with a ~300ms morph, the inline player takes the full content width, metadata moves to an About block below the episodes)
  • The inline player (PortalInlinePlayerComponent) renders a full-width theater stage (.player-shell__viewport): the 16:9 player is centered and letterboxed so the leftover on wide-short windows is always the stage's black background, never app surface. An opt-in playerAmbientMode setting (Settings → Playback, default off, built-in web players only) fills that leftover with a blurred, dimmed copy of the poster (YouTube "Ambient mode" style)
  • For inline series playback on wide windows the stage instead docks the player left and shows an "Up Next" episode rail in the leftover column (app-up-next-rail in libs/ui/playback/src/lib/portal-inline-player/): rest of the current season plus next-season spillover, playing episode highlighted, watch-progress bars from playback positions; clicking plays inline via the host's episode flow (both Xtream and Stalker). Gated by the playerUpNextRail setting (default on, web players only) and a ≥320px leftover-width check via ResizeObserver — narrower windows keep the centered theater/ambient stage; movies and live never show the rail. The rail is opaque and sits on top of the ambient fill
  • Watch state derives from inlinePlayback() !== null only; external MPV/VLC playback keeps the browse layout. Esc and "Close player" exit to browse without navigation; the now-playing back arrow is route-level back (straight to the list via the host's goBack())
  • Xtream VOD treats metadata presentation and playability as separate contracts. Empty or sparse get_vod_info data keeps the curated fallback detail page, while Play/Resume, Favorite, and Download remain available whenever a positive stream id and non-empty container extension resolve from movie_data or the catalog fields. Playback fields are selected as one atomic pair in detail → recovered catalog → owner-valid cached catalog order; incomplete candidates never combine into a synthetic source. In-memory VOD categories/streams carry their owner playlist, and cross-portal Favorites/Recent details ignore arrays from another playlist so colliding Xtream ids cannot inject stale playback or presentation data. When Electron's normalized catalog cache lacks the extension, the detail loader immediately publishes the sparse fallback and ends its loading state, then performs a best-effort category-scoped raw catalog lookup and reactively upgrades the same item with actions on success. It maps the normal SQLite route category through all persisted categories, including hidden ones, while also accepting the provider xtream_id carried by cross-portal Similar links; ambiguous numeric matches keep local-id precedence, deduplicate provider candidates, and try the next candidate when the exact VOD is absent. PWA falls back to API categories. It skips that request when existing data is sufficient, never sends an unresolved database id as a provider id, preserves concurrent metadata enrichment, and drops late detail/recovery responses after replacement, playlist reset, or detail teardown. Inline playback moves either detail page into Watch; external MPV/VLC remains in Browse. Unresolvable items expose no actions, and playback/download titles and posters fall back through info, movie_data, then catalog fields.
  • A successful external MPV/VLC episode launch immediately persists the selected episode as the latest playback-position entry and retargets the series CTA to Play episode N; real player telemetry overwrites that marker when available, so episode identity is reliable while exact external timestamps remain best-effort.
  • Stalker preserves this contract for regular /series, embedded VOD series[], and lazy Ministra VOD is_series items: 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. This metadata lets the dashboard render the tracked S/E badge for VOD-backed series. Existing playback rows without it remain badge-less until the episode is played again.
  • Hosts pass hero chips/meta/actions as *appDetailTags/*appDetailMeta/*appDetailActions templates; the shell stamps them into both the hero and the About block
  • Seasons are tabs (SeasonTabsComponent, dropdown beyond 6 seasons) with auto-selection (playing episode's season → resume season → first) that fires the same seasonSelected lazy-load/enrichment hooks as manual clicks; grid/list episode view toggle persists to localStorage; season descriptions come from get_series_info (Xtream) 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-caption line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (libs/ui/components/src/lib/vod-sources/; not MatMenu, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same matchKind and vodAutoFailover and both write the setting back. The chip counts alternative streams; the caption ("also found in N other playlists") counts distinct playlists via alternativePlaylistCount, because the popover groups one portal's copies under that portal.
  • Scope v1 is Xtream ↔ Xtream, movies only, Electron only. Stalker never reaches the content table and M3U is a JSON blob whose search forces content_type:'live'; both are additive later since VodSourceCandidate.portalType already carries all three. In the PWA every entry point is gated off by a bridge typeof check and the chip renders nothing.
  • Metadata provenance is the core contract. Every field is {value, provenance} where api/probe are facts (plain tag), parsed is a title-regex guess (tag prefixed ~, warn colour), and absent renders no tag at all plus a check chip. factualOnly() in vod-source-metadata.util.ts is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. VodSourceProbeStatus separates fail (contacted and refused) from unknown (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel width because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying api provenance. The route's OWN row is never resolved, so it takes its facts from the get_vod_info the page already loaded (providerVodMetadataOf, shared with the resolver) and picks them up via refreshRouteFacts() even when they arrive without changing the movie identity — otherwise audioDiffersFactually has nothing on one side and the dub warning cannot fire on a route-to-alternative switch.
  • Discovery (DB_FIND_TITLE_SOURCES, trigram FTS over content_title_fts) is lazy and returns only what the content table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded in SQL and duplicates collapse there too (GROUP BY cat.playlist_id, c.xtream_id before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*') ordered by title length with no row limit — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: normalizeTitleKeys strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an exact match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by LOWER() (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so caseInsensitiveGlobPattern folds the case in JS and emits one [lowerUpper] class per character — returning null, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (ß→SS). The movie's own year comes from releaseTagYear (bracketed or trailing only), never extractYear: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (keepContentId), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because content stores no container_extension and constructVodUrl returns '' without one — each alternative costs a live get_vod_info against the foreign playlist's credentials.
  • Switching = one inlinePlayback.set({...next, startTime}), never null-then-set, so the player and engine survive and re-seek. The carried position is read before the 15s persistence throttle, and VodDetailsPlaybackService uses a one-shot resumeSettled latch so a resuming engine's timeupdate at ~0 cannot overwrite the resume point. handleInlineTimeUpdate returns that verdict and the route feeds multi-source the requested startTime until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (seedResumeSeconds, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting createLogger/redactSensitiveData — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
  • Pins are keyed portal-agnostically (tmdb:{id} else title:{base}:{year} else the yearless title:{base}:, vod_source_pins table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (pinKeysFor): lookup passes every alias most-trusted-first, write holds only keys naming exactly one film, and loaded records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A write stores the decision under every key in write (setVodSourcePin(db, pin, retireKeys, aliasKeys): one upsert per key plus the leftover retirement, in a single transaction), because a movie's identity grows — recorded only under the enriched tmdb: key, a pin is invisible to the next reopen, which starts out with just a title and a year, and stays invisible for good if enrichment is off or never answers. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. The primary button says nothing at all until that row is in, and "is it in" is answered by comparing the loaded pin id rather than mere presence, or re-pinning would leave the button wearing the previous copy's timecode. An external player launched for an alternative carries the OTHER playlist's ids, so VodDetailsPlaybackBindings.activeSource feeds one ownsContent() predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: vodMultiSourceMovieKey (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while vodMultiSourceSessionKey (playlistId:contentId) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
  • Claims in the present tense (the "Playing from" caption and the source row's Playing badge) are gated on VodDetailsRouteComponent.playbackLive, never on isActive — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a timeupdate has arrived (inlinePlayback() is only the request to play); external it means the session is past launching. A merely selected row reads Current.
  • Pins are included in playlist backup as the optional sourcePins collection, carried under the playlist they point at; matchKey survives untouched and only the playlist id is remapped on restore (older archives simply lack the field).
  • Auto-failover is Settings.vodAutoFailover, opt-in and off by default, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (reportsPlaybackFailures()); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (triedSourceIds only grows), so it terminates structurally — but SELECTION is not an attempt: setActiveSource only selects, markPlaying spends the turn, and runFailover retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one — switchTo reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through playlistDisplayLabel, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state a spoken language as fact — audioLanguage, never audio. The latter holds the codec whenever the fact came from the API, and a codec cannot answer that question: AAC and AC3 routinely carry the same dub while two AC3 tracks can carry different ones, so comparing codecs fired on identical-language re-encodes and stayed silent on real dub changes. Few panels tag a language, so the warning is usually silent — which is the honest state.
  • HEAD probe reuses the main-process handler extracted to apps/electron-backend/src/app/events/stream-probe.ts (STREAM_PROBE_URL; XTREAM_PROBE_URL still delegates there for catchup), and carries the playlist's own userAgent/referer/origin (StreamProbeHeaders) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
  • See docs/architecture/vod-multi-source.md

Radio Player:

  • Dedicated audio player for channels with radio="true" M3U attribute
  • Cinematic layout: blurred station logo as backdrop, floating artwork card, transport controls
  • Always uses the built-in inline player — external player settings (MPV/VLC) are ignored for radio
  • EPG panel is hidden for radio channels (radio streams have no EPG data)
  • Volume synced with video player via shared localStorage key 'volume'
  • Keyboard shortcuts: ArrowUp/ArrowDown (volume), M (mute)
  • Component: libs/ui/playback/src/lib/audio-player/audio-player.component.ts

EPG (Electronic Program Guide):

  • XMLTV format support
  • 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_mappings keyed by the M3U lookup key or a playlist-scoped portal key (xtream:{playlistId}:{id} / stalker:{playlistId}:{id}, helpers in libs/shared/interfaces/src/lib/epg-mapping-key.util.ts); resolved on every EPG path (single + batch IPC lookups, portal detail views, preview queues); dialog: libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/

TMDB Metadata Enrichment (opt-in):

  • Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with ru-RU so exact-title matching works
  • "Similar" rail in ALL detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts, normalizeTitleKeys); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (CrossPortalSimilarService in libs/services, batched DB_MATCH_TITLES, Electron only); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation
  • Season/episode enrichment: opening a season lazily fetches /tv/{id}/season/{n} and overlays real episode names, overviews and stills via mergeEpisodesWithTmdb (Xtream: XtreamStore.enrichSelectedSerialSeason; Stalker: overlay in the series view's mappedSeasons); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (resolveEnrichmentSeasonNumber in libs/shared/interfaces/src/lib/season-marker.util.ts)
  • Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched DB_MATCH_TITLES request; Electron-only, dashboardRails.tmdbTrending toggle) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per session; series heroes show the tracked S/E badge from playback positions) — DashboardTrendingService in libs/workspace/dashboard/data-access, DashboardHeroTmdbService in libs/workspace/dashboard/feature; both load async after first paint
  • Series detail views show a TMDB production-status chip (tmdb_status, e.g. Ended / Returning) — TMDB sends status in English regardless of request language, so it is normalized to a token by normalizeSeriesStatus and rendered via seriesStatusLabelKey translations; person pages show deathday alongside birthday
  • Actor pages: cast avatar chips are clickable (TMDB person id) and open actor/:personId inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (tmdb_directors via enrichedDirectors/enrichedCreators in tmdb-credits.ts) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (?q=); the in-portal search page shows a Back button (SearchLayoutComponent.showBackButton → Location.back()) so users can return to the actor page; shared UI in libs/ui/shared-portals (ActorViewComponent)
  • Actor page "All portals" scope (Electron only): batched DB_MATCH_TITLES worker op (trigram FTS over all imported Xtream playlists, apps/electron-backend/src/app/database/operations/title-match.operations.ts); normalizeTitle is shared renderer/worker via libs/shared/interfaces/src/lib/title-normalization.util.ts
  • Opt-in via Settings > Metadata (TMDB) (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (DEFAULT_TMDB_API_KEY in libs/services/src/lib/tmdb/tmdb-config.ts — an empty placeholder in the repo by design; the real key lives in the TMDB_API_KEY GitHub Actions secret and is injected at CI build time by tools/tmdb/inject-tmdb-key.mjs)
  • Match confidence: a provider tmdb_id is a strong hint, not gospel — its payload is weighed against the item (assessProviderId: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (badProviderId:<id> row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment
  • Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded)
  • Cached in SQLite tmdb_metadata (Electron, via DB worker ops DB_GET/SET_TMDB_METADATA, plus DB_GET_TMDB_CACHE_STATS / DB_CLEAR_TMDB_METADATA behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the migration:tmdb-search-lookup-v2-cache-cleanup:v1 app-state marker.
  • Service layer: libs/services/src/lib/tmdb/; store glue: libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts and libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts (hooked in withStalkerSelection().setSelectedItem)
  • TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About
  • See docs/architecture/tmdb-metadata-enrichment.md

Favorites and Recently Viewed:

  • Per-playlist favorites and global favorites
  • Recently viewed tracks watch history

Internationalization:

  • Uses @ngx-translate with 19 language files in apps/web/src/assets/i18n/

Development Notes

Environment Detection and Dual-Mode Architecture

The app determines whether it's running in Electron or as a PWA by checking:

window.electron; // truthy in Electron, undefined in browser

Why Dual Mode? IPTVnator supports both Electron (desktop app) and PWA (web browser) to provide flexibility:

  • Electron: Full-featured desktop experience with local database, external player support (MPV/VLC), and native file system access
  • PWA: Lightweight web version that runs in any browser without installation

Environment-Specific Behavior:

  • app.config.ts - DataFactory() selects DataService implementation based on environment
  • app.routes.ts - Same /workspace/... route tree in both environments; guards keep Electron-only routes (e.g. global search) out of the PWA
  • Storage layer switches automatically:
    • Electron → SQLite/Drizzle ORM → ~/.iptvnator/databases/iptvnator.db
    • PWA → IndexedDB → Browser storage
  • External player support (MPV/VLC) only available in Electron
  • File system operations only available in Electron (uploading playlists from disk)

Base Href Configuration: The app uses different base href values depending on the build target:

  • Development & PWA: baseHref="/" (from index.html)
    • Used by: pnpm run serve:frontend, pnpm run build:frontend:pwa
    • For web servers with proper routing
  • Electron Production: baseHref="./" (overridden in build config)
    • Used by: pnpm run build:backend, pnpm run make:app
    • Required for file:// protocol in Electron

Build configurations in apps/web/project.json:

  • production: Electron build with baseHref="./"
  • pwa: Web deployment with baseHref="/"
  • development: Dev mode with baseHref="/" from index.html

Factory Pattern Implementation: The factory pattern ensures a single codebase works in both environments without conditional checks scattered throughout the application. All environment-specific logic is encapsulated in the service implementations.

Build Commit In About: CI injects the git commit into apps/web/src/environments/build-commit.ts via tools/build/inject-build-commit.mjs (same placeholder pattern as the TMDB key inject); Settings > About then shows "<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-angular and ng-mocks
  • E2E tests: Playwright testing the web app and Electron app
  • Backend tests use standard Jest
  • Bug fixes should add focused regression coverage unless there is a documented reason not to.
  • Use the impact-based validation policy in Regression Prevention And Test Updates to choose targeted unit tests, atomized E2E targets, broad suites, or CDP/manual verification.

Nx Commands

Use nx CLI for better performance:

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:build depends on web:build
  • Output goes to dist/apps/electron-backend (backend) and dist/apps/web (frontend)
  • Packaging combines both into distributable

Database Migrations

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:

  1. Define handler in appropriate events file (e.g., database.events.ts)
  2. Register with ipcMain.handle() in the event bootstrap function
  3. Expose in preload script via contextBridge.exposeInMainWorld()
  4. Call from Angular via window.electron.<methodName>()

Adding New Playlist Source:

  1. Add type to libs/shared/interfaces/src/lib/playlist.interface.ts
  2. Create event handler in apps/electron-backend/src/app/events/
  3. Add the import flow in libs/playlist/import/feature/ (add-playlist dialog + per-source import components) and surface it on the dashboard (libs/workspace/dashboard/) if needed
  4. Update database schema if needed

State Management:

  • Use NgRx for global application state (M3U playlists, libs/m3u-state)
  • Use NgRx Signal Store with signalStoreFeature() composition for portal/feature state (XtreamStore, StalkerStore)
  • Use NgRx signals for reactive data streams

General Guidelines for working with Nx

  • For navigating/exploring the workspace, invoke the nx-workspace skill first when it is available - it has patterns for querying projects, targets, and dependencies. If it is unavailable, use pnpm nx show projects, pnpm nx graph, and project project.json files directly.
  • When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through nx (i.e. nx run, nx run-many, nx affected) instead of using the underlying tooling directly
  • Prefix nx commands with the workspace's package manager (e.g., pnpm nx build, npm exec nx test) - avoids using globally installed CLI
  • You have access to the Nx MCP server and its tools, use them to help the user
  • For Nx plugin best practices, check node_modules/@nx/<plugin>/PLUGIN.md. Not all plugins have this file - proceed without it if unavailable.
  • NEVER guess CLI flags - always check nx_docs or --help first when unsure

Scaffolding & Generators

  • For scaffolding tasks (creating apps, libs, project structure, setup), ALWAYS invoke the nx-generate skill FIRST before exploring or calling MCP tools

When to use nx_docs

  • USE for: advanced config options, unfamiliar flags, migration guides, plugin configuration, edge cases
  • DON'T USE for: basic generator syntax (nx g @nx/react:app), standard commands, things you already know
  • The nx-generate skill handles generator discovery internally - don't call nx_docs just to look up generator syntax