* fix(pwa): bring the Stalker transport to parity with Electron
The self-hosted PWA's /stalker proxy now derives its portal requests from
the same shared identity and URL builders as the Electron main process:
MAG User-Agent/X-User-Agent, full STB cookie (mac + stb_lang + timezone +
serial-derived __cfduid), SN header, JsHttpRequest=1-xml defaulting, and
the sn-only-on-get_profile rule. macAddress/token/serialNumber are control
params consumed into headers and never echoed into the portal's query
string (handshake keeps its candidate token — protocol content). The
stalker-mock-server /stalker route mirrors the new contract through the
same shared builder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): forward the full identity header set in the mock /stalker mirror
Greptile review: the synthetic portal request kept only the cookie and
Authorization from the generated identity, so mock handlers could never
validate the SN/MAG-UA/Accept/Language/Connection headers the real proxy
sends. Forward the complete set, lowercased the way Express normalizes
incoming headers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dashboard): warn on source cards when a portal subscription expires soon
Dashboard source cards now carry a passive expiry chip: amber "Expires in
N d" within 7 days of the subscription lapsing, error-toned "Expired" once
it has. Account details stay behind the card's ⋮ → Account info.
Xtream expirations ride on the playlist switcher's cached PortalStatusService
check — checkPortalStatusDetails() now surfaces the parsed exp_date from the
same round-trip, so the dashboard adds no extra portal calls. Stalker
expirations come from the stalkerAccountInfo snapshot persisted at import;
it lives in the playlist payload (meta rows carry payload: null), so each
Stalker source costs one full-playlist read memoized on the playlist's
update timestamp.
New i18n keys added to all 19 locales via the i18n-fill merger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address review feedback on expiry badges
- Recompute expiry badges on a minute tick so a dashboard left open
crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2)
- Gate the expiry refresh on the recent-sources rail setting so hidden
rails cost no portal checks or playlist reads (Codex P2)
- Move chip colors to theme-aware tokens in m3-theme.scss; both themes
now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired
5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): make expiry-badge labels depend on the language signal
sourceCards previously relied on getPlaylistProvider's indirect language
read; the translate.instant() labels now read languageTick explicitly
(mirroring trendingCards). Also shift the minute tick by one so the
interval's first 0 differs from initialValue — the signal equality check
was swallowing the first heartbeat, delaying it to two minutes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(workspace): report a local phase while reading the cached Xtream catalog
Since #1311 the sync overlay is shown for the whole import session, but the
DB-first read path never emitted an import phase, so switching to an
already-imported Xtream playlist showed a bare "Syncing playlist" card with
no badge or description. The Electron data source now reports a
'loading-cached' phase (local-library badge, its own label and detail text)
before reading categories/content from SQLite, and the PWA data source
reports the remote loading phases on API fetches it previously swallowed.
Adds the two new i18n keys to en.json and all 18 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): keep the loading-cached phase from marking a real import
The store's onPhaseChange callbacks set isImporting unconditionally, and the
initialization error path gates import-cache cleanup on that flag — so a
cancelled or failed warm SQLite read would have wiped the healthy cached
catalog and forced a full provider redownload. The shared publishImportPhase
helper now publishes 'loading-cached' as a presentation-only phase; any
remote/save phase still marks the import as running. Adds regression specs
(verified to fail against the previous behavior) in a dedicated spec file to
stay under the test max-lines limit.
Addresses Codex P1 review feedback on #1345.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(portals): scope cancelled-import cleanup to types with remote work
A session-wide isImporting flag meant that once any content type contacted
the provider, cancelling during a later cache-only read cleared the healthy
cached catalogs of every not-yet-completed type. Cleanup now consults a
per-session set of types that actually performed remote or save work
(populated from typed phase callbacks and save-content events), so
cache-only types keep their catalogs on cancellation while genuinely
partial types are still cleared. Mixed-scenario regression spec added
(mutation-verified against the unguarded behavior).
Addresses the second Codex P1 on #1345.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(playback): forward portal Cookie/Authorization to built-in players
The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).
- request-header-overrides.service: the scoped override now carries Cookie
and Authorization, attached only to requests on the exact stream origin,
in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
drop credentials fail-closed; control characters in header values are
rejected. Chosen over session.cookies.set(): jar cookies attach only to
credentialed requests, which would force withCredentials into every engine
and break against the Access-Control-Allow-Origin:* IPTV panels send, and
jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
every built-in player: it extracts the full header set from the resolved
playback, configures the override BEFORE handing the source over (players
render only once the source exists), and clears the scoped layer on
destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
set ITV already had (they previously carried no portal headers at all);
same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
(isStalkerStreamCredentialSafe): same-host port changes and scheme
upgrades keep the portal profile (the #1158 class), a foreign host or
https->http downgrade keeps the credential-free KSPlayer profile. The
main-process fallback context uses the same predicate so
isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
create_link returns a local /stream/gated/video.mp4 that 403s without the
mac cookie + current Bearer token; new Electron e2e proves a built-in
player actually plays it (and that the gate refuses bare requests).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): apply header override to Stalker radio, redact mock cookie log
Address Codex review feedback on #1335:
- The radio branch of the Stalker live layout renders the dedicated audio
player, never WebPlayerViewComponent, so the resolved portal headers were
built but never applied — an auth-gated radio stream still 403'd. The
override sync is extracted into ElectronStreamHeadersService (single owner
of the scoped override slot, with clear-only-while-owning semantics so a
destroyed consumer cannot wipe a newer consumer's override), applied by
WebPlayerViewComponent for video players and by the radio branch before
the audio element gets its URL. The service feature-detects the bridge
method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
the Electron e2e covers the radio path end-to-end (bare request 403s,
built-in audio player advances past the gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): claim radio header ownership before awaiting the IPC
Codex round-2 P2: leaving the radio route while the header IPC was still in
flight left the portal cookie/token installed — ngOnDestroy saw a null scope
URL (it was recorded only after the await) and could not clear the override.
Ownership is now claimed synchronously before awaiting, destroy invalidates
the pending playback continuation, and the apply's stillCurrent verdict is
honored. Regression test covers destroy-during-pending-IPC.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): carry portal headers into collection playback
Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed
resolved through StreamResolverService.resolveStalker(), which returned no
portal headers — the video path handed the header owner an empty set and
collection radio bypassed it entirely, so auth-gated streams still 403'd
from collections.
- resolveStalker() now builds the same profile as the live layout via the
shared classifier: portal-owned streams get mac cookie/Bearer token/MAG
UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer
profile (both create_link results and direct radio URLs).
- UnifiedLiveTabComponent applies the scoped override for radio before the
audio element gets its URL (ownership claimed before awaiting the IPC,
round-2 lesson), and clears it on close and destroy.
- Regression tests: resolver header profiles for portal-host and foreign
streams; unified tab radio apply-then-clear.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): release the radio override when a new selection mounts no player
Codex round-4 P2: after radio installed its credentials, selecting an item
that never mounts a player surface (external video playback, failed
resolution) left the old Cookie/Authorization installed — no
WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both
radio hosts (unified collection tab and the Stalker live layout, which has
the identical hole) now release the previously owned radio scope at the
start of every new selection; the slot-ownership semantics keep this a
no-op when another playback already owns the override. Regression test in
the live-layout spec pins the failed-selection path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): state the exact override release points
Codex round-5 P2 flagged that the media 'ended' event does not clear the
scoped override while the player stays mounted. That is deliberate, not a
gap: a mounted player still owns the session — replay or a seek into an
unbuffered range must keep working against a gated stream, and clearing on
'ended' would 403 exactly the streams this PR fixes. The credentials only
ever travel to the exact origin that issued them, and every dismount path
(channel/source change, player close/destroy, radio close, playerless
selection) releases them. The security doc and the release note now say
precisely that instead of the ambiguous "cleared when playback ends".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* docs(playback): fit the release note back under the 400-character cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(stalker): add account info dialog for Stalker portals
Xtream playlists have had an account-info dialog for a while; Stalker
portals stored the same facts (login, expiry, tariff, status captured at
import) as dead weight in the database and showed them nowhere.
Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual
language: status pill, days-left/tariff/MAC hero stats, account and
portal panels. Data is cached-first — the import-time snapshot renders
instantly with a "Saved data" badge, then StalkerAccountInfoService
refreshes it: full /stalker_portal/ installations re-run
handshake+get_profile, portal.php panels are queried best-effort via
account_info/get_main_info. A failed refresh keeps the cached snapshot;
no data at all shows a retry-able error state.
Entry points are unified behind shared portal-account predicates
(isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces)
so both portal types get the same set: header playlist switcher (bottom
section + new per-row ⋮ Account info item), dashboard source card ⋮ menu,
and the command palette (now visible on stalker routes with its own
description). The header service picks the dialog by playlist type; the
per-row path works for non-active playlists and skips the session-scoped
stream counts.
Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog
already referenced (they rendered as raw keys), a get_main_info handler
in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all
19 locales.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): unwrap nested js.account_info envelope in get_main_info
Ministra-style portals nest the account block — fetchStalkerExpireDate()
in stalker-player-request.utils already consumes exactly that shape, so
the flat-only mapper silently discarded valid responses and legacy
imports (which have no cached snapshot) got an empty account panel.
Merge nested fields over flat aliases, send the JsHttpRequest parameter
the existing get_main_info caller sends, switch the mock server to the
nested envelope so the E2E covers the realistic shape, and document the
account-info feature in CLAUDE.md (review feedback from Greptile and
Codex on #1330).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(stalker): pin account-info expiry fixture below the day boundary
Math.round on the epoch could round up half a second, putting the
fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on
CI. Floor keeps the interval strictly inside 30 days regardless of when
within the second the spec runs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* refactor(stalker): address account-info review round two
Three P2s from Codex on #1330:
- Normalize the cached stalkerAccountInfo snapshot before rendering:
the import path persists portal values verbatim, so expireDate can be
a date string or milliseconds at runtime despite the declared number
type. normalizeStoredStalkerAccountInfo() runs the same parsers as
the fresh path.
- Publish the re-auth token into StalkerSessionService's cache: strict
portals invalidate the previous token per handshake, so the dialog's
authenticate() would otherwise strand an active portal session on a
dead token.
- Extract the duplicated ~460-line account-dialog stylesheet into
libs/ui/styles/_account-dialog.scss, shared by both dialogs with the
provider accent injected via --account-dialog-accent; each consumer
keeps only its accent and layout overrides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): serialize account-profile refresh with session auth
The dialog's direct authenticate() call bypassed the pendingAuth map
ensureToken() uses, so a refresh could run a second handshake while a
catalog or watchdog request was still authenticating. On strict portals
each handshake invalidates the other's token, and the later
setCachedToken() could publish an already-dead one.
Move the refresh into StalkerSessionService.refreshAccountProfile(): it
waits for any in-flight authentication, registers its own so later
callers wait for it, and republishes the resulting token. A failed
pending auth no longer aborts the refresh, and the pendingAuth entry is
only cleared when it is still this call's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): move pendingAuth cleanup out of the promise initializer
TS2454 under the Angular compiler: the finally block referenced
authPromise inside its own initializer, so every Electron/web production
build failed even though jest and lint accepted it. Await the promise at
the call site and retire the map entry there instead — same
only-clear-our-own-entry semantics.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): harden account-info portal detection and expiry math
Review round four (Codex P2s on #1330):
- Fall back to the URL rule when isFullStalkerPortal is undefined: a
playlist restored from an older backup carries no flag once the
one-shot metadata migration has run, and it would then be sent down
the unauthenticated legacy path and labelled a legacy panel.
- Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse
reads it as UTC midnight, which renders as the previous day west of
UTC and shifts the days-left boundary; timestamps carrying a time or
offset keep standard parsing.
- Decide expiry from the raw timestamp, not the rounded counter: an
expiry that passed less than a day ago ceil's to 0/-0, so the hero
stat claimed "0 days left" on a dead subscription.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): make account-profile refresh own the auth slot
Review round five (Codex P2s on #1330):
- Claim the pendingAuth slot in a loop and publish it before the first
await. One settled promise releases every waiter at once, so a single
pre-check let two queued refreshes both start handshakes that
invalidate each other on strict portals.
- Retire the cached token before the handshake: ensureToken() reads
tokenCache before pendingAuth, so catalog and watchdog requests
starting mid-handshake were handed a token this refresh was about to
kill instead of queueing on the slot.
- Render the portal type from the same resolver the fetch path uses, so
a restored backup without an explicit flag is no longer labelled a
legacy panel while authenticating as a full portal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): retire only the token that actually failed auth
A request dispatched with the previous token can see its authorization
failure arrive after a profile refresh has already cached a fresh one.
The retry path deleted the cache blindly, killing the fresh token and
kicking off another handshake that in turn invalidated tokens of newer
requests — cascading retries on strict portals.
makeAuthenticatedRequest() now retires the cached token only while it
still equals the token that failed; a late failure of a stale token
leaves the refreshed token in place and the retry reuses it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(stalker): distinguish the two no-data outcomes of the account dialog
A portal that answers but publishes no account facts renders the
ready-state "No account details" panel; only an unreachable portal
without a cached snapshot enters the error state with retry. The doc
conflated both as "error with retry" (review feedback on #1330).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): reject negative expiry sentinels before date parsing
Portals encode unlimited/missing expiry as "-1" or "0"; the
unsigned-digit check let "-1" fall through to Date.parse, which V8
reads as January 1, 2001 — an unlimited account rendered as expired.
Signed numeric strings now take the numeric branch, whose non-positive
guard already discards them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(stalker): reject out-of-range calendar components in expiry dates
The multi-argument Date constructor normalizes invalid components
('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown
from a placeholder. Round-trip the parsed year/month/day and reject any
date that does not survive unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The fixes from #1206 (native view misplaced on displays scaled above 100%)
and #1207 (video jump / black bar when opening control menus) were merged
before the .changes pipeline existed and the 0.23.0 backfill missed them.
Both are user-visible and referenced from issue #1139, so they get notes.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(ui): turn the phone context panel into an off-canvas drawer
On ≤640px viewports the workspace context panel (categories, filters,
settings sections, collection filters) no longer stacks above the route
content capped at 30vh — it is a hidden-by-default drawer that slides in
from the left over a backdrop, opened via a new header toggle
(phone-only, CSS-gated) and closed by selection, backdrop tap, Escape,
or any navigation.
State lives in the new WorkspaceShellContextDrawerService provided by
the shell component; panels close it explicitly after selections that
do not navigate (Stalker ITV/radio categories, settings sections,
sources filters, collection filters), since NavigationEnd alone cannot
cover those. Desktop behavior is untouched, including the
ResizableDirective inline width.
Closes the drawer follow-up deferred from #1100 / PR #1326.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): make the phone context drawer modal for keyboard users
Addresses Greptile P1 and Codex P2 review feedback on #1332:
- CdkTrapFocus on the sidebar captures focus into the drawer on open and
contains it while the drawer is modal; the shell restores focus to the
header toggle on close, since the closed drawer is visibility: hidden
and focus left inside it would silently drop to <body>.
- The drawer service closes the drawer when the viewport leaves the
phone breakpoint (matchMedia), so the trap can never hold the in-flow
desktop sidebar after a resize.
- The toggle's tooltip and aria-label are now variant-aware — categories
on portal routes, filters on sources/collection routes, settings
sections on the settings route — instead of a fixed 'Categories &
filters' that misdescribed two of the three; the two generic i18n keys
are replaced by six variant keys across all 19 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): remove background content from the a11y tree while the drawer is open
Round-2 review feedback on #1332 (Greptile P1, Codex P2):
- The rail, header, route content and playback footer are marked inert
while the phone drawer is open — CdkTrapFocus constrains Tab focus,
but a screen reader's virtual cursor could still reach and activate
the visually obscured controls behind the backdrop.
- The drawer panel itself is the trap's initial focus target
(tabindex=-1 + cdkFocusInitial), so focus capture still works when a
category list is loading, empty, or failed and renders no focusable
rows.
- Focus restore on close is deferred one tick: the toggle lives in the
inert header, and focus() on a still-inert element is silently
ignored.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): gate global shortcuts and Escape behind the open phone drawer
Round-3 review feedback on #1332 (Codex P2s):
- The shell consumes Escape while the drawer is open: downstream Escape
consumers (the portal detail shell's inline player close, the shared
controls shortcuts) check defaultPrevented, so one keypress no longer
closes both the drawer and the obscured playback surface.
- inert does not silence document-level keydown listeners, so players
opt out themselves while inside an inert region: ControlsShortcuts
gains an optional hostElement handler and ignores every shortcut
(including Escape) when that host has an inert ancestor, and the radio
audio player applies the same check to its volume/mute keys.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer
Round-4 review feedback on #1332 (Greptile P1, Codex P2s):
- The drawer carries its own phone-only close button: touch
screen-reader users have no hardware Escape and cannot reach the inert
header toggle or the aria-hidden backdrop, so the trapped surface must
offer dismissal itself — even when a category list is loading or
empty and renders no actionable entries.
- Ctrl/Cmd+F no longer opens global search while the drawer is modal;
the shortcut would have navigated and focused an input inside the
inert header.
- EmbeddedMpvShortcuts (native-view legacy dock) gains the same
hostElement/inert-ancestor guard as the shared controls shortcuts, so
the obscured player cannot react to Space/arrows/M/Escape behind the
drawer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer
Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326
stacked-panel behavior this PR replaces) and updates that spec to pin
the drawer contract instead: panel hidden by default with full-width
content, header toggle opens it over a backdrop, category selection and
backdrop tap close it. Verified locally on Chromium, Firefox and WebKit
(12/12). The spec's getByTestId calls needed plain [data-test-id=...]
locators — the web-e2e Playwright config never mapped testIdAttribute.
Also addresses Codex round-5 P2s:
- Focus restore now reports whether the toggle received focus; when a
drawer selection navigated to a route without a context panel (toggle
gone), focus falls back to the route content instead of dropping to
<body>.
- The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts
out while their host sits inside an inert region, matching the other
document-level listeners.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): suppress command palette and shortcuts dialog behind the open drawer
Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K
handler in WorkspaceShellFacade and the '?' help-key handler in
WorkspaceKeyboardShortcutsService still opened their dialogs while the
phone context drawer was modal, stacking a second focus-trapped surface
on top of it. Both now check the drawer service (injected optionally,
same shell-component providers) and stay quiet while it is open, like
the Ctrl/Cmd+F global-search gate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding
Addresses the two Codex round-7 P2s on #1332:
- WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util
and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R
global-recent shortcut can observe the modal drawer without pulling the
lazy shell chunk into the eager bundle. Cmd+R is now suppressed while
the drawer is open, like Cmd+F/Cmd+K/'?'.
- The shell registers the open drawer with a new
EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API:
the native-view video surface is composited outside DOM stacking and
would paint straight over the drawer regardless of z-index. The service
treats registered external modal surfaces exactly like open Material
dialogs.
- The service's recompute no longer reads overlayActive back before
setting it: signals already skip notification on equal values, and that
hidden read registered overlayActive as a dependency of any reactive
context calling into the service — the shell's acquire/release effect
looped forever on exactly that (caught by a live browser probe; the
unit suite mocked the service). The effect also wraps the acquire in
untracked() for caller-side hygiene.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): expose the phone drawer as a named modal dialog
Round-8 review feedback on #1332 (Codex P2s):
- While open, the drawer carries role=dialog, aria-modal=true, and a
variant-appropriate accessible name (categories / filters / settings
sections) — assistive technology now hears that a named modal surface
opened instead of an unnamed complementary landmark. Closed (and the
always-visible desktop sidebar) stays a plain landmark.
- The UI-guidelines drawer section no longer claims the drawer service
is component-provided; it is root-provided from workspace/shell/util
since the round-7 move, and the stale claim could have led a future
change to re-scope it and silently break the AppComponent shortcut
gate and the Embedded MPV overlay observer. Matching code comments
updated everywhere.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking
Greptile round-9 P1 + Codex round-9 P2 on #1332:
- The M3U video player's document-level digit-key channel switching and
Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as
every other routed-content key listener. A codebase sweep confirms
this closes the class: every document-level key listener on routed
content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or
opts out via closest('[inert]'); the guidelines now require the guard
for any new listener.
- The drawer moves from z-index 99/98 to 951/950: above the settings
action bar (100) and the root EPG/update panels (900/901), which
inert removes from interaction but not from paint order — below the
CDK overlay container (1000), since dialogs opened from inside the
drawer (Manage categories) must stack on top of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): send cmd in the reference MAG wire format
A real MAG sends cmd unencoded and the portal decodes its query exactly
once, so a cmd that already contains percent sequences (%3A tokens,
pre-encoded path segments) must pass through untouched. The previous
encodeURIComponent transport (2c032cd3c, 0.22) double-encoded such cmds
(%3A -> %253A): strict portals and reseller panels that compare cmd
literally, and stock create_link handlers matching the decoded value,
saw a different string than a real STB sends.
The new shared encodeStalkerCmdValue() reproduces the reference wire
bytes: % passes through verbatim, characters the WHATWG URL serializer
keeps raw in a query stay raw (so the bytes survive the axios/new URL
transport unchanged), and everything else is percent-encoded. That
preserves the 0.22 injection protection - &, # (and ; for PHP setups
with a ; argument separator) inside cmd cannot append or truncate query
parameters; they decode back to the original byte server-side.
Both transports now share the format: the Electron query builder is
extracted to buildStalkerRequestUrl() and the web-backend /stalker
proxy appends cmd to the portal URL itself instead of letting axios
turn slashes into %2F (the opposite divergence).
Also unifies the two divergent response-side cmd normalizers: the
cross-portal collection resolver now uses the Stalker store's
normalizeStalkerPlaybackCommand/resolveStalkerPlaybackUrl, so playing
from Favorites/global collections resolves relative (/media/...) and
query-only (?token=...) create_link replies against the portal base
instead of handing the player a bare relative path.
The mock portal's create_link response gains mock-only cmd_received/
query_keys_received diagnostics; a new Electron e2e pins the contract
end-to-end (single decode, injection blocked). Unit corpus tests cover
the encoder, the Electron builder, the web-backend proxy, and the
resolver.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(pwa): sanitize portal URL before appending stalker cmd
A registered portal URL carrying a fragment would swallow the appended
cmd (everything after # is never transmitted), and a trailing bare '?'
produced '??cmd='. Drop the hash and pick the separator from the
sanitized href before appending. Flagged by Greptile/Codex on #1334.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(pwa): make stalker cmd append visibly query-only for CodeQL
Rebuild the /stalker request URL through the URL object and concatenate
the encoded cmd strictly behind a literal '?', so static analysis can
see the tainted value never reaches host or path (js/request-forgery
alert on the previous separator ternary). Behavior unchanged; the
fragment/bare-'?' regression tests still pin the wire format.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PR #1326 fixed the workspace on phone-sized screens (issue #1100) with
SCSS-only changes and no automated coverage. This adds a mobile-layout
smoke spec asserting the invariants that regressed before: no horizontal
overflow on dashboard/Xtream/settings, rail links inside the 52px top
bar, the context panel stacking above full-width content on portal
routes, the settings section list ending above the Back footer, and the
640x360 landscape live route keeping the channel sidebar >= 72px with
the player container inside the viewport.
The Xtream tests import the portal at desktop width and then shrink the
viewport, so the persisted inline rail widths from ResizableDirective —
the exact #1100 regression scenario — are present when the phone rules
must win.
Run: pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The spec raced a fixed 160ms sleep against the service's internal
rAF + 120ms paint delay that runs before deleteXtreamPlaylistContent
is called. Under parallel jest load the sleep could win, asserting
before the mocked worker event was ever delivered. Await a deferred
resolved by the mock right after it fires onEvent instead, so the
assertion is causally ordered after the signal update.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): enforce portal auth in the mock and cover the full-portal flow
The mock server implemented neither get_profile nor get_events and
validated no auth at all, and the e2e suite imported the portal through
/portal.php — which the app classifies as a *simple* portal. The entire
authenticated branch (handshake, token, watchdog, re-auth) therefore had
zero coverage, right before a series of PRs that reworks exactly that.
Mock server:
- serve the canonical /stalker_portal/server/load.php endpoint, which
enforces the Bearer token and the Infomir MAC format like the real
middleware; /portal.php stays tolerant so the existing suite keeps
covering the simple-portal branch
- auth-store.ts models the parts of Stalker 4.9.35 a client can get
wrong: plain-text auth failures with HTTP 200, a handshake that is not
yet a session, idempotent token re-presentation, and permanent
device_id pinning (including the blank-after-pinned lockout)
- add get_profile (status 0/1/2, device conflict, block_msg) and the
get_events watchdog; profile advertises watchdog_timeout/timeslot
- new login-required scenario MAC and POST /invalidate-session so tests
can force a mid-session token loss
- the /stalker proxy route now forwards the token as a Bearer header and
wraps auth failures in the { payload } envelope, matching web-backend
Also moves extractMac into request-mac.ts: importing it from the
categories handler dragged the whole data generator into any consumer,
which broke unit tests on the workspace alias.
E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile
precedes content, that content requests carry the token while the
handshake does not, that the plain-text failure body is never rendered,
and that the client re-authenticates after the portal drops the session.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): address CodeQL findings in the new portal auth code
Two genuine defects in the code this PR added:
- readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on
"bearer" followed by a long run of spaces; require the token to start
with a non-space character instead
- the /stalker proxy route read query params as strings without
narrowing, so a repeated key (?url=a&url=b) arrives as an array and
String.prototype.includes silently changes meaning
The remaining three alerts (missing rate limiting x2, sensitive data in
a GET query) are web-service hygiene rules aimed at internet-facing
services. The mock servers bind to localhost, serve fabricated data,
ship in no artifact, and deliberately mirror the real backend proxy's
token-in-query contract; a rate limiter would break the E2E suite that
hammers them. Exclude only those two apps from analysis via a documented
CodeQL config; every shipped path keeps full coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): tighten portal-auth fidelity per review
Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid:
- adoptToken only accepts tokens the mock actually issued (or the
already-bound one). The stock server pins any presented Bearer —
handshake is stateless there — but a fixture that does the same
cannot catch a client with a broken token pipeline; documented as a
deliberate strictness divergence.
- /invalidate-session clears tokens but keeps pinned device identity:
losing a token never unpins device_id on a real portal, so changed
identity after re-auth must still hit the device-conflict branch.
- The login-required scenario gates on actual do_auth completion
instead of auth_second_step: the app sends auth_second_step=1 on its
very first get_profile, so the parameter check was trivially
bypassed and the status-2 flow never exercised. do_auth is now the
faithful boolean step (non-empty credentials -> {js:true}, recorded;
empty -> {js:false}).
- /server/load.php — the second URL shape isFullStalkerPortal
recognizes — is now served and enforced, directly and through the
/stalker proxy predicate, so full-portal tests cannot silently fall
into the tolerant branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): prove content actually reloads after re-authentication
Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong
reason" class): the re-auth test only polled for a fresh handshake and
a negative body-text assertion, both of which pass even if the original
content request is never replayed or stays unauthorized. Capture the
content token from the initial import, then assert a post-invalidation
CONTENT request goes out under a DIFFERENT token and that the ITV
categories actually render — the mock only answers content for an
adopted token, so this proves the new token round-tripped through
get_profile. Verified against a live mock that the token genuinely
rotates (old token -> "Authorization failed.", new token -> content).
Also documents the second Codex P2: the mock is deliberately strict on
/server/load.php (a real portal enforces auth there); the import dialog
vs session predicate divergence is a separate app bug the strict
endpoint will let a later PR cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): serialize the portal specs and bind mocks to loopback
Review follow-up on #1324 (Codex, 4xP2):
- Parallel-reset race: under the workspace `fullyParallel` preset the new
auth file ran concurrently with stalker.e2e.ts against one shared mock
process, and each `beforeEach` wiped global state (sessions, favorites)
mid-assertion in the other. Reproduced locally: both suites green in
isolation, two failures when run together. Merged the auth tests into
stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also
removes the pre-existing race between that file's own tests. 19/19
green across three consecutive runs.
- Watchdog was recorded but never asserted, so the suite would stay green
if the full-portal workflow stopped pinging or dropped its token —
`sendWatchdogPing` swallows failures. Now polls for an authenticated
`get_events`.
- Both mock servers listened on every interface (stalker: `listen(PORT)`
with no host; xtream: an explicit `0.0.0.0` default), which made the
CodeQL exclusion's "binds to localhost" rationale untrue. Both now
default to `127.0.0.1` with a `HOST` opt-in, and the config comment
states plainly what the directory-wide ignore trades away.
- Documented that the login-required scenario is HTTP-level only for now:
the client's `do_auth` path is dormant and sends empty credentials, so
the fixture is waiting on that client-side work rather than claiming
end-to-end coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): force a real auth failure before asserting it stays hidden
Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid:
- The "never surfaces the plain-text auth failure" test only performed a
successful import, so its negative body assertions were vacuous. It now
imports with a MAC outside the Infomir OUI: the strict endpoint answers
get_profile with a bare {status:1}, no token is ever adopted, and every
content request keeps returning "Authorization failed." Unlike an
invalidated session this cannot be repaired by the client retry, so the
failure is genuinely observed (asserted directly against the proxy) and
only then checked for not leaking into the UI.
- docs/architecture/xtream-mock-server.md still documented the wildcard
bind that 4b31f7167 replaced with a loopback default; it now states the
new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container.
- Removed a dangling "Known app-side gap: the" fragment left in the
stalker mock README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): scope /reset by MAC so parallel specs stop wiping each other
The re-authentication test passed locally but failed all three CI
attempts: no request carried a token, because self-hosted.e2e.ts issues
a GLOBAL `POST /reset` against the same mock from a parallel Playwright
worker, destroying the session mid-import. Running only stalker.e2e.ts
locally never triggered it.
Serializing within one file (4b31f7167) could not fix this — the
interference is between files. Mock state is per-MAC, so `/reset` now
accepts `?macAddress=` and clears only that MAC's data, favorites,
session and watchdog counters; the unscoped form is kept for callers
that own the whole server. Both spec files now reset only the MACs they
own, so no worker can disturb another.
Verified: a scoped reset of one MAC leaves another MAC's session intact
(and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together
23/23 green — the combination that reproduced the CI failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): scope the last global Stalker reset in sources-pwa helpers
Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed
resetPwaMockServers, which still wiped the whole Stalker fixture from a
third spec file. Scope it to the two MACs this suite owns.
The auth tests use dedicated MACs no sibling touches, so portal sessions
— the fragile state — can no longer be cleared by a parallel worker.
Content MACs still overlap between files, which is harmless: that data is
regenerated deterministically from the same seed.
Verified with the full interfering set running together:
stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): await the first authenticated content request
The re-auth test kept failing on CI (3/3 attempts) with an undefined
token while passing locally. My earlier diagnosis — a sibling spec's
global /reset — was wrong: the failure survived the scoped-reset fix.
Real cause is a race in the test itself. `addFullStalkerPortal` only
awaits the route change, so on a slower runner the first authenticated
content request has not been recorded yet when the token is read; the
sibling test that passes happens to await `.category-item` first. Poll
for a content request carrying a token before capturing it.
The scoped-reset work stands on its own merits (cross-file resets were
a real hazard), it just was not what broke this test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): drop serial mode, batch resets, cover the auth handlers
Review round on a44f8135f plus a stability regression I introduced.
Codex, both valid:
- The proxy route stripped `token` from the forwarded query, so
`handshake` never saw a presented token and the idempotent-handshake
behaviour I documented was unreachable through the PWA path. The real
backend forwards every param except `targetId` *and* sets the header;
match it. Verified through the proxy: re-handshake now returns the
same token with not_valid 0.
- The login-required scenario had no committed test, so the README claim
was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth ->
profile, MAC-format rejection, device conflict, idempotent handshake,
watchdog). Handlers are called directly because the dispatcher pulls in
the faker-based generator, which this project's Jest cannot transform.
- Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of
sharing the Stalker suite's, so no reset can reach another suite's
state at all.
Stability: a baseline run of master passed 23/23 first try while this
branch failed a different test each run, so the flakiness was mine.
`mode: 'serial'` was a stand-in for isolation that per-MAC scoping now
provides properly, and it amplified every flake by aborting the rest of
the file; removed. `beforeEach` also fired seven sequential resets — the
endpoint now accepts repeated `macAddress` params so a suite clears all
of its MACs in one request. Added a retrying POST helper after an
ECONNRESET on a control call.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each; 28 mock unit tests; lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): restore serial mode for the shared-scenario file
Review follow-up (Codex P2), valid: the previous commit removed
`mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS,
so under fullyParallel one test in this file could clear another's data
or session mid-run.
Of the two suggested fixes, serialize rather than give each test its own
MAC: the tests here are written against scenario fixtures (default,
minimal, embedded-series) whose shapes the assertions encode, so a MAC
per test would mean inventing a scenario per test and rewriting
pre-existing assertions. Cross-file isolation stays with the disjoint
sibling MAC range, which is what serial was wrongly standing in for
before.
The header now states both levels explicitly so the next reader does not
undo one of them.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The context-actions section of the playlist switcher lost its "Playlist
info" button when playlist actions moved into the per-row menu
(156c12c51): the showPlaylistInfo input, the playlistInfoRequested
output and the whole shell wiring stayed alive, but no template rendered
the entry anymore — the active playlist's info dialog was only reachable
by locating its own row in the list.
Render the button again, gated on the existing showPlaylistInfo input,
alongside Account info and Add playlist. Regression test asserts all
three context actions render in the opened menu and that clicking
Playlist info emits.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix(ui): make the workspace usable on phone-sized screens
The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).
Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.
Found while walking the rest of the UI at 375px and 768px:
- The detail hero kept poster and details side by side, squeezing the
action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
other up to tablet width, because the paginator does not shrink and the
meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.
Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.
Closes#1100
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — keep the palette reachable and the video visible
Two findings from the Codex review on #1326.
Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.
The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the channel list keep its height on a landscape phone
Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.
The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.
Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — settings nav on landscape, poster dead space
Two more findings from the Codex review.
The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.
The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the playlist switcher yield to the search field on narrow phones
Codex review of d6133da9: on a 320px header a route that contributes its
shortcut button left the search field less than its own chrome needs (~74px
of icon, palette trigger, gaps and padding), so the field's contents spilled
onto the buttons beside it.
The switcher is the one header region whose content can ellipsize, so it is
what shrinks — down to an 88px floor — while the field states its chrome as
a minimum. The field's basis moves from auto to zero so the input's intrinsic
size stops counting as content: with basis auto the field claimed its
intrinsic width even when room was ample and squeezed the switcher to ~115px
on a 375px screen that could fit all 140.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): fit the switcher's own chrome inside its phone floor
Follow-up to the Codex note that the trigger's fixed chrome (type icon,
refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows
the switcher to shrink to. The flagged scenario itself cannot occur — the
Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron
enforces a 900px minimum window width so it never sees the phone breakpoint —
but the floor should hold on its own terms rather than by accident of which
buttons happen to render. Dropping the decorative type icon on phones brings
the fixed chrome under the floor, and the name gets the space instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): keep session headers on same-host redirects
Since 0.22 requestWithValidatedRedirects stripped Cookie/Authorization
whenever a redirect changed the *origin*, so a portal answering with an
http->https upgrade or a port move lost the MAC cookie and Bearer token
mid-session. Real Stalker/Ministra servers then reply with a plain-text
"Authorization failed." body: categories fail to load, create_link never
resolves, and no player receives a stream URL (#1158 regression window).
Scope credential stripping to the host instead: same-host scheme/port
redirects keep headers, basic auth, params, and request bodies; a
redirect to a different host still drops all of them, preserving the
original hardening intent (no credential leaks to third-party hosts).
Also adds the Stalker API compatibility roadmap produced by the
2026-08-01 protocol audit (.plans/, force-added like earlier plans).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(electron): strip credentials on same-host https-to-http downgrades
Review follow-up (Greptile P1 + Codex on #1322): the host-only check
kept Authorization/Cookie/basic auth/params/body when an https request
was redirected to http on the same host, replaying a TLS-obtained
session in cleartext. Treat that downgrade like a host change: strip
credentials and refuse to replay request bodies. Scheme upgrades and
port moves on the same host keep headers — the actual #1158 scenarios.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(playback): escape commas in mpv header fields on the TS paths
PR #1321 fixed the comma truncation of --http-header-fields inside the
native embedded-mpv addon, but the same OPT_STRINGLIST parsing bites
three TypeScript call sites that join header fields with ',':
- external MPV CLI launch (--http-header-fields=...)
- external MPV instance reuse (set_property http-header-fields)
- embedded MPV frame-copy loadfile options (opt.http-header-fields);
the helper's %len% quoting protects only the option-list level, mpv
still stringlist-parses the value afterwards
The Stalker MAG user agent contains "(KHTML, like Gecko)", so strict
portals received a truncated X-User-Agent and rejected live streams
with HTTP 400 (#910). Escape backslashes and commas per field with the
same scheme as the native fix, via a shared util.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(playback): cover the reused-instance IPC header escaping
Review follow-up (Greptile on #1323): the regression coverage only
exercised the CLI spawn path. Capture the JSON IPC traffic of a second,
reused mpv launch and assert the escaped MAG user agent survives the
set_property http-header-fields transport, so later serialization
changes cannot silently reintroduce truncated headers. net is mocked
with a passthrough default because the VLC specs bind a real ephemeral
port via createServer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.