* feat(stalker): endpoint probing + behavior-based portal mode with lazy repair
Replace the URL-shape guess behind isFullStalkerPortal with real endpoint
discovery: at import, probe portal.php -> server/load.php ->
stalker_portal/server/load.php (the pasted .php endpoint first) and
classify the portal by observed behavior — a token-less itv/get_genres
answering data proves a token-free panel, the middleware's plain-text
auth failure proves the endpoint enforces the token, confirmed by the
real handshake + get_profile. The proven endpoint and mode are persisted.
The three diverging portal-mode predicates (import, session service,
legacy migration) collapse into one shared helper in
@iptvnator/shared/interfaces; executeStalkerRequest becomes the single
request choke point (search and the collection stream resolver fold in),
and the production-dead makeStalkerRequest copy is removed.
Existing misclassified playlists repair themselves lazily: only after a
request actually fails with the plain-text auth bodies, HTTP 404, or a
terminal handshake error, at most once per playlist per session, and only
a configuration discovery proved to answer is persisted — via a minimal
portalUrl/isFullStalkerPortal patch, so favorites, recents and playback
positions survive. Working reseller panels are never probed or rewritten;
there is deliberately no eager one-shot migration, because tolerant
portal.php panels cannot be told apart from misclassified canonical
portals without probing.
The Electron handler now embeds the HTTP status code in the error message
(ipcRenderer.invoke strips custom properties from rejections), and probe
requests carry silent:true so expected 404s do not toast error snackbars.
The stalker mock gains a portal.php-less /ministra host so e2e can prove
the 404 fallthrough end to end.
Fixes#850, #686, #755.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): sync watchdog, PWA proxy errors and cmd resolution with lazy repair
Review round 1 (Greptile P1, Codex P1/P2):
- A successful repair now re-syncs the ACTIVE watchdog playlist via the new
StalkerSessionService.refreshActiveWatchdogPlaylist(): a simple-to-full
repair starts the required keepalive mid-session, full-to-simple stops it,
and an endpoint change repoints the pings instead of leaving them on the
activation-time snapshot.
- PwaService.forwardStalkerRequest surfaces the web-backend proxy's
normalized { message, status } no-payload envelope as an HTTP error
carrying the status, so endpoint discovery and the lazy repair can
classify upstream 404s in the PWA too (previously payload unwrapping
returned undefined and dead endpoints were unrepairable there). Probe
requests pass silent:true and skip the error snackbar.
- fetchStalkerPlaybackLink and the collection StreamResolverService re-apply
the repair override AFTER the request, so a relative create_link reply
resolves against the endpoint that actually answered (the resolver keeps
the /stalker_portal path segment as base, so this matters beyond origin).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): parse candidate URLs and tie repair overrides to their source config
Review round 2 (Codex P2 x2):
- Endpoint candidates are now derived from the parsed origin + pathname:
a pasted URL carrying a query or fragment (host/c?key=value) no longer
gets /portal.php bolted onto the query, which made every probe hit /c
and persisted the non-API URL.
- A repair override is tied to the failing configuration it replaced.
Playlists carrying anything else (the user edited the portal URL or mode
through the playlist dialog) drop the override and re-arm the
once-per-session probe latch, so edited metadata is used verbatim and
may repair again if it fails.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): auth-gated probes, normalized offline fallback, mock docs sync
Review round 3 (Codex P1 x2, P2):
- A probe answered with HTTP 401/403 now classifies the endpoint as
auth-required and attempts the real handshake instead of skipping the
candidate: non-standard middlewares answer 401 where the stock server
sends HTTP 200 + plain text, and such portals authenticated fine before
discovery existed.
- The unreachable-host import fallback normalizes the pasted URL (origin +
pathname) before the legacy /c -> portal.php rewrite, so a query or
fragment can no longer make it persist the browser page URL - a 200 HTML
answer from /c is not a repair trigger, which would have left the
playlist empty for good.
- The stalker mock-server README and architecture doc now describe
behavior-based discovery and the /ministra host instead of the retired
URL-shape rule and its "known inconsistency" note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): recognize JSON auth failures and guard repairs against mid-probe edits
Review round 4 (Codex P1 + P2):
- isStalkerAuthFailureResponse() recognizes the JSON envelope some panels
answer instead of the plain-text body ({js:{error:"Authorization
failed"}} / {js:{msg:...}}). Probe classification treats it as
auth-required instead of token-free data, and the lazy-repair trigger
fires on it at runtime — previously such a portal was persisted simple
with no repair path at all.
- A repair is committed only after re-reading the persisted row and
verifying it still carries the configuration that failed: a user who
edits the portal URL (or deletes the playlist) during the multi-second
probe now wins over the in-flight repair result for the old URL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): probe past endpoint 5xx, sibling fallbacks, identity-aware repair guard
Review round 5 (Codex P2 x3):
- A probe that fails with a RESOLVABLE HTTP status keeps discovery going:
a broken /portal.php handler answering 500 must not hide a healthy
sibling endpoint. Only status-less failures (true network level) stop
the loop. The Electron handler now gives real HTTP 5xx responses the
same parseable "HTTP Error <code>" message shape as 4xx, so the
renderer can tell them apart from ECONNREFUSED/timeouts after
ipcRenderer strips the object shape.
- Standard fallback candidates for a nonstandard pasted endpoint
(.../cp/api.php) derive from its DIRECTORY, so recovery probes hit
/cp/portal.php instead of /cp/api.php/portal.php.
- The repair's row re-verification also compares the MAC and all Stalker
identity fields: a probe authenticated as the old identity must not
install its token/watchdog or persist onto a row whose credentials were
edited mid-probe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): reactivation-safe watchdog, wider JSON auth phrases, per-config probe latch
Review round 6 (Greptile 4/5 concern + Codex P1/P2):
- setCurrentPlaylist applies the repair override before feeding the
watchdog and store state: re-activating the portal route with the stale
NgRx meta no longer stops or repoints the repaired keepalive back to
the broken configuration.
- The structured js.error/js.msg fields accept the full phrase set the
session service recognizes (Invalid token, Auth failed, bare
unauthorized/authorization) — panels answering those envelopes were
still classified token-free. Plain-text body matching stays narrow on
purpose (HTML false positives).
- The once-per-session probe latch is keyed by the SOURCE configuration
fingerprint (endpoint, mode, MAC, identity) instead of the playlist id:
a repair discarded because of a mid-probe edit no longer blocks the
edited configuration from repairing, while stale snapshots of an
already-probed configuration still cannot loop the probe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): identity-aware override invalidation and timeout-tolerant probing
Review round 7 (Greptile P1 + Codex P2):
- The repair override records the identity fingerprint the probe
authenticated as. Editing the MAC or any Stalker identity field
afterwards drops the override, the per-config probe latch AND the cached
token, so requests and watchdog pings never pair the edited identity
with a session negotiated for the previous one.
- A status-less probe failure that is a TIMEOUT (renderer budget, axios
request timeout, ETIMEDOUT) continues to the next candidate — one
hanging handler must not hide healthy siblings; connection-level
failures (refused, unresolvable host) still stop discovery, so dead
hosts keep failing fast.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): watchdog pings authenticate as the persisted row
Review round 8 (Greptile 4/5 concern):
The watchdog held its activation-time playlist snapshot for the whole
session, so portal metadata edited (or repaired) mid-session kept the
keepalive authenticating as the previous identity/endpoint — its pings
could keep the old session alive and repopulate the playlist-scoped token
cache with a token for the pre-edit identity.
Each ping now resolves the playlist from the persisted row first (the
single source of truth), falling back to the snapshot only when the store
cannot be read, and refreshes the snapshot on every successful read. Any
edit — identity, endpoint or mode — reaches the keepalive within one ping
cycle; a row now marked simple (or deleted) stops the watchdog. The
in-flight guard is claimed before the row read so overlapping pings
cannot double-fire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): identity-tagged tokens, watchdog override overlay, retire-on-failure
Review round 9 (Greptile 4/5 concern + Codex P2):
- The session token cache is tagged with the identity fingerprint (MAC +
all Stalker identity fields) the session was negotiated for; ensureToken
re-authenticates instead of handing an edited identity the previous
token. The fingerprint helper is shared (stalker-identity.utils) with
the repair layer's override/latch checks.
- Watchdog pings overlay the repair layer's in-session override on the
resolved row (registered decorator, no import cycle): a simple-to-full
repair whose persistence is pending or failed no longer reads the stale
row and stops the freshly started keepalive.
- makeAuthenticatedRequest retires a failed token even on the no-retry
path (watchdog pings), so a dead session is never handed to the next
caller.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): pending authentications are identity-scoped
Review round 10 (Greptile 4/5 concern):
pendingAuth entries carry the identity fingerprint they authenticate as.
A request for an edited identity no longer adopts an in-flight result
negotiated for the previous identity: it waits the old authentication out
(a competing handshake would strand it with a dead token on strict
portals) and then negotiates its own session. This was the last
id-only-keyed session structure — override, probe latch, token cache,
watchdog snapshot and pending auth are now all identity-aware.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): atomic repair persistence, full probe history, normalized offline classify
Review round 11 (Codex P2 x3 + P1 docs):
- The repair's row verification and patch now run ATOMICALLY inside the
per-playlist write queue via the new
PlaylistsService.transformPlaylistMeta(): a user edit that is queued but
not yet committed wins over the repair — the transform sees the edited
row and aborts instead of overwriting it. Write failures after a
successful verification keep the session-only override, read failures
discard the repair.
- The per-playlist probe latch keeps EVERY attempted source fingerprint,
so alternating edits (A -> B -> A) cannot evict a fingerprint and let
stale snapshots re-run discovery.
- The unreachable-host import fallback classifies the normalized
origin+pathname, so a query merely mentioning /server/load.php cannot
make a panel URL look canonical and abort the offline import.
- docs/architecture/stalker-portal.md documents the actual probe
sequencing: any resolvable HTTP status (incl. 5xx) and timeouts continue,
401/403 classify as auth-required, only connection-level failures abort.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): collision-proof session fingerprints
Review round 12 (Greptile P1): identity values are unrestricted strings,
so the delimiter-joined fingerprint could alias distinct identity tuples
(serial "a|b" + empty device vs serial "a" + device "b") and bypass the
identity invalidation. Both the identity fingerprint and the repair
source fingerprint are JSON-encoded now; regression test pins the exact
aliasing pair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): preserve URL authority in normalization; document per-config latch
Review round 13 (Codex P1 docs + P2):
- normalizeStalkerPortalInputUrl mutates the parsed URL (clear query/
fragment, trim pathname) instead of rebuilding from origin, and the
candidate builder swaps only the path — file: URLs (origin "null") no
longer make the builder throw, and basic-auth credentials are not
silently dropped before probing.
- The canonical docs and the repair service JSDoc now describe the actual
loop guard: at most one probe per SOURCE CONFIGURATION (endpoint, mode,
MAC, identity) per playlist per session, not once per playlist.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): HTTP 401/403 failures trigger the lazy repair
Review round 14 (Codex P1): discovery classifies 401/403 endpoints as
auth-required, but the repair trigger accepted only 404 — a legacy
playlist misclassified token-free against an HTTP-auth-gated middleware
could never reach discovery and stayed unusable. 401/403 now qualify;
endpoint-specific 5xx still do not.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): re-enter repair for edited configurations after a pending probe
Review round 15 (Codex P2): a request carrying an edited configuration
that raced an in-flight probe only awaited it and inherited its outcome —
the edited fingerprint stayed unattempted and the first request failed
without triggering its own discovery. repairPortal now re-enters after
awaiting the pending probe, so the per-config latch decides: already
attempted -> reapply, never attempted -> own probe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): probe history remembers outcomes so restored configs repair again
Review round 16 (Greptile P1): the per-config latch kept A's fingerprint
after an edit to B dropped A's override, so restoring A left it latched
with nothing to reapply — broken until restart. The history now stores
each probe's OUTCOME (override or null): a restored configuration
reinstalls its remembered repair without a second discovery, and the
anti-ping-pong property (A<->B alternation never re-runs discovery from
stale snapshots) is preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(playlist): serialize deletion behind the per-playlist write queue
Review round 17 (Codex P2): deletePlaylist bypassed
serializePlaylistWrite, so a queued mutation (e.g. the Stalker portal
repair's conditional transform) finishing after an unserialized delete
could upsert the row back and resurrect the playlist. Deletion now runs
through the same queue: queued writes commit first, the delete lands
last, and a transform enqueued after the delete reads a missing row and
aborts. Regression test pins the write-then-delete ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): reinstalled repairs re-sync the watchdog like fresh ones
Review round 18 (Greptile P1): the restored-configuration branch
reinstalled the remembered override without the watchdog refresh the
fresh-repair path performs — if the intermediate edit stopped the
keepalive, the restored full-portal session recovered requests but never
its pings. The reinstall now calls refreshActiveWatchdogPlaylist with the
override applied, symmetric with a fresh repair.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): discarded probes retry once their configuration is restored
Review round 19 (Greptile P1): the pre-probe history reservation survived
the row-mismatch discard, so restoring the original configuration hit the
latch with nothing to reinstall — lazy repair stayed disabled for the
session. Probe records are now explicit (override / no-change /
discarded): a discarded configuration probes again once one cheap row
read confirms the row was RESTORED to it, while stale snapshots of it
stay declined without a discovery run.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): IPC-safe transport errors, repairable profile path, nested base paths
Review round 20 (Codex P2 x4):
- The Electron handler throws a real Error for axios failures without a
response: Electron serializes rejections via toString(), so a plain
object arrived as "[object Object]" and discovery could not tell a
timeout (keep probing) from a dead host (stop).
- isAuthorizationError parses HTTP 401/403 out of the IPC-wrapped message,
so an expired-token 403 retires the token and re-authenticates instead
of surfacing as a plain failure.
- The account-info full-profile path (which bypasses
executeStalkerRequest) routes repair-trigger failures through
StalkerPortalRepairService and retries with the repaired playlist, so
opening the dialog can fix a stale endpoint.
- resolveStalkerPlaybackUrl derives the installation base from the
endpoint's API suffix instead of a fixed stalker_portal|c|portal
allowlist: relative create_link replies now resolve correctly under
arbitrary discovered installations such as /cp/server/load.php.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): strict probe data shape, mode-aware profile retry, docs API name
Review round 21 (Codex P1 docs + P2 x2):
- Probe classification requires the real get_genres shape (array, or a
{data: []} envelope without an error) instead of a bare `js` key: a 200
error envelope ({js:{error:"Unknown action"}}, {js:false}) no longer
ends discovery on a broken candidate and persists an empty catalog.
- After a repair that flips the portal to simple mode, the account-info
retry re-enters the mode routing and uses get_main_info instead of
handshaking against a token-free panel again.
- docs/architecture/stalker-portal.md names transformPlaylistMeta and its
atomic source-check invariant (plus the serialized deletion) rather than
the race-prone updatePlaylistMeta.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): account dialog re-routes after a simple-to-full repair
Review round 22 (Codex P2): fetchViaMainInfo runs through
executeStalkerRequest, whose lazy repair retries the SAME action, so a
repair proving the portal is actually full left the dialog calling
get_main_info — canonical installations publish subscription details only
through handshake + get_profile, leaving the dialog empty. The routing is
now symmetric with the full-to-simple case: an empty main-info result
whose repair flipped the mode re-enters the profile flow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): row-gate override reinstall; document mode-based account routing
Review round 23 (Codex P2 + P1 docs):
- Reinstalling a remembered override now requires the persisted row to
actually carry that configuration again. A stale request for A while the
row holds an unrelated C no longer resurrects A's override, which would
retry against B and repoint the active watchdog away from C. (The
edit-back-to-A case stays as documented: there the row IS A.)
- docs/architecture/stalker-portal.md and CLAUDE.md describe account-info
routing by the observed portal MODE instead of the endpoint shape — a
token-enforcing portal.php is a full portal now — and note the
mode-change re-routing in both directions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): share the auth-failure predicate; prefer profile over partial main-info
Review round 24 (Codex P1 + P2):
- isAuthorizationError now reuses isStalkerAuthFailureResponse, so the
phrases discovery and the lazy repair already classify as auth failures
(Access denied., Unauthorized request., and their JSON envelopes) also
retire the session token. Previously a full portal expiring with either
phrase kept its dead token: the repair rediscovered the same
endpoint/mode, recorded no-change, and every later request stayed broken.
- After a simple-to-full repair, even a PARTIAL get_main_info answer no
longer wins over the profile flow — expiry and tariff live only behind
handshake + get_profile. The partial facts are kept only if the profile
path itself publishes nothing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): keep a literal c installation directory in candidate derivation
Review round 25 (Codex P2): the /c landing-page rewrite ran after the
endpoint file was stripped, so `/tenant/c/portal.php` collapsed to
`/tenant` and the sibling probes went one level too high, rejecting a
valid portal whose installation directory is literally named `c`. The
rewrite now applies only when the pathname itself ends in `/c` (no
endpoint file); pasted endpoints strip only the file part.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): route rejected post-repair main-info retries to the profile flow
Review round 26 (Codex P2): a simple-to-full repair during
fetchViaMainInfo makes executeStalkerRequest retry the same action against
the repaired full portal, and installations that do not implement
get_main_info answer 404 — the rejection escaped before the repaired-mode
check, so the dialog failed instead of switching to get_profile. The
rejection is captured and reaches the same check; without a mode change it
is rethrown unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): full predicate for wrapped denials; record the removed store prop
Review round 27 (Codex P2 + P1 docs):
- The repair trigger applies the shared auth-failure predicate to the error
MESSAGE too, so authentication's wrapped structured denials
(Error('Profile error: Access denied.')) reach the repair instead of
bypassing it and leaving a healthy sibling endpoint unprobed.
- docs/architecture/stalker-store-api-baseline.md records makeStalkerRequest
as removed, with the reason it gets no facade alias: it was
production-dead and held a fourth private copy of the portal-mode branch
that the shared predicate exists to prevent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): complete auth predicate for wrapped error messages
Review round 28 (Codex P2): the plain-text BODY matcher deliberately knows
only the three middleware phrases, so passing an error message through it
let authenticate()'s wrapped denials — Error('Profile error: Invalid
token') / 'Auth failed' — bypass both the repair trigger and the session
auth predicate. A dedicated isStalkerAuthFailureMessage() applies the wide
phrase set to controlled error strings, while arbitrary portal bodies keep
the narrow matcher that cannot false-positive on HTML pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(stalker): reject denied profiles during confirmation; document all repair triggers
Review round 29 (Codex P2 + P1 docs):
- Full-portal confirmation validates the get_profile envelope with the
shared structured predicate: a handshake can hand out a token whose
profile still answers {js:{error:"Invalid token"}}, and authenticate()
inspects only msg/block_msg — discovery would have persisted an unusable
endpoint and stopped before the healthy sibling. authenticate() now
returns the raw profile response for that check.
- The canonical lazy-repair contract lists the complete trigger set: the
plain-text bodies AND their JSON envelopes, HTTP 404, HTTP 401/403, and
terminal handshake/profile errors.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 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>