* fix(stalker): fall back to short EPG when bulk EPG misses the current programme
Some portals' bulk get_epg_info returns only future programmes — the one
airing now is absent. The ITV channel-list previews read exclusively from
that bulk map, so every row showed 'No program information available', and
the EPG panel preferred any non-empty bulk list over the short-EPG fallback,
so it showed upcoming shows with no 'on now' entry. Recently Viewed uses
get_short_epg per channel, which is why the same channel worked there.
Panel: merge the short-EPG fallback into the bulk list instead of either/or,
and trigger the fallback whenever the bulk list has no currently airing
programme (not only when it is empty).
Rows: new throttled StalkerEpgPreviewQueue (mirroring Xtream's
EpgQueueService — bounded concurrency, inter-request spacing, 5-minute cache
including empty results, reset on playlist switch) fetches get_short_epg for
rendered channels the settled bulk guide cannot answer.
Docs: stalker-epg.md fallback contract updated accordingly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): bound the preview-queue burst and keep mapped channels portal-free
Review follow-up (Codex P1/P2 on #1386):
P1 — request volume: each queue sync is now capped at 30 channels (top of
the list first, where a freshly opened category is scrolled to), and the
sidebar scroll handler re-syncs (throttled, 300 ms) to fill the next gaps.
Request count now tracks how far the user scrolls instead of how many rows
are rendered; caching (including empty results) and 200 ms pacing remain.
P2 — manual mappings: a channel whose bulk record comes from a manual XMLTV
mapping never falls back to the portal short EPG. The panel path resolves
the channel's mapping before falling back and bails when an override owns
the channel; the row path excludes overridden channels from both the queue
and its cache. New store query hasItvEpgMappingOverride() exposes override
ownership; merging portal data into a mapped schedule could otherwise
surface the portal's programme — the exact thing the mapping replaces.
Docs updated (stalker-epg.md); regression tests for the cap, the mapped-
channel suppression, and the override query.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): scope the panel EPG fallback by channel and revalidate queued previews
Review follow-up (Codex round 2 on #1386):
The panel's short-EPG fallback is now stored with the channel id it was
fetched for, and activeEpgPrograms merges it only while that channel is
still selected. A channel switch moves the selection synchronously but the
old fallback is replaced only after the new channel's EPG load runs, so the
unscoped merge mixed the previous channel's programmes into the new panel
during slow playback resolution — and left them there when resolution
failed.
The row-preview queue's completion callback now revalidates ownership: a
row claimed while the fetch was in flight — by a manual mapping override or
by bulk data — is never overwritten by the late portal response.
Both races covered by a new focused spec (verified to fail on the pre-fix
component); stalker-epg.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): supersede the preview backlog on view exit and own empty mappings
Review follow-up (Codex round 3 on #1386):
The preview queue is now superseded when the rendered channel list empties
(a legacy-paged category switch clears it before the new channels arrive)
and when the view leaves ITV for radio — an abandoned view's backlog no
longer keeps issuing get_short_epg requests for rows that are gone.
Mapping ownership is now tracked separately from the mapped guide's
programs: a saved mapping whose XMLTV channel currently has no entries
still owns its channel, so hasItvEpgMappingOverride() keeps the portal
short-EPG fallback out — consistent with a mapping's purpose of replacing
portal data.
Both covered by regression tests (verified to fail pre-fix; the ITV-exit
test re-arms the backlog after init because the playlist effect's first run
resets the queue); stalker-epg.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(stalker): reconcile every EPG contract reference with the row fallback
Review follow-up (Codex round 4 on #1386): the overview, get_short_epg API
notes, and data-mapping sections of stalker-epg.md still stated that rows
never issue per-row requests, and the stalker-portal skill instructed that
only the active channel may fall back — contradicting the contract this PR
establishes. All references now describe the bulk-first row previews with
the throttled short-EPG fallback queue. skills:validate passes (the skill
stays within its 500-word budget).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): publish empty-mapping ownership reactively
Review follow-up (Codex round 5 on #1386): when the row-preview fetch
finishes before the mapping lookup, a portal programme is already rendered.
An empty mapped guide then recorded ownership only in a plain Set — no
state was patched, the preview effect never reran, and the stale portal row
survived. applyMappedItvEpg now re-patches bulkItvEpgByChannel (identical
content, new reference — deliberately) whenever it establishes new
ownership, even without programs, so the rerun sync removes the fallback
row. Store regression test extended (fails pre-fix); stalker-epg.md updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.
Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).
CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".
Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.
The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.
Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.
Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(build): include shared UI stylesheets in Nx cache inputs
`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.
Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.
Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.
`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.
Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(build): spawn git without a shell in the stylesheet check
`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.
Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.
Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.
Reported by Codex review on #1360.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(styles): move nav-list partial into ui-styles (#1361)
* fix(build): count every target of a comma-separated Sass @import
`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.
Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.
The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Third slice of the release-notes pipeline (#1256 format+generator, #1257 CI
gate): release screenshots become reproducible and provably mock-only.
The v0.20 capture script was single-use (hard-coded slugs, paths, hero) and
fail-open: a lost IPTVNATOR_E2E_DATA_DIR silently fell back to the user's
real ~/.iptvnator database, `...process.env` leaked ambient TMDB keys and
proxies, nothing gated network access, and no frame content was ever
validated. Each hole leaks real playlists, credentials, or copyrighted
artwork into published screenshots without a single signal.
New pipeline:
- tools/release/screenshots.manifest.json — declarative shots (slug, title,
named setup steps, themes). Adding a feature shot = one manifest entry.
- capture-release-screenshots.ts — orchestrator; output goes to
apps/website/public/blog/<release>/screenshots/<slug>-<theme>.png, release
slug derived from package.json (or --release), --only/--theme filters.
- capture-app-driver.ts / capture-navigation.ts — launch, seeding, theme,
and the named-action vocabulary; actions are order-independent (every
portal action starts from the dashboard).
- screenshot-guards.mjs — the fail-closed policy, pure and unit-tested:
G1 the real database is snapshotted (sha256+mtime) before launch and must
be byte-identical after; the isolated DB must actually exist
G2 the app receives an allowlisted environment, never ...process.env
G3 deny-by-default network gate; known app-level calls (GitHub update
check) are answered by local stubs; any other blocked request fails
the run — a silently-blocked TMDB call would leave a frame that looks
broken rather than unsafe
G4 every frame is scanned before capture: external img/background URLs,
credential-shaped text, MAC addresses, non-localhost m3u8 references
G5 TMDB enrichment asserted disabled via the renderer's IndexedDB
Any violation deletes every frame captured in the run and exits non-zero.
The guards paid for themselves on the first live run: G3 caught the mock
server redirecting stream endpoints to a public demo HLS
(test-streams.mux.dev) — meaning earlier hand-run captures could embed
third-party video frames. The M3U shot now deliberately captures the groups
layout without starting playback.
`.changes` validation now cross-checks `screenshot:` slugs against the
manifest, so a note cannot reference an image the capture run never
produces.
Verified end-to-end: 10/10 shots (5 slugs × dark/light) captured against
dist build + xtream-mock-server, frames visually inspected (fictional
titles/artwork only), guard-violation paths exercised live. 67 unit tests
in release-tools, lint green, script files within the repo size limit.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* ci(release): gate PRs on an authored release note
Second slice of the release-notes pipeline (#1256 landed the format and
generator): make the .changes/ habit survive contact with reality.
- "Release note gate" job in ci.yml, PR-only: validates every .changes/*.md,
then requires an added note (or the no-release-note label) when the PR
touches runtime code under apps/ or libs/. Tests, e2e projects, the
website, mock servers, shared testing helpers, snapshots and docs are
auto-exempt.
- Policy lives in tools/release/check-release-note-gate.mjs as a pure
function fed PR files+labels as JSON — unit-tested (10 cases) instead of
encoded in workflow bash. The failure message lists the triggering files
and names the exact fix.
- Labels are fetched live rather than from the stale event payload, so
applying the label and re-running the check works without a new push.
- The job is dependency-free Node: no pnpm install, runs in seconds.
- release-notes and release-cut skills added under .claude/skills/ and
mirrored to .codex/skills/; CLAUDE.md/AGENTS.md sections updated to point
at the gate and the skills.
The no-release-note label itself was created in the repository.
Tests: 47 passing in release-tools (10 new gate cases); gate-step shell
verified with shellcheck at the CI severity; ci.yml YAML-parse checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(agents): make the release skills discoverable by Claude Code too
`.codex/skills/**` was un-ignored so Codex picks up repository skills in any
clone, but `.claude` was ignored wholesale — and Claude Code only discovers
skills under `.claude/skills/`. The release-notes and release-cut skills
therefore existed only on whichever machine authored them.
Mirror both skills into `.claude/skills/` and opt them in by name rather than
un-ignoring the directory: contributors keep personal skills there
(i18n-fill, website, …) which must stay local and out of `git status`.
CLAUDE.md/AGENTS.md updated so the "skills live under .codex/skills/" claim
does not go stale, including the requirement to keep mirrored copies in sync.
The CI gate and the CLAUDE.md/AGENTS.md section remain the load-bearing
enforcement; skills only carry the detail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(ci): only a note this PR authored satisfies the release-note gate
Review follow-ups on #1257 (Codex P2 ×2, Greptile P1).
- Drop `renamed` from the accepted statuses. The PR files API compares
base…head, so a note created and then renamed inside the same PR still
reports as `added`; a `renamed` entry means the file already existed on the
base branch. Accepting it let a runtime-code PR pass by moving another
PR's unconsumed note, which documents nothing and gives the generator no
adding commit to resolve a PR link from.
- Require a direct child of `.changes/`. `loadNotes()` reads only the
immediate directory, so `.changes/sub/note.md` satisfied the old prefix
check while never being validated or rendered into any release surface.
Tests: renamed and nested notes now assert a failing gate (12 gate cases).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>