Commit Graph
113 Commits
Author SHA1 Message Date
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00
4grayandClaude Opus 5 9ff1c6ae01 feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.

Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.

get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".

Closes the identity-fields cluster: #927, #860.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:09:07 +02:00
4gray d2a83164ec feat(stalker): protocol-correct auth lifecycle (#1354) 2026-08-03 23:06:47 +02:00
4grayandClaude Opus 5 c741815b97 fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs

`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.

Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.

Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.

`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.

Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.

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

* fix(build): spawn git without a shell in the stylesheet check

`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.

Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.

Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.

Reported by Codex review on #1360.

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

* refactor(styles): move nav-list partial into ui-styles (#1361)

* fix(build): count every target of a comma-separated Sass @import

`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.

Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.

The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:18:37 +02:00
4grayandClaude Fable 5 b92503feae feat(stalker): endpoint probing + behavior-based portal mode with lazy repair (#1344)
* 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>
2026-08-02 18:01:45 +02:00
4grayandClaude Opus 5 6c065124ed feat(stalker): add account info dialog for Stalker portals (#1330)
* 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>
2026-08-02 09:58:03 +02:00
4grayandClaude Fable 5 e86e988e72 feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* 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>
2026-08-02 09:11:11 +02:00
4grayandClaude Fable 5 d44045de31 test(playlist): fix flaky refresh-preparation worker-event spec (#1331)
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>
2026-08-01 22:00:29 +02:00
4grayandClaude Opus 5 94efd7d379 fix(workspace): restore playlist info entry in header playlist dropdown (#1328)
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>
2026-08-01 20:26:38 +02:00
4grayandClaude Fable 5 8f861a3a1b fix(ui): make the workspace usable on phone-sized screens (#1326)
* 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>
2026-08-01 19:23:20 +02:00
4gray 46c7713841 fix(ui): preserve EPG in narrow channel rows (#1312)
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.
2026-07-31 08:27:36 +02:00
4gray 32ba209b63 fix(portals): restore fresh-import pins atomically (#1311)
* fix(portals): restore fresh-import pins atomically

* fix(portals): preserve Xtream restore retry state

* fix(portals): serialize Xtream restore revisions
2026-07-30 07:40:03 +02:00
4grayandClaude Opus 5 f80eb4d1b9 fix(playlist): open playlists handed over by the OS (#1299)
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.

The path now belongs to the main process, which is where the OS actually
delivers it:

- argv is parsed on first launch (skipping the executable and Chromium
  switches) and normalized to an absolute path;
- macOS gets an `open-file` listener registered before `whenReady`, since
  Launch Services never puts the path in argv;
- the single-instance guard forwards a second launch's argv and working
  directory instead of discarding them, so opening a playlist against a
  running app works too.

Requests are queued in the main process until the renderer subscribes to the
`OPEN_FILE` push and drains the queue, which closes the startup race. The
import itself reuses the existing file path, so persistence, playlist-scoped
EPG and the navigation to the new playlist behave exactly like a dialog
import; a failed open now surfaces a snackbar instead of silence.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:28:29 +02:00
4gray a2fafcfc08 test(performance): add end-to-end Xtream benchmark harness (#1300)
* docs(performance): plan Xtream benchmark

* feat(xtream-mock-server): add deterministic 100k fixture

* style(xtream-mock-server): apply repository formatting

* fix(xtream-mock-server): harden performance fixture data

* feat(xtream-mock-server): add performance control plane

* docs(performance): correct Xtream capture plan

* fix(xtream-mock-server): harden performance controls

* fix(xtream-mock-server): harden control lifecycle

* feat(performance): add Xtream preload markers

* feat(performance): trace Xtream main phases

* feat(performance): mark Xtream store publications

* feat(performance): trace Xtream database phases

* feat(performance): trace Xtream delete cancellation

* feat(performance): capture Xtream phase attribution

* feat(performance): mark Sources Xtream refresh

* test(performance): define Xtream benchmark evidence contracts

* test(performance): add Xtream benchmark runner

* test(performance): surface failure evidence writes

* test(performance): align database read clock

* test(performance): preserve capture failure contracts
2026-07-28 08:08:07 +02:00
4gray 24f0dee6f0 test(performance): add formal M3U import benchmark (#1287)
* test(performance): add formal M3U import benchmark

* test(performance): harden formal capture validity

* test(performance): address benchmark review feedback
2026-07-27 10:34:22 +02:00
4gray f9e71a6d8d fix(electron): isolate refresh performance correlation 2026-07-26 22:37:52 +02:00
4gray 1ab82b04a1 refactor(deps): drop uuid for a shared crypto-based id helper (#1266)
Supersedes #1252 and #872. uuid 14 is ESM-only, apps/web/jest.config.ts only
kept v9 working by mapping `^uuid$` at a `wrapper.mjs` that v14 no longer
ships, and the specifier also has to be synced in
libs/shared/m3u-utils/package.json or @nx/dependency-checks fails lint.

All four call sites only used `v4()`, so the dependency goes away instead.
`createRandomId()` prefers `crypto.randomUUID()` and falls back to building the
same v4 shape from `crypto.getRandomValues()` — that fallback is load-bearing,
because randomUUID is only exposed in secure contexts and the self-hosted PWA
is regularly served over plain http on a LAN address. getRandomValues stays
available there, and it is what uuid's own v4 used.

`@types/uuid` goes too; it only existed for the untyped v9 package.
2026-07-26 22:27:31 +02:00
4gray 9ae53e4515 fix(playback): make the "Show subtitles" setting reach the web players (#1269)
The persisted subtitle preference only ever had an owner behind the default-off
shared web-controls flag, so with the shipping controls it did nothing: Video.js
never read it, ArtPlayer declared the input but never used it, and the HTML5
player only ran a one-shot pass after play() resolved — before hls.js had added
its text tracks. No portal host bound the input at all, so it never reached
Xtream or Stalker pages either.

Extract the source-local track controllers into the adapter-free
WebVideoSourceTracks and have WebVideoSourceControlsBridge wrap it, so both
controls modes apply the preference through the same code. The preference-off
players bind it directly (VjsLegacyTracks for Video.js), and
WebPlayerViewComponent reads the preference from SettingsStore instead of an
input so every host inherits it.

The preference means different things depending on who renders the caption UI:
shared controls stay authoritative for the session, while vendor chrome is
source-default — the preference seeds each new source and is released once the
media reports playing, so the engine own caption menu keeps working. Mode
selection is an optional playbackStarted probe passed to the HLS, native and
Shaka helpers; in that mode the HLS helper deselects the track rather than
hiding it, since subtitleDisplay would silently override the vendor menu.

Closes #1155
2026-07-26 09:26:40 +02:00
4gray 3032cfa88d fix(m3u): avoid persisting hydrated favorites (#1232) 2026-07-26 02:20:32 +02:00
4grayandClaude Fable 5 b4c0cce741 fix(backup): export and restore hidden Xtream categories by real xtream IDs (#1224)
Category rows crossed the DB-worker IPC boundary with Drizzle's camelCase
property names while the renderer contracts declare snake_case, so backup
export dropped hidden-category IDs and restore degraded to a type-only
match that hid every category. Project category ops to the declared wire
shape, normalize restore state from untrusted sources (dropping entries
without a numeric xtreamId), reject entries with missing user-state
collections, and add full export→import round-trip coverage (unit
manifest-equality + Electron e2e) plus regression tests.

Closes #1017

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:32:45 +02:00
4grayandClaude Fable 5 bd07e17857 feat(m3u): DASH + ClearKey playback via Shaka Player (#1225)
* feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines

Adds the typed ChannelDrm model (shared interfaces) and a KODIPROP
post-processing step in createPlaylistObject() — the single funnel for all
four playlist import paths. Parses inputstream.adaptive.license_type,
license_key and drm_legacy; ClearKey keys accepted as kid:key hex pairs,
W3C ClearKey license JSON, or a plain kid→key JSON map. Unsupported license
types (Widevine/PlayReady/license URLs) are preserved with supported=false
so playback can surface a DRM diagnostic instead of failing silently.
Also adds isDashStreamUrl/isDashChannel helpers for DASH routing.

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

* feat(playback): add Shaka DASH source engine with ClearKey support

Introduces ShakaVideoSession (libs/ui/playback/src/lib/shaka-engine/): a
lazily imported shaka-player engine (separate lazy chunk, ~217 KB transfer)
owning attach/configure/load with an operation queue and generation guard
against channel-switch races. Channel ClearKey config maps to
drm.clearKeys; channels with an unsupported license type emit a
DrmOrEncryption diagnostic without starting an engine. Shaka errors are
classified into the existing playback diagnostics
(PlaybackDiagnosticSource.Shaka).

Wires the engine into both built-in players like hls.js/mpegts.js:
- HTML5: extension === 'mpd' branch in playChannel(); hls/mpegts/native
  glue extracted to helpers to keep the component within the size budget
- ArtPlayer: customType 'mpd' in ArtPlayerSourceSession (+ getDrm seam)
- Shared controls: WebVideoControlsSource kind 'shaka' +
  WebVideoShakaControls using the Shaka 5 text model (selectTextTrack(null)
  hides subtitles; Player.setTextTrackVisibility no longer exists)

Adds a CJS shaka-player jest stub (video.js precedent) for web specs.

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

* feat(m3u): route DASH channels to the inline Shaka-capable player

DASH (.mpd) channels always play in a built-in web engine (radio
precedent): external MPV/VLC cannot receive KODIPROP ClearKey
configuration (VLC upstream #29465) and Video.js has no DASH bridge yet.

- shouldShowInlinePlayer() bypasses the external-player setting for DASH
- new shouldAutoLaunchExternalPlayer() guard consolidates the MPV/VLC
  auto-launch conditions in the m3u-state effects (incl. catch-up path)
- the M3U page overrides the player for DASH channels: ArtPlayer stays
  ArtPlayer, everything else falls back to the HTML5 player
- ChannelDrm is passed through ResolvedPortalPlayback into the synthetic
  player-view channel

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

* test(e2e): add offline DASH ClearKey fixtures and e2e coverage

Fixtures (apps/web-e2e/src/fixtures/dash/): ~4s VP9+Opus DASH, clear and
CENC-encrypted variants with fixed synthetic ClearKey credentials.
Content synthesized by ffmpeg; encryption done by Shaka Packager because
ffmpeg's mp4 muxer writes senc-only metadata (Chromium needs saiz/saio)
and cannot produce the subsample encryption the VP9 CENC binding
requires. Generation script + README document regeneration.

web-e2e (Chromium): import an M3U with KODIPROP ClearKey via raw text,
verify encrypted and clear DASH actually play (currentTime advances, no
diagnostic banner) and that an unsupported license type (Widevine)
surfaces the DRM diagnostic. Fixtures are served through Playwright route
interception with HTTP Range support; the Angular service worker is
blocked since SW-routed requests bypass interception.

electron-backend-e2e: the same happy path + negative against a local
Range-aware fixture server — the automated proof that ClearKey EME works
in the real Electron runtime (file:// secure context).

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

* docs: document DASH + ClearKey playback architecture

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

* fix(pwa): extract KODIPROP DRM on the web-backend /parse import path

The web-backend keeps its own playlist builder for the PWA URL-import
path, so the shared createPlaylistObject() DRM hook never ran there and
encrypted DASH channels imported by URL reached Shaka without keys.
Apply extractDrmFromRaw() in that builder too and cover the path with a
regression test.

Addresses Codex review on PR #1225.

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

* fix(playback): interrupt stalled Shaka loads and destroy failed engines

Two review findings on the ShakaVideoSession lifecycle:

- stop()/start() now tear the current player down immediately instead of
  queueing the destroy behind the in-flight operation. Shaka's destroy()
  interrupts a pending load() (LOAD_INTERRUPTED), so a stalled manifest
  fetch can no longer wedge the operation chain and block the next
  channel start (Codex P1).
- A rejected attach()/load() now destroys the failed player after
  emitting the diagnostic, so a non-functional engine never stays
  attached to the media element or exposed to the shared-controls
  bridge (Greptile P1).

Regression tests cover both paths. The Shaka fakes are consolidated into
a shared jest-free test double that mirrors the destroy-interrupts-load
semantic, and the ArtPlayer source-session spec is split (fixtures +
DASH cases) to stay within the max-lines lint budget.

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

* fix(m3u): unify DASH URL detection with playback extension normalization

isDashStreamUrl() used the simpler getStreamExtensionFromUrl(), so URLs
the player engines classify as DASH (stream.MPD, ?ext=mpd, ?format=mpd)
were not routed to the Shaka-capable inline player and lost their
ClearKey metadata with Video.js or external players configured
(Codex P2). The normalized getPlaybackMediaExtensionFromUrl() now lives
in @iptvnator/shared/m3u-utils (re-exported unchanged from the playback
lib) and both routing and engine selection share it.

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

* chore(lint): satisfy CI lint and CodeQL in DASH support files

- replace shell-built tar/npm commands with execFileSync arg arrays in
  the fixture generator (CodeQL: uncontrolled shell command)
- give jest stub methods explicit bodies (no-empty-function)
- compact the diagnostic label switches in WebPlayerViewComponent to
  stay under the max-lines budget

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

* fix(playback): tear down the Shaka engine on critical error events too

A non-recoverable Shaka error emitted after a successful load left the
dead engine attached to the media element and exposed to the
shared-controls bridge (Greptile P1, round 2). Critical error events now
destroy the player right after the diagnostic is emitted, matching the
load-failure path.

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

* fix(m3u): honor DASH catch-up URLs and drop unusable DRM fallbacks

Two Codex round-2 findings:

- The inline-playback DASH gate only examined the channel URL, while the
  external-player guard checks the resolved catch-up URL — a replay that
  resolves to an .mpd manifest with MPV/VLC configured ended up with no
  player at all. The gate now uses the effective playback URL
  (activePlaybackUrl ?? channel.url).
- The unsupported-DRM diagnostic advertised MPV/VLC fallback actions,
  but external players cannot receive the KODIPROP license config either
  — the diagnostic no longer recommends them.

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

* fix(playback): suppress unusable external fallback for ClearKey DRM failures

Runtime DRM errors on channels that carry KODIPROP ClearKey config (wrong
or rotated keys) advertised MPV/VLC fallback actions, but external
players never receive the license config — the fallback could only fail
differently. DRM-classified diagnostics from such channels no longer
recommend external players; clear channels keep the hint.

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

* fix(m3u): symmetric DASH inline gate and lazy DRM for pre-upgrade playlists

- The inline DASH gate is now true when either the channel or the
  resolved catch-up URL is DASH, mirroring the external-player guard —
  a .mpd channel whose catch-up resolves to .m3u8 no longer ends up
  with no player at all.
- Playlists imported before the DRM feature carry no drm field, but the
  raw KODIPROP block survived in the stored items; the M3U page now
  falls back to extractDrmFromRaw(channel.raw) at playback time, so
  encrypted channels work without a re-import (Channel gains raw?).

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

* docs: sync the DASH/Shaka contract across agent docs

Mirrors the DASH/Shaka source-engine contract into AGENTS.md and adds
Shaka to the shared web-video bridge descriptions in CLAUDE.md and the
player-controls contract; documents the lazy raw-KODIPROP DRM fallback
for pre-upgrade playlists in the M3U architecture doc.

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

* fix(playback): reset the media element for rejected DRM and widen ClearKey fallback suppression

- Switching from a playing stream to an unsupported-DRM DASH channel
  loads no new source, but play() still ran and the un-loaded element
  could resume the previous stream underneath the diagnostic banner.
  The HTML5 player now resets the element instead of playing.
- Any inline failure on a KODIPROP ClearKey channel (manifest, codec,
  media, network — not just DRM-category errors) is unsolvable in
  MPV/VLC, which never receive the license config; the external
  fallback hint is now suppressed for all diagnostics of such channels.

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

* fix(playback): restore suppressed DASH captions when the preference re-enables

The Shaka bridge dropped the auto-selected text track with
selectTextTrack(null) when showCaptions was off, but did not remember it
— re-enabling the preference mid-session left captions permanently off
(HLS/native bridges already restore). The session now remembers the
suppressed track id and reselects it via the bridge's caption-state pass;
suppression is also skipped when no track is active. Covered by session
and new WebVideoShakaControls specs.

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

* chore: retrigger CI

GitHub Actions created no check suites for the last three pushes to this
branch (third-party apps received the webhooks); an empty commit re-fires
the push and pull_request events.

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

* refactor(playback): split oversized Shaka session and HTML5 spec files

CI lint enforces max-lines 400: extract ShakaTextTrackSuppression and the
shaka-error helpers out of ShakaVideoSession, and move the DASH-specific
HTML5 player test into its own spec. No behavior change.

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

* ci: allow manual dispatch of the cross-platform E2E workflow

GitHub stopped delivering push/pull_request events for this branch;
workflow_dispatch provides a manual escape hatch (CI and build-and-make
already have one).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:31:18 +02:00
d61fd5db19 feat: add strip country prefix setting (#1162)
* feat: add strip country prefix setting

* feat: scope country-prefix stripping to live content and cover missing surfaces

- narrow the heuristic: pipes always strip, dash/colon separators only
  when the prefix is a short uppercase tag ("UK - BBC One" strips,
  "Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
  playback isLive, external sessions without contentInfo, dashboard
  cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
  M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
  SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
  channel-list-item and external-playback-dock

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

* test: cover strip-country-prefix call sites for codecov

- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback

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

---------

Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:43:37 +02:00
4grayandClaude Fable 5 1c710d69ef chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps (#1116)
* chore(cleanup): delete nine dead components, orphaned i18n keys and unused deps

Removes verified-dead components (0 class/selector references outside
their own files): EpgListComponent (+ epg-list-item), EpgViewComponent,
LiveEpgPanelComponent, StalkerCollectionChannelsListComponent,
NavigationComponent, FilterSortMenuComponent, video-player
ToolbarComponent, PortalCollectionShellComponent and
LoadingOverlayComponent, together with their barrel exports.

Alive code extracted from the deleted trees:
- LiveEpgPanelSummary -> libs/ui/shared-portals/src/lib/live-epg-panel-summary.ts
- EpgProgramActivationEvent -> libs/ui/epg/src/lib/epg-program-activation-event.ts
- epg-list.utils.ts trimmed to the three timeline-used helpers and moved
  to libs/ui/epg/src/lib/epg-program.utils.ts
- epg-item-description/ moved up out of the deleted epg-list/ folder

Also removes 18 i18n keys now unused (from all 18 locales), dead CSS
selectors targeting the deleted elements, and unused dependencies:
lodash (+ @types/lodash), semver, @ngrx/component-store and
@videojs/http-streaming (videojs-quality-selector-hls declares no peer
dependency on it; video.js 8 bundles VHS).

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

* chore(coverage): move shared-portals to Tier C, fix stale doc references

The Tier A gate failed in CI because deleting the dead epg-view and
live-epg-panel components removed the only specs in libs/ui/shared-portals.
The lib now contains a single type-only interface (LiveEpgPanelSummary),
so there is no runtime code to unit test; reclassify it to Tier C with a
documented reason, matching the gate's own guidance.

Also update remaining doc references to the deleted components in
docs/architecture/stalker-epg.md, iptvnator-ui-guidelines.md and
CLAUDE.md.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:09:50 +02:00
4grayandClaude Opus 4.8 0b526b119a feat(epg): add vertical list view for the live EPG panel (#1115)
* feat(epg): add vertical list view for the live EPG panel

Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).

- New EpgListViewComponent (app-epg-list-view) mirrors
  EpgTimelineComponent's input/output contract 1:1, so all four live
  hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
  with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
  hasProgramsForDateKey, epg-archive.util catch-up gating,
  epg-summary.util collapsed-summary maths, epg-date helpers,
  EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
  duplicated logic.
- Rows show time range, title, optional description, live progress on
  the on-air row, catch-up "Watch" on past rows when archive playback
  is available, and a details dialog; keyboard activation guards
  nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
  collapse/expand remounts, and shows a sticky in-flow "On now" strip
  (never overlaying rows) when the current programme is scrolled away;
  all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
  modifier (--epg-inline-height clamp); timeline and collapsed heights
  are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
  SettingsStore/StorageMap → segmented control in the EPG section);
  Electron-only UI, PWA stays on the timeline default. i18n keys added
  to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
  persistence spec, swap tests in all four host specs, and Electron
  E2E for the settings round-trip and the rendered list view. Docs
  updated (m3u-playlist-module.md).

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

* fix(epg): address list-view review findings from Codex and Greptile

- Reset the list view to today when a new channel's programme set
  arrives while the user is parked on another day (timeline parity):
  the scroll controller now keys by the full programme-set identity
  (programsFocusKey) and commits today before focusing, instead of
  silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
  on SettingsStore; the four live hosts consume the derived signal
  instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
  registerEpgListViewEffects(), bringing the component back under the
  300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
  coverage: return-to-today on channel switch, day navigation left
  alone, no-takeover when today has no data, empty-set no-op.

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

* fix(epg): drop malformed programmes from the list-view day filter

Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:05:08 +02:00
4gray 401012eac4 feat(epg): rework live EPG panel into a horizontal timeline (#1102)
Replaces the vertical EPG list with a shared horizontal `app-epg-timeline`
ribbon across all live surfaces (M3U player, unified live tab, Xtream, Stalker):
zoom, day navigation, short-programme grouping, catch-up/timeshift, and
per-state empty views. Backend gains timezone-aware `datetime()` comparisons,
unscoped source fallback, non-ASCII candidate matching, and chunked candidate
queries.

Timeline split into reusable, view-agnostic modules (archive/summary/dialog
service/render util/scroll controller) for the future EPG list view.

Fixes landed during review:
- honor the controlled `selectedDate` input (seed via linkedSignal)
- restore ribbon position across collapse/expand
- keep the ribbon mounted when scrolling across a gap day
- don't trigger block playback on Enter from nested watch/info buttons
- don't reset timeshift playback on the 30s now-tick during EPG gaps

Greptile 5/5 (safe to merge); Codex clean; CI green.
2026-07-01 21:09:49 +02:00
4gray 3cb228d931 fix(m3u): highlight active archive epg program (#1097)
fix(m3u): highlight active archive epg program

* Store the active archive EPG program with the resolved playback URL so the clicked M3U catch-up row stays highlighted during archive playback.
* Show archive playback state and return-to-live controls in the inline player panel.
* Hide duplicate EPG program rows that share the same time slot while keeping the richer provider entry.
2026-06-28 14:34:51 +02:00
4gray e801028005 feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
2026-06-21 22:53:07 +02:00
4gray 89916af9d8 feat(search): add workspace global search with M3U support
Moves global search into a routed workspace view, adds M3U live/radio results, lazy pagination, and DB-backed matching improvements.
2026-06-20 23:05:48 +02:00
4gray 20d0f01428 fix(xtream): address portal review feedback 2026-06-14 13:47:42 +02:00
4gray 254879f92b fix(xtream): improve portal compatibility 2026-06-14 13:21:56 +02:00
4gray bc4a5958dc fix(remote-control): apply volume to built-in M3U players
Closes #1061
2026-06-14 13:12:45 +02:00
4gray dfdb5bb8ad fix(playlist): save xtream details in pwa
- save Xtream playlist details through browser-safe metadata persistence in PWA\n- keep PWA Xtream data source cache in sync with current playlist metadata\n- cover dialog close timing, stale cache, and PWA data-source bootstrap regression
2026-06-13 16:04:54 +02:00
4grayand4gray e8aa7c3a34 [codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:24 +02:00
4gray 701cf23299 chore(frontend): clean logging and lint suppressions 2026-05-26 20:18:12 +02:00
4gray 003a8774a1 fix(electron): tighten navigation review feedback
Restrict packaged file navigation to the app renderer, guard redirects, and handle fire-and-forget header override IPC failures.
2026-05-23 19:05:38 +03:00
4gray fc449ca73e feat(electron): harden renderer security
Scope playback request header overrides to active stream origins and document the Electron runtime security contract.
2026-05-22 23:31:41 +03:00
4gray ed8680116c fix(runtime): address consolidated review feedback 2026-05-22 14:02:56 +03:00
4gray 4603bd83d5 refactor(runtime): gate playlist refresh by capability 2026-05-22 13:36:39 +03:00
4gray 3e398f1d1a refactor(remote-control): gate live bridge by capability 2026-05-22 13:35:03 +03:00
4gray 61cfe64951 refactor(playlist): use runtime capability for xtream sections 2026-05-22 12:35:43 +03:00
4gray e51dbd995e refactor(import): use runtime capability for url hint 2026-05-22 12:30:22 +03:00
4gray 6ad61ce680 refactor(playlist-ui): use runtime capabilities 2026-05-22 11:39:23 +03:00
4gray 820fbef121 refactor(playlists): move refresh action to ui boundary 2026-05-22 11:27:04 +03:00
4gray b2778d8b18 refactor(playlists): centralize delete lifecycle 2026-05-22 11:10:04 +03:00
4gray c571c57c5b refactor(runtime): centralize platform capabilities 2026-05-22 11:00:34 +03:00
4gray 36bce47764 merge: resolve master conflicts for pwa hardening
- merge origin/master into PR #964 and keep embedded MPV test on the isolated playback sub-entrypoint

- centralize EPG capability through DataService.supportsEpg and update PWA web-e2e expectations

- split BrowserAccessError copy between Electron and PWA diagnostics
2026-05-22 10:20:03 +03:00
4gray 4ab8915483 chore(web): enable strict TypeScript mode 2026-05-22 02:58:12 +03:00
4gray 55efc24608 fix(pwa): harden self-hosted runtime boundaries 2026-05-22 02:09:57 +03:00
4grayandClaude Opus 4.7 d59fa74650 fix(add-playlist): align dialog head with content padding
The custom .apd-head was getting visually orphaned from the rest of the
dialog body: zero horizontal padding so the title sat flush against the
dialog edge (cards inside mat-dialog-content are inset by 24px), plus a
~44px vertical gap above the title even though the head reported
padding: 0.

The gap was Material's .mdc-dialog__title::before — a 40px-tall
invisible inline element used to baseline-align dialog titles to a
grid. Useful for single-line titles, hostile to a two-line head with
explicit subtitle.

Set .apd-head padding to 20px 24px 4px so it shares the dialog
content's 24px horizontal rhythm, then suppress the ::before ghost.
Title now sits 20px from the dialog top edge with the same left inset
as the method cards and the form fields below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:16:49 +02:00
4grayandClaude Opus 4.7 810d9170f0 fix(add-playlist): widen dialog and use card-friendly labels
The 5-card method picker was rendering at the old 560px dialog width
which left each card too narrow — titles wrapped to 2-3 lines and the
subtitles to 6, all visible in the user-reported screenshot.

Three fixes:

1. Bump the dialog width from 560px → 780px to match the v0.22 mockup.
   maxWidth: 92vw keeps the responsive collapse path intact.

2. Replace the inherited "Add via URL" / "Add via file upload" /
   "Add Xtreme Code" / "Add Stalker Portal" / "Import from text" tab
   labels with short noun-only card titles: "M3U URL", "M3U file",
   "Xtream credentials", "Stalker portal", "Raw m3u text". The dialog
   itself is titled "Add playlist" so the "Add" prefix on every card
   was redundant. The "Xtreme" misspelling on the old key disappears
   as a side effect.

3. Lock cards to min-height: 132px so the URL card (short subtitle)
   stops collapsing while the Xtream/Stalker cards (longer subtitle)
   stretch — selection states now look visually consistent. Slightly
   tighter padding (10/11/12 vs 12/14) to fit the new width without
   feeling cramped.

i18n: 5 new METHOD_*_LABEL keys translated across all 17 locales by
per-locale agents; placeholder integrity verified across all 920+
leaf keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 01:15:18 +02:00