Commit Graph
161 Commits
Author SHA1 Message Date
4grayandClaude Fable 5.1 3b2af73baa style(ui): ADR and one global kit layer for menus, select panels and destructive buttons (#1936)
* docs(ui): record the Angular Material role decision in the UI guidelines

Angular Material stays the behaviour and accessibility layer; the look comes
from the design kit through mat.*-overrides() on --app-* tokens in the shared
partials, kit-only components build on CDK, and plain actions may become
native buttons. Records the measured Material and CDK share of the initial
bytes, why a replacement would not pay, and the v22 behaviour changes to
keep covered in E2E.

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

* style(ui): one global kit layer for menus, select panels and destructive buttons

- `<mat-menu class="app-menu">` gets the kit panel from one rule in
  styles.scss; the two lazy components drop their `::ng-deep` copies.
- Select option panels follow the same panel: bordered, 10px radius, 6px
  padding, 36px rows at 13px, the chosen row on the list-selection tokens.
- `.app-destructive-button` is the kit's red tint on the `--app-danger-*`
  tokens (tinted fill, red text, no ripple, 40% opacity when disabled)
  instead of a solid error fill, for filled, text, outlined and icon
  buttons.
- The backdrop blur is scoped to the dark backdrop the CDK gives modal
  overlays; menus, selects and popovers open over a transparent backdrop
  that no longer matches the rule.
- `mat.elevation-classes()` is dropped: nothing uses `.mat-elevation-z*`,
  and its 25 rules cost 4.4 KB of the initial stylesheet. The stack now
  measures 1,595,843 initial bytes (was 1,597,764).

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

* test(e2e): wait for focus to enter an opened menu before navigating it

Material focuses a menu's first item after the next render, which the
zoneless scheduler runs on a later task than the keydown that opened it.
The panel is visible before that, so End sent as soon as it appeared still
reached the trigger: on the Linux runner the Sources row-menu delete test
then found "Remove playlist" inactive on every retry. `waitForMenuFocus`
polls for the active element inside the open menu; the Sources keyboard
and switcher keyboard tests use it before their first navigation key.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:53:22 +02:00
4grayandClaude Opus 5.5 90a31d9be9 feat(sources): redesign the Sources page and the playlist switcher (#1932)
* feat(sources): add the shared source row and credential-free formatting

One app-source-row (regular for the Sources page, compact for the
playlist switcher) with an app-source-tile, built on pure helpers:

- resolvePlaylistSourceKind: one provider rule for icons, filters and
  counts (the list filter and the panel counts used to disagree).
- sourceDisplayName / sourceDisplayUrl: a name or URL never shows a
  password, token or MAC. URL imports that stored
  "get.php?username=…&password=…" as the title now show the host.
- stripUrlCredentials (shared/logging): drops what the log redactor
  would mask, plus short user/pass keys.
- formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line
  order and Intl dates and durations in the UI language.
- SourceStatusService + SourceHealthHistoryService: one status per
  source (desktop health check, browser Xtream portal status) and the
  last conclusive result kept across restarts, so "not responding for
  3 days" survives a relaunch. No URL or credential is stored.

Also fixes playlistNameHasCredentials answering differently on repeated
calls (global regex lastIndex), and adds the UI-kit button, menu and
segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt
in with class="app-menu", styled by the components that use them.

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

* feat(sources): rebuild the Sources page on the shared source row

- Side list (UI-kit side list): title with the playlist count and an Add
  button, a Type group, and on desktop a Status group (Any / Responding /
  Not responding). The two filters combine and persist; each group counts
  within the other's choice. The chosen row uses one selected-row token
  (soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
  header bar; Clean up and the sort menu are secondary buttons. Clean up
  shows how many sources do not respond. Sort adds Last updated, Most
  channels and (desktop) Not responding first, and keeps Name Z-A and
  Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
  row keeps Retry in view. "No matches" offers Reset filters. Dragging in
  custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
  runs when the toast closes (its close button commits at once). Deleting
  the active source switches to the next row. A large Xtream delete shows
  its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
  performance drivers follow the new markup and delete flow.

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

* feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P

- The trigger is a 40px secondary button: the active source's tile with
  its status dot, its name and "type · channels" (or the source count),
  and a chevron that turns while open. The name comes from
  sourceDisplayName, so a URL-named playlist no longer shows its URL or
  credentials in the toolbar.
- Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays
  the command palette) and is listed in the keyboard shortcuts dialog.
- The trigger's inline refresh button is gone; refresh stays in the
  playlist's ⋯ menu and the command palette.

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

* feat(switcher): rebuild the playlist switcher as an overlay popover

The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.

- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
  control (All / M3U / Xtream / Stalker with counts) instead of three
  checkboxes, remembered between openings (the old checkbox setting is
  migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
  "All" in the Sources page order, as compact source rows: the current
  one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
  info, Account info, Check again and Remove (with Undo); the footer's
  info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
  field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
  row's menu, Escape closes and returns focus to the trigger. Hints in
  the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
  row (and its focus) left the page.

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

* refactor(sources): remove the old source row, filter state and dead styles

- Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by
  app-source-row and its tile dot.
- Delete the ngrx type-filter slice (FilterActions, selectedFilters,
  selectActiveTypeFilters); the Sources filters live in
  SourcesViewStateService.
- Drop the empty state's unused "no results" variant and its styles.
- The switcher and Sources list components are no longer in the max-lines
  baseline.
- Docs: UI guidelines (source row, Sources page and switcher, status dot,
  selected-row tokens, UI-kit mixins), the source-health contract and the
  zoneless inventory. Release notes for the redesign and the credentials fix.

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

* fix(catalog): keep the refine menu whole in short windows

The refine menu held 12 rows at 48px plus two section titles (657px).
Angular Material 22 sets no max-height on a menu panel, so below ~800px
the overlay was pushed up over its own Refine button, and at 600px the
last thresholds were clipped behind an invisible scrollbar.

The minimum-rating thresholds now open in a submenu from one row that
names the active threshold. The menu is about 390px and opens below its
button at 600px. The submenu trigger sits in a role="none" wrapper:
Material offsets a submenu by the first direct mat-menu-item's
offsetTop, and with the sort rows inside a role="group" that item was
the trigger itself, which opened the submenu 335px off screen.

The Electron E2E opens both menus by keyboard in a 600px-tall window
and checks that each lies whole on screen without scrolling.

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

* style(workspace): drop the side panel's divider line

The context panel beside Sources, categories, Settings and collections
drew a 1px outline-variant border against the content surface, whose
background, shadow and rounded top-left corner already mark the edge;
the line clashed with that corner. The border stays 1px but transparent,
so the panel width, the persisted sidebar widths and the resize handle's
position do not move. The handle keeps its hover and drag highlight.

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

* fix(switcher): keep the search field and type filter whole with many sources

The switcher popover is a height-capped flex column. With a long list
(hundreds of sources) the browser shrank every part in proportion to its
size, not only the list: the type filter, which clips its overflow,
collapsed to 2px and showed no labels, and the search field dropped from
36px to its 21px text line. The header, search, type filter, empty state
and footer are now flex: none, so only the list shrinks and scrolls.

The search field now matches the header and side-panel search fields:
the shared --app-search-* tokens, a 10px radius, an 18px icon, the same
text size and focus halo.

The Electron E2E opens the switcher in a 320px-tall window and checks the
search height, the type toggles and the scrolling list; it fails without
the fix (search 21px).

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

* fix(sources): checks behind filters, keyboard Undo in the switcher, check causes

Local review round 1 (Codex + Greptile) on the redesign:

- A saved Responding / Not responding filter hid every source without a
  result, and the hidden rows were what started the checks, so the list
  stayed empty. keepSourcesChecked (portal shared data-access) now keeps
  every library source checked while the Sources page lives, on desktop
  where a check is one shared job that rows join.
- Removing from the switcher left its focus trap open around the search,
  outside of which the Undo toast sits: Tab could not reach Undo. The
  popover now closes first; Undo takes the focus and returns it to the
  trigger (SourceRemovalOptions.returnFocusTo). Every switcher action
  closes through one path, so a closing row menu no longer pulls the focus
  back into the popover.
- A running desktop Xtream delete brought its source back into the
  switcher as an ordinary option that could be opened or removed again; the
  switcher now leaves it out until the delete ends (the Sources page shows
  its progress and Cancel).
- Undo re-activated the removed source over a source the user had chosen
  since, when nothing had replaced it (it was the only row a filter
  showed). Undo now restores only while no other source is active.
- The switcher highlight stays on its option, or in range, when the list
  changes under the same search, so Enter still opens something.
- The status dot named every failure "Could not connect". SourceStatus
  now carries the check's cause (access denied, account disabled, timeout…,
  kept with the health history and validated on read), and M3U dots again
  say the check covers the playlist URL, not its channels.

Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo
guard and focus return, the switcher close/highlight/deleting cases and
the dot's cause; the filter and Undo specs fail without their fixes. The
switcher keyboard E2E now deletes and undoes from the keyboard.

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

* fix(sources): own Undo timer, per-connection health history, race-free perf waits

Local review round 2 (Codex + Greptile):

- The delete committed whenever the shared snack bar was dismissed, so
  another message (a refresh finishing elsewhere) cut the four-second Undo
  window short and deleted the source early. The window is now the
  service's own timer; only the toast's close button (commitNow) or a
  second delete commits sooner.
- The health history was keyed by playlist ID only, so an edited address
  or account inherited the old connection's result, failing run included.
  Entries now keep a 32-bit FNV-1a fingerprint of the source health key
  (no URL or credential); a result for another connection starts over and
  a stale entry no longer shows.
- The M3U refresh-cancellation probe still queried the removed row's
  .cancel-btn, .busy-state__message and .action-spinner, so its cancel
  never fired; it now uses the source row's selectors (smoke run passes,
  cancelEffect=true).
- The Xtream delete benchmark waited to catch the "Removing…" row coming
  back before it left, which a fast delete could finish before the wait
  began. The driver now arms a delete-playlist operation window before the
  commit click and ends on its completed event and the absent row.

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

* fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger

Local review round 3 (Greptile; Codex was clean):

- Name order compared the stored title, while the rows show a URL-named
  or credential-bearing source by its host: a source stored as
  get.php?username=… sorted by its hidden account details. sortPlaylists
  takes the list's name function, and Sources and the switcher pass
  sourceSortName, the name they display.
- The Xtream delete benchmark clicked Remove while preparing, which starts
  the four-second Undo window; capture setup before the trigger could
  outlast it, so the delete committed on its own before the measurement
  and the close button was gone. Preparation now stops at the open menu,
  and the trigger clicks Remove and then the toast's close button, with
  the trigger time taken right before the commit click as before.

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

* fix(sources): never hand over to a leaving source; browser checks per connection

Local review round 4 (Codex + Greptile):

- Removing the active source handed over to its neighbour even when that
  neighbour's own delete was still running ("Removing…"), activating a
  source about to disappear. replacementAfterRemoval (playlist shared
  util) picks the nearest row that is not leaving, on the Sources page and
  in the switcher; SourceRemovalService also refuses a leaving replacement.
- In the browser build a "Check again" for an account the user then
  edited could finish last and overwrite the new account's status under
  the same playlist ID. Portal statuses are now keyed by playlist and
  connection fingerprint, so the old check lands under the old key.
- The switcher scrolled the highlighted option into view through a
  selector built from the playlist ID, which a restored ID with "." or
  ":" broke (or made throw). It now finds the row by its exact DOM ID.

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

* fix(sources): keep ⌘P out of open dialogs; redact refresh errors

Local review round 5 (Greptile; Codex was clean):

- Ctrl/Cmd+P opened the playlist switcher over an open dialog (an
  editor, a confirmation), taking the focus out of it and handing it back
  to the toolbar behind the dialog on close. The shortcut now leaves the
  key alone while MatDialog has open dialogs.
- SourceListRefreshService logged a failed refresh's raw error, which can
  carry the playlist URL and its credentials. It now logs through the
  redacting portal logger (createLogger).

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

* fix(sources): show a new search's first match; keep inconclusive check causes

Local review round 6 (Codex; Greptile was clean at 5/5):

- Typing a search or picking a type in the switcher highlighted the first
  match but kept the list scrolled where the previous result left it, so
  the highlighted row could sit above the view. The list returns to the
  top whenever the search or type changes.
- A source with no kept history whose check was inconclusive (connection
  checks paused by the connectivity guard, a cancelled check) lost the
  live cause and time, and its dot said only "Not verified". The status
  now keeps them; a running check still reads as checking.

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

* fix(switcher): fall back to All when the remembered type has no sources left

Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the
last source of the remembered switcher type was removed while two other
types remained, the list kept filtering by the missing type. Its segment
was gone, no segment was selected, and the list said "Nothing found". The
list now filters by All until a source of that type returns; the remembered
choice is kept.

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

* fix(sources): clean credentials nested in kept URL parts

Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive
parameter value unchanged, so a URL nested in one (a proxy's
?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and
password and reached the Sources row through sourceDisplayUrl.

Kept query and fragment values and path segments are now cleaned
recursively, up to four levels: a nested URL loses its credentials the
same way, a nested parameter list loses its sensitive keys, and a part
that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper
nesting is dropped rather than shown.

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

* fix(sources): keep fragment and nested URL secrets out of source names

Greptile bot (PR review, P1 security): an unnamed import of
https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=…
as its title. isUrlDerivedName only knew names ending in a query, and
sourceSecrets only read the top-level query, so the row and the toolbar
showed the secret while the displayed URL hid it.

A name that ends the URL with a query or a fragment is now URL-derived,
and shows the host. The secrets a typed name is checked against now
include sensitive fragment parameters and, as stripUrlCredentials cleans
them, the credentials of URLs and parameter lists nested in parameters
(nested account names included).

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

* fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher

PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes):

- CI: category-management.e2e refreshed the active playlist through the
  old switcher trigger's inline refresh button, which the redesign
  replaced with the switcher row menu (⋯ → Refresh playlist). The helper
  now goes through that menu; it failed on all three OS shards.
- A desktop Xtream delete's completed event cleared its progress while
  PlaylistsService still cleaned up (parental lock, stored state), so the
  already-deleted source was actionable again on Sources and back in the
  switcher until the store dropped it. A terminal event now only ends
  Cancel; the row stays busy until the delete settles and the removal
  service clears it.
- A running delete (from the switcher, say) disappeared from Sources when
  the page's saved filters excluded it, hiding its only progress and
  Cancel. Running deletes stay listed whatever the filters.

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

* fix(sources): keep counting a failure's duration while the list stays open

Local Greptile (5/5, one P2) on the PR round: the row's status tag
formatted "Not responding · 3 days" against the time the row mounted (or
the language last changed), so a new failure never gained its duration
and an existing one froze while Sources stayed open. A shared root
SourceClock now ticks once a minute and only the status tag reads it, so
dates and the meta line do not recompute every minute.

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

* fix(sources): keep the page header in view; mark the active source's row

Local Greptile (two P2s) on the PR round, both regressions against master:

- The redesigned Sources page scrolled as one, so the title, Clean up and
  Sort left the view in a long library (master kept a sticky header, and
  the UI guidelines say headers stay and lists scroll). The header now
  stays and only the list below it scrolls, which also keeps a row reached
  by keyboard from scrolling under the header.
- Master marked the active playlist on the Sources page; the new row only
  tinted the current row in the compact (switcher) size. The active
  source's row is now tinted with the same --app-list-selected-* token on
  the page too; the switcher keeps its check.

The design handoff called the header non-sticky and the current tint
switcher-only; both followed master and the guidelines instead.

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

* fix(sources): name the Sources sort group; refit the switcher on resize

Local review on the PR round:

- Greptile: the Sources sort menu's radio group had no accessible name,
  unlike every other menu group (UI guidelines). It is named by its
  section, "Sort by".
- Codex: the switcher popover capped its height once, when it opened, so
  shrinking the window while it was open left the footer and the lower
  rows out of reach. A window resize refits the open popover.

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

* wip: linear URL-cleaning regexes

* fix(sources): linear URL-cleaning patterns; keep the stripper off the startup path

PR #1932 CI:

- CodeQL (polynomial regular expression on uncontrolled data): the
  embedded-URL and keyed-pair patterns in stripUrlCredentials could retry
  from every position of a long run of letters. Matches now start only
  where a name can start (lookbehinds) and every name part is bounded, as
  is the bare-userinfo pattern; a spec times 50,000-character runs. The
  preceding "wip: linear URL-cleaning regexes" commit holds that change.
- Initial bytes ratchet (+135 B over after master's #1928 lowered the
  baseline): stripUrlCredentials sat behind the shared logging barrel,
  which startup code imports, so esbuild placed it in an initial chunk
  although only the lazy Sources page and switcher use it. It now lives
  beside its only consumer in playlist/shared/util source-display, and the
  logging barrel exports the two redaction helpers it needs. Measured
  locally on the merge with master: 1,537,497 bytes, within 1,538,700.

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

* fix(sources): case-insensitive Xtream path markers; no removal during a refresh

- Strip and redact the account segments after `/LIVE/`, `/Movie/` or an
  encoded marker such as `/%6Cive/`, as after `/live/`. The display-name
  check also treats an Xtream path password as a URL secret.
- A source cannot be removed while a switcher refresh clears and refills
  it: the removal service ignores the request, the switcher disables
  Remove, and the Sources page shows the refresh as busy.

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

* fix(sources): search URLs nested in path segments for name secrets

A proxy URL can carry a whole source URL, or a token pair, in one path
segment. The display stripper already cleans such segments; the name
check now collects their secrets too, so a name holding them collapses
to the host.

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

* fix(sources): decode nested URLs before classifying; narrow Xtream paths

- A URL nested in a parameter or path segment is percent-decoded one
  round at a time until it reads as a URL or parameter list, so one
  encoded twice is cleaned too; a value still encoded after the last
  round is dropped. The name check unwraps nested values the same way.
- The two segments after `/live/` and its siblings count as an account
  only when a stream follows them, so a playlist path such as
  `/live/news/list.m3u` keeps its file name in the URL and the title.
- Adding a playlist from the phone drawer closes the drawer first, so
  two focus-trapped modals never stack.

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

* fix(sources): clean URL-shaped fragments; one registry for busy sources

- A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its
  userinfo, an unclassifiable secret is dropped, and the name check reads
  a URL in the fragment as well as a parameter list.
- Every refresh holds its source in the root SourceActivityService, which
  deletes and the startup auto-refresh already use: Sources-row refreshes,
  the switcher's M3U refresh and the shared Xtream flow. The switcher no
  longer offers Refresh or Remove for a source the page is refreshing, the
  removal service ignores a held source, a second refresh from either
  entry point is skipped, and the page disables Remove while it is held.
- The long-input timing spec allows 3 s: the inputs take about 0.25 s,
  a backtracking pattern takes minutes, and a loaded runner took 1.6 s.

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

* fix(sources): keep a browser refresh busy until the backend answers

The browser build's PLAYLIST_UPDATE returned at once while its request
ran on, so the row stayed refreshable and removable and the shared
registry saw the source as idle. The event now settles when the refresh
does (the PWA service resolves after its success or error report; the
Electron fallback awaits its handler), and both the Sources row and the
switcher keep the source busy and held until then.

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

* fix(sources): name check hides values under short account keys

The name check used the log redactor's key list, which leaves out the
short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops.
Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…`
collapses to the host; `user`/`usr` are account names like `username`
and stay shown at the top level, as by design.

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

* fix(sources): read keys, markers and secrets through every encoding layer

Each check decoded its input once, so one more encoding layer slipped a
secret past it: `?%2570assword=…` kept its value, and `/%256Cive/…`
kept its account segments, both on screen and in logs.

`decodeFully` (shared logging) undoes up to four percent-encoding layers
and is now the one form every name check reads: the redactor's key
normaliser and Xtream marker check, the display key predicate and path
check, and the name check's secret values. Logs and the Sources lists
gain the fix together.

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

* fix(sources): read Xtream paths decoded; keep short decoded forms out

- An Xtream account could hide behind encoded separators
  (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount`
  reads the path as decoded parts, maps them back to raw segments, and is
  the one scanner for the log redactor, the on-screen URL and the name
  check. Account segments go whole, joined ones included.
- The name check required a minimum length only of the raw secret, so a
  decoded form as short as one letter (`%2542` is `B`) hid ordinary
  names. Each form must now be long enough on its own.
- The decoding helpers move to `url-credential-parts.ts`, which keeps the
  redactor under the line limit.

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

* fix(sources): read an Xtream path both whole-segment and split

Reading the path only as decoded parts misplaced the account when the
user name itself held an encoded slash (`/live/joe%2Fx/hunter2/1.ts`):
the password segment stayed on screen and in logs. `findXtreamAccount`
now scans both readings, each raw segment decoded whole and the decoded
path split on every separator, and drops what either finds.

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

* fix(sources): drop JSON configs from shown URLs; read their secrets

A nested value such as `?config={"password":"…"}` was neither a URL nor
a parameter list, and the quoted key slipped past the free-text check.

- A nested value that parses as a JSON object or array is dropped from
  the shown URL (JSON can spell a key so no pattern reads it, such as
  `w`); brackets that are not JSON, like `[1080p]`, stay.
- The free-text check accepts a quoted key (`"token": …`).
- The name check parses JSON values and collects what sits under
  sensitive keys, and follows URLs in its other strings.

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

* fix(sources): hold Xtream sources through re-import; match secrets in any case

- An Xtream refresh released its hold when navigation ended, while the
  re-import ran on from the route session, so Remove came back during
  the import's writes. The route session now takes the hold synchronously
  on NavigationEnd and keeps it until its sync, and the import it awaits,
  settle; the store also holds the source for every content
  initialization, retries included.
- Secret matching was case-sensitive against encodeURIComponent's
  upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text
  and secrets are now compared lower-cased, the text also fully decoded.
- The Xtream `· username` label is left out when it is, or holds, the
  password.

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

* fix(sources): read a bracketed key as parameters unless it is JSON

The name check returned early for any value starting with `{` or `[`,
so `[password]=…` (not JSON) skipped the parameter check and its
password stayed in the name. It now returns early only when the value
parses as a JSON object or array.

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

* fix(sources): no opening a source from the switcher mid-refresh

While an Xtream refresh was still deleting the catalog, ⌘P could reopen
the switcher and selecting that source navigated at once, starting its
import before the user data to restore was parked. The refresh flow now
exposes `isRunning` (its set of runs is a signal), and the switcher marks
a refreshing source aria-disabled and ignores its selection by pointer
and Enter alike. Imports alone do not block opening a source.

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

* fix(sources): hand over only to an idle source; hide names that are a short secret

- Removing the active source could hand over to a neighbour a refresh
  was still clearing, opening it and starting its import early.
  `SourceRemovalService.canTakeOver` (not leaving, not deleting, not held
  in SourceActivityService) now picks the replacement in both lists and
  gates it in the service.
- Secrets under three characters were dropped from the check entirely,
  so a name that was just a two-letter password showed it. Short secrets
  now count as the whole text (trimmed, any case); longer ones still
  match inside a text.

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

* fix(sources): offer Refresh only when it can run; drag handle never opens

- PlaylistRefreshActionService runs one refresh at a time, but
  canRefresh checked only the source, so the switcher, the command
  palette and the dashboard offered a Refresh that refresh() then
  ignored. canRefresh now applies the same global guard.
- In Custom order a click on a row's drag handle bubbled to the row and
  opened the source; the handle now stops it.

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

* fix(sources): no idle Refresh on held rows; Recent counts the filtered list

- The Sources page offered Refresh on a source the startup auto-refresh,
  an import or a delete held, and refresh() then refused it. The row now
  offers Refresh only while it can run, keeping it (with its spinner)
  while that source refreshes.
- The switcher showed Recent whenever the library had six sources, so a
  type filter down to one or two listed them twice and the keyboard
  visited each twice. Recent now depends on the filtered count.

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

* fix(sources): compare passwords in their own case; only a MAC caselessly

Matching every secret lower-cased hid any name holding the password word
in another case: the Electron E2E source "Stress Xtream Portal" (password
`stress`) showed its host, and the deletion test could not find its row.
A password is case-sensitive, so it is compared as written; the text is
still read fully decoded, so `%3a` escapes count as `:`. A MAC is the
same address in either letter case and is compared lower-cased.

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

* fix(sources): match a MAC from the URL in any letter case

Only the source's own `macAddress` was compared caselessly, so an M3U
URL carrying `?mac=…` (nested ones too) left the MAC visible in a name
written in another case. Any MAC-shaped secret, and every form of the
source's own MAC, is now compared lower-cased; passwords keep their case.

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

* perf(sources): keep source-health helpers off the startup path

Master's #1931 lowered the initial-bytes baseline, and this branch then
sat 535 bytes over its slack. Most of its growth was runtime helpers in
`@iptvnator/shared/interfaces` (reachability, health history, reason and
fingerprint helpers) that only the lazily loaded source services call:
the bundler put them in a chunk the app preloads. They now live in
`@iptvnator/portal/shared/data-access` beside those services (types stay
in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:06:23 +02:00
ec4b9286a7 perf(web): provide form-field defaults per component, not at the root (#1928)
* perf(web): provide form-field defaults per component, not at the root

app.config.ts provided MAT_FORM_FIELD_DEFAULT_OPTIONS in the root
injector. Importing the token kept @angular/material/form-field in
main.js, and Material 22.2's form field imports @angular/forms/signals,
which would add @angular/forms to startup as well (the reason the
Angular 22.2 dependency update exceeds the initial-bytes ratchet). Every
form field renders in a lazy chunk, so the outline + dynamic defaults
now come from provideFormFieldDefaults()
(@iptvnator/ui/components/form-field-defaults) on each component that
renders <mat-form-field>.

tools/nx/check-form-field-defaults.mjs (ui:form-field-defaults:validate,
run in CI) reports a component that renders a form field without the
provider, and any use of the token outside the shared provider. The
Xtream import form test asserts outlined, dynamically sized fields with
no root provider; without the component provider they render as fill.

renderer.initialBytes: 1,596,201 -> 1,534,604 bytes (-61,597) on a local
production build; the baseline is lowered to the measured value.

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

* perf(web): cite #1928 as the initial-bytes baseline evidence

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

* test(ui): check form-field providers per component on the TypeScript AST

The guard matched templates and providers with regular expressions over
the whole file: a quoted inline template went unread, one component's
provider covered another in the same file, an earlier array-valued
provider hid the call, and provideFormFieldDefaults() in app.config or a
route passed although it loads Material's form field on startup again.
It now reads each @Component decorator's own template and providers
array, and reports any provideFormFieldDefaults() call outside one.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:53:08 +02:00
b546fe0123 fix(m3u): keep groups hidden in Manage groups after a restart (#1894) (#1907)
* fix(m3u): keep groups hidden in Manage groups after a restart (#1894)

Electron fills the playlist store from DB_GET_APP_PLAYLIST_METAS, which
reads the metadata columns only. hiddenGroupTitles lives only in the JSON
payload, so after a restart the store had none and the groups rail showed
every group, although SQLite still held the saved selection; saving the
dialog from that state then overwrote it.

M3uWorkspaceRouteSession already reads the full playlist to publish its
channels and favorites. It now copies hiddenGroupTitles from that read into
the store (updatePlaylistMeta, persist: false) before the channels, unless
the store value changed while the read was queued behind a save.

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

* fix(m3u): wait for the playlist entity before hydrating hidden groups

The startup inventory publishes playlists only after settings and EPG
reconciliation, while a restored or deep-linked playlist route waits for
the metadata read alone. The route session's full read could therefore
finish first, and updateOne dropped the hydration for the missing entity
for the whole session. The hydration now waits for the entity.

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

* test(m3u): cover hidden groups when the Groups route opens before the inventory

An Electron E2E reloads the renderer on the Groups route while the startup
inventory is held (the EPG reconcile fails once and its retry is parked),
and records from document start whether a hidden group is ever rendered
visibly. It fails on the previous hydration that did not wait for the
playlist entity. A service test pins that getPlaylist reads hidden groups
from a meta update that is still being written.

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

* test(m3u): type empty hidden-group arrays in the route session spec

The CI spec typecheck (apps/web/tsconfig.spec.json) rejected `[]` inferred
as never[] inside the PlaylistMeta and Playlist casts with TS2352.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:06:39 +02:00
4grayandClaude Opus 5.5 43358e10f2 fix(ui): welcome screens and drop overlay follow the app theme (#1886)
* fix(ui): welcome screens and drop overlay follow the app theme

The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).

White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.

Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.

New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.

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

* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read

The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.

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

* test(e2e): measure welcome text contrast from rendered pixels

The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.

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

* fix(tools): scan only runtime sources in the theme-references guard

The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.

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

* fix(xtream): set live paginator tokens through mat.paginator-overrides

The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.

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

* fix(tools): skip every test-only naming convention in the theme guard

The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.

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

* fix(ui): keep welcome card text legible while hovered

At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:22:23 +02:00
4grayandClaude Opus 5.5 c76cf357b7 refactor(workspace): remove standalone-layout branches no route can reach (#1889)
* refactor(workspace): remove standalone-layout branches no route can reach

Every routed view sits under the /workspace route (the other top-level
paths are redirects), so isWorkspaceLayoutRoute() was true for every
caller. Global search became a route on 2026-01-12 and the last route
outside the workspace went with the home component on 2026-03-02;
nothing opens SearchResultsComponent in a MatDialog.

Removed as unreachable:
- isWorkspaceLayoutRoute() and the route's data.layout marker; the
  channel list, Xtream live layout and unified collection read the q
  query param directly, the clear-view command no longer checks a flag
  that was always true, and the M3U EPG guide header action keeps only
  its supportsEpg gate.
- The playback sidebar's playlist header (Home button and playlist
  switcher, showPlaylistHeader, its stylesheet and the macOS padding
  rule). The switcher itself stays: the workspace header uses it.
- Xtream search's dialog mode (MAT_DIALOG_DATA initialQuery, dialogRef,
  close buttons) and inline search input; Back is offered whenever the
  search is not the global one, as before.
- Stalker search's inline-input flag (Back is always offered, as before).
- SearchLayoutComponent's showSearchInput, showCloseButton, closeClick,
  searchTermChange and focusSearchInput, and SearchFormComponent, which
  only it rendered.
- SETTINGS.BACK_TO_HOME and PORTALS.SEARCH in all 19 locales, the
  zoneless checklist entry for the deleted form, the specs that only
  covered these paths, and two Electron E2E assertions that the deleted
  form is absent.

Behaviour of every reachable path is unchanged.

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

* refactor(ui): drop the macOS host class and switcher rule nothing uses

The app root's `macos-platform` host class only fed the removed sidebar
header's padding rule, so no stylesheet reads it any more. The shared
portal-sidebar partial's `.sidebar-header .switcher` rule matched no
element: the only `switcher` class lived in that removed header, which
used `.current-playlist`, not `.sidebar-header`.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:07:57 +02:00
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* fix(ui): one focus ring colour everywhere via the shared mixin

#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.

- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
  offset; the projects that newly import `libs/ui/styles` declare
  `ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
  themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
  validate`: an outline in a focus rule must use the token or, over
  video, the player's `--pc-*` palette. Three deliberate exceptions are
  listed with their reasons, and a stale one is reported. On master it
  reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
  where it is drawn.

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

* fix(ui): search fields and shadow rings take the focus colour too; guard reads them

Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.

- Every one now uses `--app-focus-ring` (the halos and tints keep their
  strength through `color-mix()`); over video the panel's search border
  mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
  shadows and its border colours. Neutral boundaries (separator and
  widget-border tokens, transparent, currentColor) and blurred lift
  shadows are not indicators. On the previous commit it reports these 21
  declarations.

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

* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring

Greptile on #1882 (2×P2):

- The guard joined a declaration's colours and accepted the lot when the
  token appeared anywhere, so `border-color: var(--app-focus-ring) red`
  or a `color-mix()` of the token and red passed. It now splits each
  declaration into plain colours, every `color-mix()` argument included,
  and checks each one: the token, the player palette or a neutral
  boundary. An outline or shadow ring without a colour is drawn in the
  text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
  declaration (the diagnostic's amber is also a text colour), so a stale
  one was never reported. It now counts only where it excuses an
  off-token focus indicator.

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

* fix(settings): the row a search result reveals takes the app focus ring

Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.

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

* fix(tooling): accept focus colour tokens by exact name

Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.

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

* fix(tooling): check the body of a focus mixin, the shared ring included

Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.

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

* fix(tooling): read logical border sides in the focus colour guard

Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.

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

* fix(tooling): an exception excuses its own colour, not the declaration

Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.

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

* fix(tooling): tell a var() or calc() width from the ring colour

Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.

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

* fix(tooling): read a shadow ring whose spread is computed

Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.

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

* fix(tooling): read shadow lengths by type, with the colour first or no blur

Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.

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

* fix(tooling): a negated focus condition does not make a focus rule

Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.

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

* fix(tooling): read a width in any CSS length unit

Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.

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

* fix(ui): the open-in-playlist chip rings in the app focus colour

Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.

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

* refactor(ui): move the open-in-playlist chip styles into a stylesheet

Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:40:08 +02:00
4grayandClaude Opus 5.5 ea7efdbd9b fix(workspace): open a source row from the keyboard (#1885)
A Sources row opened only on a pointer click: the row div was not
focusable and had no key handler, so a keyboard user could Tab to its
actions but never open the playlist.

Following app-content-card, the row's title and meta block becomes the
keyboard activation element: role="button", tabindex="0", named by the
source title, aria-current on the active source, aria-disabled while
busy. Enter and Space open the source (Space prevents the page scroll).
The drag handle, health indicator and actions stay its siblings, so no
control is nested inside a role="button" and their keys never bubble
into it. A click anywhere on the row still opens it via the row.

The row draws the ring with the shared focus-ring mixin, inset, because
the list's scroller would cut a ring drawn outside the full-width row.
playlist-shared-ui now declares its ui-styles stylesheet dependency.

Tests: unit spec for Enter/Space, bubbling from an action button, busy
and selected states; Electron E2E tabs to a source row, checks the ring
and opens it with Enter (fails on the previous component).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:15:04 +02:00
4grayandClaude Opus 5.5 4281194cb4 fix(a11y): accessible names for icon-only buttons and a guard (#1880)
* fix(a11y): accessible names for icon-only buttons and a guard

Icon-only buttons named only by a matTooltip (or by nothing) had no
accessible name: the icon ligature is hidden from assistive technology
and a tooltip only adds a description. 22 buttons on master, in .html
and inline templates, now carry a translated aria-label bound to their
tooltip key. The channel row's favorite star keeps one label
("Favorite") and reports its state through aria-pressed.

New guard `pnpm run a11y:icon-buttons:validate` (CI step) fails on a
<button> whose only content is mat-icons (through control flow,
ng-container and spinners) without aria-label, an aria-label binding
or aria-labelledby. The inline-template lookup moves to a shared
tools/nx/inline-templates.mjs used by the icon-ligature guard too.

web-e2e icon-button-names.e2e.ts runs axe's button-name rule on a
channel list, the channel details dialog, Sources, the playlist info
dialog and an Xtream search. Five new keys are translated in all 18
locales.

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

* fix(a11y): text hidden from assistive technology never names an icon button

Greptile: a static aria-hidden="true" child's text counted as the
button's name, so <button><mat-icon/><span aria-hidden="true">Close</span>
passed the guard. Hidden subtrees now add only their icons.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:34:04 +02:00
4grayandClaude Opus 5.5 e1d4f00d93 fix(i18n): follow runtime language switches in stored and memoized labels (#1872)
* fix(i18n): follow runtime language switches in stored and memoized labels

A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.

Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.

Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.

Documents the rule in the UI guidelines and the Stalker store contract.

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

* docs(stalker): record getSelectedCategoryLabel in the store API baseline

getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.

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

* fix(i18n): one translation tick that also follows late dictionaries

Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.

`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.

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

* test(i18n): stub the Xtream selection the merged search scope reads

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

* fix(i18n): series view reads the shared translation tick

#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.

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

* refactor(i18n): move injectTranslationTick to @iptvnator/services

The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:31:38 +02:00
4grayandClaude Opus 5.5 2b400cb81d fix(i18n): translate the remaining hard-coded labels (#1871)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

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

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

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

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

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

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

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

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

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

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

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

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

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

* fix(i18n): translate the remaining hard-coded labels

Follow-up to the UI-26 pass. The remaining English UI literals now come
from translation keys, translated in all 18 locales:

- Windows/Linux window controls, playlist switcher title and actions
  menu, the portal status tooltip, dashboard carousel roles and rail
  scroll buttons, the search placeholder, the card remove tooltip, the
  EPG offset unit, the REC chip, the Stalker EPG source label and the
  export file type.
- Embedded MPV failures raised in the renderer and the release-notes
  dialog without a bridge. Settings never showed its English reasons,
  so they are dropped.
- Unnamed Stalker episodes: data access leaves the title empty and the
  series view labels it after the TMDB overlay. Synthetic season names
  stay, because they key persisted episode progress.
- The phone remote-control page, which follows the first browser
  language with a translation and sets <html lang>.

Removes the dead getStatusMessage(), the unused favorites layout and the
translateWithFallback() helpers whose keys now exist.

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

* fix(remote-control): keep the remote build off shared-interfaces

The language resolver imported the Language enum, which made
remote-control-web depend on shared-interfaces. Its build-performance
chain then hit Nx's recursive-invocation guard through the existing
shared-interfaces/shared-logging build loop, failing the Electron E2E
and performance journey builds. The resolver now keeps its own list of
translation codes, and a spec checks it against the locale files the page
loads.

Also drops the deleted favorites layout from the zoneless checklist,
whose guard spec requires ticked entries to exist.

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

* perf(journeys): raise the launch DOM mutation baseline for translated attributes

Launch now sets 569 DOM mutations before the first dashboard card instead
of 557 (identical in all three CI iterations). The extra twelve come from
attributes this PR moved from static English to translated bindings on
the launch path: the window-control labels and tooltips on Linux, the
hero carousel and slide roles, and the rail scroll-button labels. A
translated attribute is a binding set after the element is attached, so
each costs a mutation. Accepted as a deliberate trade-off; it needs the
perf-baseline-increase label.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:22:31 +02:00
4grayandClaude Opus 5.5 6e18983400 fix(i18n): translate hard-coded labels, snackbars and missing keys (#1867)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

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

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

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

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

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

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

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

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

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

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

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

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:02:21 +02:00
4grayandClaude Opus 5.5 932876fbd9 fix(import): no bogus Stalker expiry notice for unlimited accounts (#1851)
* docs(website): say the Stalker validated notice needs an expiry date

The import shows "Portal validated" only when the portal profile
carries expire_date; an account without a fixed expiry is added
silently. The guide described the notice as unconditional (Codex
review on #1808).

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

* fix(import): read the Stalker import expiry through parseStalkerDate

The validated-import snackbar multiplied the raw account_info.expire_date
by 1000. Portals encode an unlimited account as -1, "0" or a zero date,
which announced a 1969/1970 expiry, and a date-string expiry rendered as
"Invalid Date". Every other consumer already reads the value through
parseStalkerDate; the import now does too, so the guide's "no notice
without a fixed expiry" holds (Codex review on #1851).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:02:43 +02:00
0a6f54ca15 perf(playlist): make the playlist import and shared UI components OnPush (#1820)
* perf(playlist): make the playlist import and shared UI components OnPush

Plan item C6 step 3 for libs/playlist (import/feature and shared/ui): the
twelve Eager components switch to OnPush. Two of them rendered plain fields
written after an await, outside any template event, which only an Eager
check on the next zone tick picked up:

- playlist-item's portal status dot (PWA, after the async portal check)
  now reads a signal;
- playlist-info's playlist is backed by a signal behind its existing
  getter/setter name, so the EPG source list follows removals and file
  picks that land after awaited cleanup and dialogs.

A regression test for each fails on OnPush with the plain field and passes
with the signal. The Stalker import's post-await patchValue needs no change
(see the zoneless checklist). The m3u feature-player components stay Eager
for the playback PR.

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

* test(playlist): check the OnPush dialogs without forcing a render

Review follow-ups (Greptile, Codex):

- The portal-status and EPG-row tests forced detectChanges() after their
  await, so they passed with plain fields. They now let the fixture render
  on its own; with portalStatus back on a plain field the status test fails.
- New: the playlist info dialog enables Save and shows the path after a
  native EPG file pick, without a forced render. pristine and valid read
  the form's state signals, so the OnPush dialog follows on its own.
- New render spec for the add-playlist dialog with the real URL form: Add
  enables after typing and after a patch from outside the child (as an
  auto-detect prefill does).

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:11:16 +02:00
3cc5af1492 perf(playback): make the web players and M3U player OnPush (#1822)
* perf(playback): make the web players and M3U player OnPush

Plan item C6 step 3 for playback: the eight Eager components in
libs/ui/playback (video.js, ArtPlayer, HTML5/hls/mpegts, audio player,
web player view, VOD details, sidebar, external-player dialog) and the M3U
video player and VOD detail switch to OnPush. The player libraries' events
already reach the UI through the signal-backed controls adapter or
outputs, and the players' DOM belongs to the libraries.

The M3U video player rendered three plain fields written outside template
events: the channel-number overlay, cleared by a 2 s debounce timer, and
the player choice, written from an IndexedDB read and a settings effect.
They are signals now, and a test checks that the overlay leaves the DOM
when the timer fires.

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

* test(playback): let the overlay's signal write schedule its own render

Review follow-up (Greptile): the test forced a render with
fixture.detectChanges() after the debounce timer, so it would pass even
if the signal write stopped scheduling an OnPush render. It now runs the
fixture with autoDetectChanges and only advances the fake timers; with
plain fields under OnPush the overlay never renders and the test fails.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:12:52 +02:00
4gray ab8460338a feat(ui): cinematic movie and series details pages and dashboard hero (#1792) 2026-10-03 23:08:16 +02:00
4grayandClaude Opus 5.5 a8dd1eaa97 fix(import): consistent add-source forms with masked passwords and URL errors (#1796)
* fix(import): consistent add-source forms with masked passwords and URL errors

- Mask the Xtream password in add and edit (and the Stalker one in edit)
  behind a shared PasswordVisibilityToggleDirective: one translated
  "Show password" label, state in aria-pressed, type="button".
- Give the Xtream server URL its own mat-error and a neutral hint instead
  of the EPG file error; give the M3U URL a mat-error.
- Use "Playlist title" in every add form, "MAC address" casing, a single
  ellipsis in "Validating portal…" and one "Add playlist" submit label;
  translate the method radiogroup's aria-label.
- Show Stalker refusals inline under the portal URL (role="status", like
  the Xtream connection test), translated in the template and cleared by
  edits; translate the snackbars for outcomes that close the dialog.
- Translate new strings into all locales; reuse the identical Stalker URL
  error translations; fix MAC casing and ellipses; drop unused keys.
- Unit specs per form, edit-dialog spec, new add-source-forms web E2E;
  update E2E locators; UI guidelines Forms section; Stalker contract.

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

* fix(import): mask the password again when an add form is cleared

Clear erased the password but left the visibility toggle on, so the next
password typed in the Xtream or Stalker form showed in plain text.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:13:54 +02:00
4grayandClaude Opus 5.5 23a1860119 fix(ui): destructive confirmations, verb labels and provider icons (#1783)
* fix(ui): destructive confirmations, verb labels and provider icons

Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".

The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.

Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.

The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.

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

* fix(ui): one provider icon per playlist row, imperative Korean remove label

A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.

HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.

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

* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon

Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.

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

* fix(ui): let the playlist row's cancel action render in the error color

The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.

The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.

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

* test(ui): give the dialog service spec the now-required confirm labels

ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:36:16 +02:00
4gray 572034f3be fix(ui): declare Material system tokens and migrate dead --mdc overrides (#1775) 2026-10-01 18:02:50 +02:00
4gray f38c6f86d1 feat(playback): draw catch-up programmes as seek-bar segments (#1750) 2026-09-29 19:34:38 +02:00
d8229b98fa feat(playback): record recently viewed only after the stream plays (#1732)
* feat(playback): record recently viewed only after the stream plays

A channel, movie or episode used to enter Recently Viewed (and the
dashboard's Continue Watching hero) the moment it was selected or its
link was resolved, so streams that failed straight away cluttered the
history.

Writers now defer the write to a root PlaybackHistoryGate, keyed by the
stream URL and/or the playback session key. The inline players confirm
those keys once the owned engine's position has advanced by two seconds
(seeks, stalls, pauses and a previous stream's progress do not count),
the radio player does the same, and a launched MPV/VLC session confirms
on `opened`/`playing`. M3U with MPV/VLC configured keeps recording on
selection. Covers M3U (live, radio, movie detail), Stalker (live, radio,
VOD, series), Xtream VOD and series, and the global live collection.

The M3U host's embeddedPlayback is now compared by value: the history
write updates the playlist meta mid-playback, and a new but identical
playback object remounted the engine and restarted the stream.

E2E flows that relied on recording-on-click now play local fixtures
(HLS/TS/WebM routed in place of unreachable or public streams) and wait
for confirmed playback.

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

* fix(playback): tighten recently viewed confirmation per review

- Correlate by session key first: when both the deferred write and the
  confirmation carry a playbackSessionKey, only that is compared, so the
  same stream URL played in another playlist no longer records a failed
  attempt. URLs remain the fallback (portal writes, MPV/VLC sessions).
  The M3U radio player now receives the host's session key.
- Count only playing progress: engines report `playing` (not paused, not
  seeking) with each time update, so short seeks of paused media no
  longer confirm a view.
- Xtream: a write confirmed after a playlist switch still saves to its own
  playlist but no longer replaces the current playlist's recent list.
- Global live tab: a row confirmed after another row was selected still
  moves to the top of an open Recently Viewed list (only a disposed tab
  skips the notification).

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

* fix(playback): confirm session-keyed history only by its own session

A write deferred with a playback session key (M3U) is now confirmed only
by that key. The app-wide MPV/VLC session confirmation carries just the
URL, so opening the same stream externally from another playlist could
still commit an abandoned attempt. An "Open in MPV/VLC" recovery launch is
instead confirmed by the WebPlayerViewComponent that requested it, under
its own session key, once the launch has opened.

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

* perf(playback): keep the history gate off the initial bundle

The `@iptvnator/services` barrel ships in the initial bundle, so adding
PlaybackHistoryGate there (and subscribing to it from the app-wide
ExternalPlaybackService) grew renderer.initialBytes by 1,141 bytes.

- Move the gate to a new lazy-only `playback-data-access` project
  (`@iptvnator/playback/data-access`; scope:shared, domain:playback,
  type:data-access) and register it in the coverage policy.
- The gate subscribes to MPV/VLC session updates itself; it is created by
  the first deferred write, which precedes the launch it waits for.
  ExternalPlaybackService is back to master.
- The Xtream "playlist switched before confirmation" check moves to the
  lazy helper; the initial-path store only takes a `skipListRefresh` flag.

Net effect on this branch: +27 bytes over master (master itself is
108 bytes over the ratchet baseline already).

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

* perf(playback): drop late Xtream confirmations off the initial path

The Xtream store ships in the initial bundle, so even the small
`skipListRefresh` flag cost 27 bytes there. A confirmation can only
arrive after a switch to another playlist from a slow MPV/VLC launch
(the inline player goes with the page), so the lazy helper now drops it
instead: recording it would misfile the item or replace the other
playlist's recent list. with-recent-items is back to master.

This branch is now 3 bytes below master on renderer.initialBytes; the
ratchet still reports master's pre-existing overage.

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

* test(playback): pass the spec type-check gate from master

- playback-data-access: align tsconfig.spec.json with the epg-data-access
  config #1705 updated (bundler resolution, global.d.ts for window.electron).
- M3U recent-history spec: type the selectSignal override.

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

* fix(playback): keep late Xtream confirmations in their own playlist history

A confirmation that arrives after a switch to another playlist (a slow
MPV/VLC launch) is no longer dropped: the lazy helper saves it to the
captured playlist through the data source, without reloading the store's
recent list, which belongs to the other playlist by then. The store and its
barrel ship in the initial bundle, so the save path stays in the feature
helper; renderer.initialBytes stays under the baseline.

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

* fix(playback): correlate global live-tab history by its session key

The unified Favorites/Recent live tab deferred its history write by stream
URL only, so the same URL played from another playlist could confirm a
failed selection, and a switch to catch-up before confirmation could never
match. It now defers with the tab's playlist-scoped playbackSessionKey (the
key its players confirm with), and the tab's radio player receives it too.

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

* fix(playback): keep MPV/VLC rows of the live tab confirmable by URL

The live tab's session key can only be confirmed by its own inline
players; MPV/VLC confirm the launched URL alone. A row that goes to an
external player (also later, after a double-click) now defers by URL, and
only rows played inline carry the session key.

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

* fix(playback): per-channel M3U history attempts, capability-based Xtream fallback

- M3U: the recently-viewed dedupe key now includes the channel id, so a
  second row of the same URL defers its own write (its session key) and
  is recorded when it plays after the first row failed.
- Xtream late write: key uncached content by Xtream id per
  supportsXtreamSqliteDataSource (the data-source factory's contract), not
  by a generic Electron bridge.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:23:06 +02:00
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

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

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

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

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

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

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

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

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00
e45cd85a78 feat(settings): PIN-protected parental lock for categories (#285) (#1601)
* feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

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

* fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

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

* fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

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

* fix(settings): compile the PIN hashing helper in the Node backend build

The web backend compiles the shared interfaces library without DOM typings,
so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker
build. The helper now describes the WebCrypto surface it needs structurally
and reaches it through `globalThis`.

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

* fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

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

* fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor

- The Xtream detail guard hydrates the PWA session cache before judging an
  item on a cold navigation and fails closed when the catalog cannot place
  the item.
- The dedicated Stalker search route filters withheld genres, re-fires on
  lock changes, judges paging on the raw page and restarts from page 1 on a
  lock flip.
- The Xtream category dialog loads its lock candidates through the
  capability-selected data source; the PWA source now lists its raw
  categories with lock flags, so locks can be configured there too.

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

* fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

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

* fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

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

* fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

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

* fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

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

* fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store

- A Stalker search page issued before a relock was filtered with the
  pre-relock withheld set and could still be applied after it; the
  staleness check now includes the parental lock version.
- A lock store payload that does not parse or is not an object is a
  failed read (everything withheld until it reads again), no longer an
  empty store.

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

* fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

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

* test(xtream): move the parental-lock reload specs beside the content spec

The content feature spec sits at the 1200-line spec cap.

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

* fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

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

* fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

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

* fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

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

* fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" on restore.

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

* fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

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

* fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration

- The Xtream category guard no longer runs the item check on category-only
  routes (Number(null) is 0), which prompted for the PIN on every unlocked
  VOD and series category while the lock was active.
- A lock change during the initial Xtream hydration withholds the rows the
  hydration publishes and runs the filtered reload once it has settled,
  on every path that marks the content initialized.

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

* fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

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

* fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload

The Xtream store stays populated after leaving that portal, so its reload
runs on every apply; a locked M3U channel no longer keeps playing behind a
slow database or provider read.

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

* fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries

- The workspace route resolver awaits ParentalLockService.initialize()
  next to the settings load, so no route or catalog activates before the
  PIN and lock store are known.
- Every lock write re-reads a failed store before building its edit, so a
  recovered store is edited rather than overwritten.
- The deferred hydration reload runs under the publish guard of the
  request that deferred it.
- Backup import validates every parental lock entry and rejects a damaged
  list instead of erasing the persisted locks on restore.

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

* fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

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

* fix(settings): gate the Electron cached category/content reads while locks are unknown

The warm-route hydration reads the cache directly; it now returns nothing
while the lock store withholds everything, like the live reads.

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

* fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps

- clearSearchResults() advances the search request version, so a search
  issued under the previous lock state cannot republish what a relock
  just cleared.
- A lock write publishes its store revision only once every touched type
  is stamped, so a reload triggered by it cannot read a later type
  through its old stamps.

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

* fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

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

* feat(settings): one lock entry point per rail plus a right-click Lock/Unlock

- Stalker's dedicated lock button becomes the same "Manage categories"
  (tune) button the Xtream rail has; it opens the lock-only dialog, so
  every portal type shares one entry point and the rail header keeps
  three actions.
- Right-clicking a category (Xtream, Stalker) or an M3U group offers a
  single-row Lock / Unlock through the shared CategoryLockMenuComponent,
  behind the same PIN gate and lock store as the dialog.
- The settings hint explains where locks are set; group lock strings added
  to all locales (ru/de translated).

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

* fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

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

* fix(settings): apply single-row lock toggles inside the lock store's write queue

The right-click Lock/Unlock (portal categories and M3U groups) built the
new list before entering the queue, so two quick toggles shared one
snapshot and the second dropped the first. Lock writes now accept an edit
of the current list, evaluated inside the queue.

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

* fix(settings): retry a failed settings read before any parental-lock settings write

updateSettings writes the whole settings object, which after a failed
startup read is the defaults; enabling the lock or changing the relock
timeout then replaced the user's persisted preferences. The read is
retried first and the write refused while settings stay unreadable. The
settings writes move to parental-lock-settings-writer.ts.

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

* fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value

- The M3U groups rail now renders the same "N locked · Enter PIN to show"
  row as the portal category rail, so locked groups no longer vanish
  without an in-context unlock.
- A failed relock-timeout write rolls back to the value read after the
  settings retry, not to the pre-retry default.

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

* fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

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

* fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

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

* fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

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

* perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

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

* fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then fails, the index is re-stamped from the
  previous locks at once. Title matching and multi-source discovery query
  the worker directly and trust the index, so the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

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

* fix(settings): keep the M3U groups view when every group is locked

With every group locked the filtered channel list is empty, so the
container showed its generic empty state and the groups rail's
"N locked · Enter PIN to show" row never appeared.

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

* fix(settings): fail closed when the lazy Stalker enforcement step cannot load

A rejected chunk (e.g. a stale PWA page after a deployment) escaped
applyStalker(), so a locked Stalker selection kept playing after a relock
and the Xtream step was skipped. The step now leaves the Stalker route on
a load failure, which clears the selection and stops playback, and the
Xtream step still runs.

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

* fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld

On Electron a relock that overtook the initial content hydration waited
for the category reload before the content reload set the deferral flag;
the older unlocked hydration could publish its streams in that window.
withholdCatalog() now sets the flag itself, before anything is awaited.

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

* fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

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

* fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

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

* fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

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

* fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

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

* fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

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

* fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands

Lock edits that take a lock away now commit only while the session is
unlocked, checked inside the write queue at commit time, so an editor
save still in flight (or queued) when the app relocks cannot remove
locks. Adding locks stays allowed. The Xtream category dialog drops its
lock draft after a relock, and a backup restore re-asks the PIN only
when it would remove a lock. Clearing a playlist's last lock keeps its
index stale until the store write has landed.

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

* fix(settings): clear an Xtream detail from a hidden category synchronously on relock

The synchronous relock step now clears a selected Xtream item whose
category the visible category list cannot place (a manually hidden
category opened through search), instead of leaving it usable until the
awaited reloads and lookup finish.

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

* fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

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

* fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

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

* fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile

When writing the previous store back after a relock-refused removal
fails, the lock store now keeps a pending rewrite, is not readable (the
locked session withholds everything) and rewrites the persisted store
from memory on the next access, so a restart cannot load the removal.
A failed "Remove all playlists" clear shares the same retry.

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

* fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode

A lock removal is now authorized right before its first write is issued;
a relock that lands after that is ordered after the write, which
completes. This drops the post-write rollback, whose own failure could
leave the persisted store diverged from memory across a restart. The
Stalker search closes a detail without a genre on relock while every
category is withheld.

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

* fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

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

* test(stalker): cover page-one rows leaving the screen on relock while the reload hangs

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

* fix(settings): offer the portal unlock row in fail-closed mode

When the lock store cannot be read every portal category is withheld
but no locked ids are known, so the rail showed no "Enter PIN to show"
row. It now shows the row without a count in that state.

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

* fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN

Catalog title matching and multi-source discovery query the SQLite
worker directly; they now return nothing while the parental lock
withholds everything (unreadable store or a bridge without the worker
filter). The Stalker lock dialog captures its playlist, provider and
section before the PIN prompt and re-checks them after it.

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

* fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

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

* fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

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

* fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist

A locked session can no longer change the relock timeout: the Settings
selector is disabled until the PIN is entered and the service refuses
the change while locked. An M3U right-click lock toggle now captures its
playlist before the PIN prompt and is saved only if that playlist is
still open.

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

* fix(settings): reset a locked M3U channel however it becomes active

The enforcement service now checks the active M3U channel whenever it
changes while locked, so numeric zapping, next/previous and remote
commands, which select from the full channel list, cannot start a
channel of a locked group. Numeric zapping also skips such a channel.

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

* fix(settings): bind the M3U group management result to its playlist

The groups view captures the playlist before the PIN prompt and drops
the management dialog's hidden and locked group lists once another
playlist is open, so they cannot be saved under that playlist.

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

* docs(parental-lock): record the accepted restart case of a failed rollback write

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

* fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

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

* fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value

The Settings switch snaps back to the saved state when clicked and
follows it once the PIN action succeeds, so a cancelled or refused PIN
no longer leaves it showing the opposite state. A failed switch write is
undone to the value read after the settings retry instead of the
hard-coded inverse.

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

* fix(settings): match noncanonical PWA Xtream category ids against their locks

The PWA data source compared raw provider category ids such as "009"
with locks stored as numbers, so such a category stayed visible while
locked. Both sides are now compared in canonical numeric form.

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

* fix(settings): close the M3U group editor on any relock

The group management dialog lists every group name, locked ones
included, even when it opened without lock toggles (unreadable lock
store). It now closes on any relock, and the groups view re-checks the
lock state before opening it.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 16:38:34 +02:00
4grayandClaude Fable 5.1 8ebb7e3424 perf(ci): run Tier A coverage concurrently with isolatedModules ts-jest (#1701)
Tier A coverage runs projects a few at a time (largest first, bounded Jest workers, buffered output, fail-fast kept) and ts-jest transpiles with isolatedModules instead of type-checking per process; five type re-exports become export type, two decorated inputs use import type. Unit Tests and Typechecks job: 26 min -> 9 min (Tier A step 23 min -> 6.5 min).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:05:26 +02:00
b30c783e85 fix(search): locale-invariant Turkish case folding in every search path (#1640)
Turkish upper and lower case queries now return the same results everywhere a
title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a
combining dot behind, so "İnş" and "inş" reached different search arms and
different results.

- Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`,
  which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı".
- The Electron content search composes to NFC and drops the leftover combining
  marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell
  the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII.
- A shared `foldSearchText` covers every in-memory filter: channel lists, the
  Xtream and Stalker catalogs, category filters, collections, the EPG guide, the
  command palette, sources, the playlist switcher and the download lists.
- Composing before the strip keeps canonically equivalent spellings equal while
  the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left
  alone, as the FTS index does not fold it either.

Covered by a SQLite-backed spec over the real trigram index plus regression
cases in the affected renderer specs.

Closes #609.

Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:14:02 +02:00
4gray e9eca1c386 chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2

* fix(deps): complete Angular migrations after rebasing on master

* fix(ci): use the Node pin for Windows runtime refresh

* docs(deps): synchronize the workspace-shell Node requirements
2026-09-14 19:02:40 +02:00
d438f5c655 feat(epg): accept local XMLTV files as EPG sources (#1600)
* feat(epg): accept local XMLTV files as EPG sources

Settings → EPG and the playlist dialog accepted `file://` in their form
pattern, but the main process rejected everything except http(s), so a local
XMLTV entry saved fine and then failed on import. Both surfaces now take a
remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC
path (`classifyEpgSourceReference` in shared/interfaces), and the settings
section spells out the accepted formats with examples.

The EPG worker opens every source through `openEpgSourceStream`: remote
links keep the validated-redirect client and trust policy, local files are
read from disk behind the signature-sniffing optional gunzip stage, so
.xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources
may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U
headers, since the local branch bypasses `validateRemoteUrl`.

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

* feat(epg): pick local XMLTV files with a native file dialog

A folder button beside each EPG source row (Settings → EPG and the playlist
dialog) opens the native open-file dialog and writes the chosen absolute
path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind
`ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by
`RuntimeCapabilitiesService.supportsEpgFilePicker`.

The row's refresh/remove buttons carry `data-test-id`s now, and the EPG
e2e suites address them by id instead of index, since the folder button
became the first button in a row.

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

* fix(epg): authorize local XMLTV files in the main process

Review follow-up (Greptile P1, Codex P1). The renderer hands source strings
to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could
not enforce the provenance rule: a compromised renderer, or a legacy
`file://` entry an older version stored from an M3U header, could name any
file on disk.

`EpgWorkerService.startFetch` now asks a main-process
`EpgLocalSourceAuthorizer` before a local path reaches the worker: a path
the native picker returned is trusted at once, a hand-typed path is
confirmed once in a native message box the renderer cannot fake, and a
refusal is reported in the progress panel. Allowed paths persist under
TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its
local branch only when main set `allowLocalFile`; the service defaults to
deny-all until epg.events installs the persisted authorizer.
`resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a
stored non-remote entry unless it is also in `manualEpgUrls`.

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

* fix(epg): fail a refused local EPG fetch instead of resolving it

Review follow-up (Codex P2). A denied native confirmation now rejects the
fetch after reporting the error row, so handleFetchEpg and the renderer's
fetch result cannot claim the file was read. Also restores the unrelated
CLAUDE.md paragraph an earlier formatter pass had reflowed into a list.

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

* fix(epg): cancel a local source retired during its authorization prompt

Review follow-up (Codex P2). startFetch keeps the request generation
captured before awaiting the native confirmation and rechecks it
afterwards: a source retired meanwhile ends as cancelled instead of
starting an import that a pending clear would then have to await.

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

* test(epg): leave the local XMLTV e2e with a pristine settings form

The local-file test ended with the EPG source field still dirty, which
arms the main-process close guard: the app then waited for the unsaved
changes dialog instead of closing, the close timeout killed it, and on
Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir
cleanup) and hung the Playwright worker teardown. Discarding the form
before the app closes takes the test from 15 s to 4 s locally.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 21:04:41 +02:00
4gray bd848aaad6 feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) 2026-09-13 10:39:14 +02:00
4gray 62655a8b5d feat(playlist): show desktop health indicators for network sources (#1592) 2026-09-12 23:05:17 +02:00
4gray a417826b01 fix(m3u): determine VOD playback independently of TMDB (#1594) 2026-09-12 22:47:54 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray fff022afe4 fix(ui): keep detail back navigation available while scrolling (#1576) 2026-09-10 21:34:10 +02:00
4gray 7f06690e72 feat(epg): copy catch-up programme URLs (#1569)
* feat(epg): copy catch-up programme URLs without changing playback

* fix(epg): let newer archive copy requests supersede pending work
2026-09-08 07:29:11 +02:00
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

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

* docs(epg): add programme guide implementation plan

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

* feat(epg): add window-scoped guide programme queries

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

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

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

* feat(epg): expose guide programme and coverage reads over the bridge

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

* docs(epg): separate coverage chunk size in the guide plan

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

* feat(epg): add guide source contract, day layout maths and preferences

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

* fix(epg): key guide IPC answers by trimmed, present keys only

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

* docs(epg): guide search hits carry a row id

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

* fix(epg): make guide geometry DST-safe and tighten the contract

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

* feat(epg): cache guide programmes per day with batched loading

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

* feat(epg): add guide keyboard navigation controller

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

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

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

* feat(epg): add the programme guide grid components

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

* feat(epg): add a Guide button to the timeline toolbar

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

* feat(m3u): adapt the playlist channel list to the guide contract

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

* fix(m3u): guard the guide's initial group scope and track language changes

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

* feat(m3u): open the programme guide in place with a docked player

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

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

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

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

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

* docs(epg): document the programme guide and its release note

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

* i18n(epg): translate the programme guide

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

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

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

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

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

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

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

* chore(epg): tidy guide docs, palette gating and the unbound output

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

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

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

* perf(epg): let the guide window predicate use the programme time index

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

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

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

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

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

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

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

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

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

* docs(epg): describe guide row ids as scope-local

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

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

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

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

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

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

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

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

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

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

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

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00
4grayandClaude Fable 5.1 0dcfba7045 fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface

The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.

- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
  collection) under live-sidebar-state:<surface>; the M3U player now goes
  through the service instead of its own signal. The legacy shared key is
  forgotten on startup and never read, so the update itself restores the
  list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
  renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
  the control exists in both states instead of disappearing with the rail.
  Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
  app-channel-list-hidden-state (title, shortcut hint, full-size "Show
  channels list" button) instead of asking to pick from a list that is not
  on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.

Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.

Refs #1458

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

* test(live-tv): mirror the EPG offset setting in the sidebar spec mock

Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.

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

* test(web-e2e): scope the Stalker radio rail toggles to the rail

The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.

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

* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

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

* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API

master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:48:58 +02:00
4gray d9d6f49757 feat(playback): slide-in channel list for fullscreen playback (#1519) 2026-09-05 17:00:46 +02:00
4gray 0ba5107561 fix(m3u): use custom User-Agent for URL import and refresh (#1535) 2026-09-05 14:49:23 +02:00
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00
5b2eb515d1 feat(downloads): track live-TV recordings in the download manager (#1452)
* feat(downloads): track live-TV recordings in the download manager

Embedded MPV recordings were written to disk and forgotten: no list, no
reveal/play, no missing-file handling, and the channel/EPG context was lost
the moment the recording stopped. Recordings now live beside downloads:

- New `recordings` table (no unique index, no playlist FK — recordings
  survive source deletion; playlist name stored via playlistDisplayLabel).
- EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus
  a session-snapshot observer for implicit stops (stream-replacement
  auto-stop, frame-copy helper crash, session error/close); startup repair
  turns rows a hard kill left behind into playable `interrupted` partials.
- Channel/EPG metadata is captured at recording START in all four live
  hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers
  renderer-side enrichment with every program overlapping the recorded
  window, keyed by target path — covering recordings that span a program
  boundary. Provider EPG never reaches SQLite, so post-hoc lookup is
  impossible by design.
- Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a
  separate supportsRecordings capability gate (the supportsDownloads
  allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs
  are gated on the recordings table, so the renderer-supplied recording
  directory stays a write-location preference, not a shell-access grant.
- Manager UI: `recording` filter chip, "Recording now" queue section (REC
  pulse, elapsed, live file size — no percentage, the length is unknown),
  16:9 channel-logo Recordings library, Needs attention with Remove only,
  focused detail at /workspace/downloads/recording/:recordingId.

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

* fix(downloads): close the stop-enrichment race and repair player stubs

Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv
acknowledges, while the recording row's terminal-state update is still queued
in the tracker. The renderer answers that snapshot with stop enrichment, whose
handler only accepts a terminal row — so the covered-program metadata could be
silently dropped with "Recording not found".

- EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write
  chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup.
  Regression covered from both sides: the handler must not touch the database
  until the barrier resolves, and the barrier must imply a committed row.

CI also caught spec stubs that had not learned the new player inputs (my local
run-many had been an Nx cache hit, so the failures only surfaced in CI):

- Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the
  `recordingMetadata` input and `recordingStopped` output across the m3u,
  Xtream, Stalker, unified-live-tab and web-player-view specs.
- The races spec now asserts the metadata argument explicitly instead of
  matching a two-argument call.
- Extract the Stalker and unified-live-tab spec stubs into sibling
  `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs
  stay under the 1200-line test limit without shaving assertions.

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

* test(downloads): make the recordings events spec a module

The spec deliberately has no static imports — every dependency is swapped
through jest.doMock before the harness's dynamic import — which also made it a
TS script rather than a module, so its top-level `registeredHandlers` landed in
the global scope and collided with the same-named const in stream-probe.spec.ts
(TS2451). Local per-project runs compile the specs separately and stayed green;
only the Tier A coverage suite builds them into one program, so CI caught it.

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

* fix(downloads): address Codex review on recording lifecycle

Four findings from the Codex review, all real:

- P1: `addon.stopRecording()` only dispatches — native-view uses
  `mpv_set_property_async`, frame-copy writes a helper command — so
  finalizing inside the stop hook could stat a file mpv had not flushed and
  even unlink bytes still being written. The tracker now treats the hook as a
  request and finalizes on the acknowledged inactive snapshot, with a 10 s
  bound so a lost acknowledgement cannot strand the row. Only a recording
  that never went active has its empty reservation removed. Stop enrichment
  follows through `whenFinalized(targetPath)` (bounded) instead of merely
  draining the write queue.
- Live file size: `file_size_bytes` is written at finalization only, so the
  manager's 15 s refresh reported nothing while recording. Active rows are
  now decorated with a current `fs.stat` size.
- Manager-initiated Stop bypassed both player stop paths, so recordings
  spanning program boundaries kept only the start-time program.
  `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits
  `recordingStopped` for every trigger; the adapter and legacy toggle no
  longer emit it themselves.
- Startup recovery could terminate a row another live instance was still
  writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid`
  and recovery skips those whose owner process is alive.

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

* fix(downloads): derive the enrichment wait from the stop fallback

Greptile caught the seam my previous fix left: the enrichment barrier waited
5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a
stop mpv never confirms let the terminal-row lookup expire early and drop the
covered programs with no retry — precisely the case the fallback exists for.
The wait is now derived from the acknowledgement bound (fallback + 1 s), with
a regression test that fails if the two ever drift apart again.

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

* fix(downloads): address the second Codex pass on recordings

Four more findings, all real:

- P1 (macOS native-view): `StopRecording` clears `recordingActive` *before*
  dispatching the async property set and restores it if the request is
  rejected, so the first inactive snapshot is optimistic, not an
  acknowledgement — the tracker could finalize (and stat) a file mpv was
  still writing, and a rejected stop would leave the row `completed` while
  recording continued. An inactive snapshot now has to survive a 1.5 s settle
  window (three poll cycles); a revived recording cancels the pending
  finalization.
- Removing a failed row unlinked its path unconditionally, which takes the
  file of a newer recording that reused the freed name within the same
  timestamp second. The cleanup now runs only while no other row claims it.
- The All chip and the header's active badge ignored recordings, so a manager
  holding only recordings read "All 0" and an active recording never showed
  up in the badge.
- Switching channels auto-stops the recording, but by the time the host
  handled the stop its `activeChannel`/EPG already described the NEW channel,
  so the old recording was enriched with the wrong schedule (and an unrelated
  program could be promoted to its title). The stop event now carries the EPG
  key captured while the recording was active and every host compares it
  before enriching.

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

* fix(downloads): close the persistence race and two recording UX gaps

- Greptile P1: the enrichment deadline (fallback + 1 s) still raced the
  terminal write — if the tracker queue or the UPDATE took longer than the
  remaining margin, `whenFinalized` returned while the row was still
  `recording` and the one-shot enrichment was dropped. The deadline now
  bounds only the wait for mpv; `finalize()` removes the entry synchronously,
  so once it has started the wait follows the write itself.
- Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per
  process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another
  instance's row could stop an unrelated local recording. Foreign rows are
  now refused.
- Codex: the In progress chip counted active recordings while its filter
  deliberately hid them, so clicking it showed "no matches". Active
  recordings now belong to that filter — a chip whose count disagrees with
  its page is a lie.

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

* refactor(downloads): drop the enrichment barrier instead of tuning it

Three review rounds circled the same class: synchronizing mpv's asynchronous
stop acknowledgement with a one-shot program enrichment. Each fix moved the
deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason
a deadline existed at all — the handler insisted on a *terminal* row.

It never needed one. `openSync('wx')` makes the reserved path exclusive while
a recording owns it, so the newest row for that path IS the recording that was
stopped, and `finalize()` writes only status/end time/size and never
`programs_json`. Enrichment and finalization are therefore order-independent:

- `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any
  status and awaits only the tracker's write queue, which exists solely to
  guarantee the INSERT committed (a recording stopped milliseconds after it
  started).
- `whenFinalized`, its deadline constant, and the per-entry finalized promise
  are gone; the tracker keeps only the settle window and fallback that make
  *finalization* itself correct.

No behavior is lost and the whole timing class disappears with the code.

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

* fix(downloads): bind recording finalization to its entry and shield live rows from startup repair

Two races from the Codex review:

- Tracker timers finalized by reusable session id, so a stop followed by an
  immediate restart on the same session let the old settle timer finalize
  the NEW row (marked completed while mpv kept writing) and strand the old
  row in 'recording'. Finalization is now bound to the exact open entry,
  and replacing a session's entry arms the old entry's settle timer so an
  unobserved stop still finalizes it.

- reconcileStaleRecordings() runs after the renderer is interactive; a
  recording started during bootstrap has ownerPid === process.pid and was
  repaired to interrupted/failed mid-write. Recovery now skips rows the
  tracker reports as actively tracked (activeRowIds()).

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

* fix(downloads): harden recording startup repair against recycled pids and stale renderer lists

Second Codex pass on the recovery path:

- A live ownerPid alone no longer shields a row: after a crash the OS can
  recycle the pid for an unrelated process, which would park the row in
  'recording' with no instance able to finalize it. Recovery now also
  checks (best-effort, ps/tasklist) that the process looks like an
  IPTVnator/Electron instance; an unreadable name stays conservative and
  keeps the skip.

- The renderer loads before the repair pass runs and may already hold the
  pre-repair list with a stale Stop affordance; recovery now broadcasts
  one RECORDINGS_UPDATE_EVENT after changing any rows.

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

* fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat

Third Codex pass:

- A synthetic error/closed snapshot from disposeSession() arrives while
  the frame-copy helper may still be flushing (0.5 s quit grace + 2 s
  SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write
  — short captures became terminal 'failed', longer rows persisted a
  truncated size, and startup recovery could repair neither. The tracker
  now defers that finalization behind a 2.5 s flush window; the row stays
  'recording' (repairable) meanwhile, and an already-acknowledged stop's
  settle timer keeps its 'completed' verdict instead of being relabelled
  'interrupted'.

- The active row's live file size used a bare await stat(): one stat
  hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST.
  The probe now mirrors the availability probe's contract — in-flight
  coalescing plus a 1 s deadline degrading to no size.

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

* fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes

Fourth Codex pass:

- Recycled-pid discrimination no longer stops at the process-name family
  check (any Electron app could shield the row): a live holder must also
  not provably have started after the recording did (ps -o etime= /
  PowerShell StartTime). A pid frees only when its previous owner dies, so
  a recycled pid's holder is always younger than the recording; unreadable
  evidence stays conservative.

- /workspace/downloads/recording/:recordingId gets a supportsRecordings
  capability guard redirecting the PWA to the manager — RecordingsService
  never becomes authoritative there, so the detail rendered a permanently
  blank workspace.

- Finalization and startup repair stat through a bounded async probe (3 s
  deadline, ENOENT/ENOTDIR as the only proof of absence) instead of
  main-thread statSync: a dead network mount no longer freezes the main
  thread or the tracker queue, repair leaves unjudgeable rows recoverable,
  and finalization keeps the requested status with an unknown size rather
  than branding a likely-good file failed. The 0-byte reservation unlink
  is fire-and-forget for the same reason.

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

* fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches

Fifth Codex pass:

- Recording list decoration now uses the bounded availability variant that
  preserves 'unknown': a timed-out or permission-errored probe is not
  proof of absence, so a good recording on a slow mount no longer lands in
  Needs attention with its Play/Reveal hidden.
  ElectronRecordingItem.fileAvailability widens accordingly; consumers
  already gate on === 'missing'.

- Startup repair probes its whole batch concurrently, so main.ts awaits
  roughly one 3 s deadline instead of one per stale row.

Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES
stays out of scope (debug-only flag, same single-window design as
DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread.

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

* fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes

Sixth Codex pass:

- The recording duration formatter rounded minutes after flooring hours,
  so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared
  recordingDurationLabel() now rounds the total minutes before splitting
  (both the detail page and the library card used a duplicated copy).

- Startup repair's synchronous ps/tasklist/PowerShell ownership probes get
  a 2 s spawn timeout and are memoized per unique pid, so a batch of rows
  from one crashed instance costs at most one name query and one
  start-time query, and a hung process query degrades to the conservative
  fallback instead of blocking the main thread.

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

* fix(downloads): return to the manager through history from the recording detail

Seventh Codex pass (single finding): with a validated returnUrl the manager
is already the previous history entry, so Back now uses Location.back()
instead of pushing a third entry that made the browser Back button reopen
the detail; router navigation remains the fallback for direct links —
matching the offline-detail navigation.

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

* fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime

Eighth Codex pass:

- RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed
  row's leftover reservation: cleanup is raced against the 1 s deadline,
  so a hung network unlink cannot keep the Remove action busy — the row
  deletion is what matters.

- Reveal/Play swap the synchronous lstat gate for the bounded async
  availability probe: a dead mount no longer blocks the main process, and
  only PROVEN absence refuses the action — an inconclusive probe lets the
  shell try and answer honestly.

- Startup repair dates an interrupted row's endedAt from the captured
  file's mtime (mpv's last write) instead of the repair time, so an
  overnight shutdown no longer inflates a five-minute capture into an
  hours-long recording; the repair-time fallback remains when mtime is
  unreadable.

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

* fix(downloads): keep recording-start program metadata fresh across EPG boundaries

Ninth Codex pass (single finding): the unified live tab's
recordingMetadata computed cached its Date.now() verdict — starting a
recording after an EPG boundary snapshotted the previous show. It now
tracks the existing 30 s progress tick. The Stalker live layout's
currentProgram had the same memoization (feeding recording metadata, the
EPG panel summary, and external-player metadata); it gains a 30 s clock
tick with interval cleanup in ngOnDestroy.

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

* fix(downloads): re-select the Xtream current program against the 30 s tick at recording start

Tenth Codex pass (single finding): the Xtream live layout's recording
snapshot read withEpg().currentEpgItem, a computed whose Date.now()
verdict stays cached until epgItems changes — a recording started after
an EPG boundary snapshotted the previous show. The selection logic is
extracted as the pure findCurrentEpgItem(items, nowMs), the store
computed delegates to it unchanged, and recordingMetadata re-selects
with the layout's existing 30 s currentTimeMs tick.

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

* fix(downloads): scope stop enrichment to the exact recorded list item

Eleventh Codex pass (single finding): the stop-enrichment guard compared
only the EPG key, which is not unique for M3U items — two list entries
sharing a tvgId (or the display-name fallback) could hand the first
item's recording the second item's schedule after a switch-triggered
auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque
sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured
while the recording is active exactly like the EPG key, carried through
the player's stop edge, and compared by the hosts before enriching.
Xtream/Stalker keys are already playlist+id-scoped and need no extra key.

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

* fix(downloads): derive the M3U start-snapshot program from the active channel's schedule

Twelfth Codex pass (single finding): the M3U recording snapshot read the
NgRx currentEpgProgram, which retains its last value across a channel
switch and through EPG gaps (the mirror effect only dispatches when a
program exists) — a recording started on a channel with no airing
program could persist the previous channel's title, which stop
enrichment deliberately never overwrites. The snapshot now derives the
program from the active channel's own schedule against the existing 30 s
clock, and an EPG gap snapshots no program.

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

* fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update

Thirteenth Codex pass (single finding): finalize() removes an entry from
the open map before its queued terminal update commits, so
activeRowIds() briefly omitted a row still persisted as 'recording' —
startup recovery overlapping a clean stop could relabel it interrupted,
after which the tracker's status-guarded update could not restore
'completed'. Finalizing entries now stay in a dedicated ledger until the
update settles, and the repair UPDATE itself is guarded on
status='recording' as a second belt against a finalization that commits
between recovery's SELECT and its write.

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

* fix(downloads): register update listeners before the initial list load

Fourteenth Codex pass (single finding): RecordingsService awaited its
initial RECORDINGS_GET_LIST before subscribing to the update ping — a
recording transition during that request pinged into the void while the
response still reflected the pre-transition state, and recording pings
are rare enough that nothing self-healed until the 15 s poll (armed only
once an active row is visible). The listener now registers first so the
load-state coalescing queues the trailing refresh. DownloadsService had
the same latent window and gets the same reorder.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
2026-08-23 08:05:25 +02:00
4gray bee7df1e02 feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a
provider sent — links, Xtream credentials, a MAC address with device identity
— and a deterministic parser recognizes the source(s) and prefills the
matching import form.

- detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC
  addresses and labeled fields, classifies each finding as Xtream, Stalker or
  an M3U link/body, and returns ranked candidates. Pure and synchronous.
- Built against a corpus of 19 real reseller handouts kept verbatim in the
  spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex
  serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a
  guard so a parental PIN is never read as the account password.
- Detection only proposes: the target form's own validation and behavioral
  probes remain the sole path into the store, and no pasted text leaves the
  app. Passwords are masked on candidate cards, including query and HTTP
  Basic userinfo forms.
- Covered by parser, component and dialog unit tests plus two web E2E specs
  for the paste → pick → prefilled form workflow; i18n for all 19 languages.
2026-08-16 13:19:54 +02:00
4gray 365cf35317 refactor(portals): extract the destructive Xtream refresh into one flow (#1431)
The header action and the Workspace sources page each had their own ~60-line
implementation of the destructive Xtream refresh, which is why #1421 had to fix
the same connectivity-guard bug twice. `XtreamRefreshFlowService` now owns the
sequence once; the entry points supply only an `XtreamRefreshProgressReporter`,
so neither can reach the guard reset and skip it.

Extracting it surfaced a pre-existing race that neither entry point could have
fixed alone: the two guards were independent, so the same playlist could be
refreshed from both at once and the second run parked an already-emptied catalog
over the first run's snapshot, losing favorites, history, hidden categories and
playback positions. The shared flow now serializes runs per playlist, refusing a
second one before the guard reset.

Found by Greptile; the ordering is pinned by a test that fails when the check
moves below the reset.
2026-08-13 17:33:22 +02:00
4grayandClaude Fable 5 f7bb3a13db feat(playlist): open recognized M3U movies in the VOD detail view (#1420)
M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.

Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.

Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:18:06 +02:00
4gray e3f72f7dce perf(portals): fast-fail requests to portal hosts that stopped answering (#1421) 2026-08-13 07:31:23 +02:00
4grayandClaude Fable 5 5b211faf73 feat(remote-control): cover live collections, honest volume, status resets (#1399)
* feat(remote-control): cover live collections, honest volume, status resets

Remote control previously worked only on the three routed live layouts
(M3U player, Xtream live, Stalker ITV); playing live TV from favorites,
recently viewed, or the global collections left the mobile remote inert.

- Wire channel up/down, number select, and status publishing into the
  unified live tab, covering per-portal and global favorites/recent for
  M3U, Xtream, and Stalker; navigation follows the search-filtered,
  sorted list exactly as rendered (shared deriveVisibleFavoriteChannels)
- Treat non-live status updates as snapshots in the main process so
  stale now-playing fields are cleared instead of merged forever
- Publish a reset snapshot from every integration on destroy, so
  leaving a live view clears the remote instead of freezing it
- Report M3U supportsVolume only for built-in inline playback and no-op
  volume commands while MPV/VLC/Embedded MPV owns the audio
- Publish live status for Stalker radio (same layout, same handlers)
  and fix its channel-number lookup for non-numeric radio ids

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

* fix(remote-control): review-loop hardening for status honesty

- Make the non-live status update an authoritative reset in the main
  process: only portal survives, supportsVolume is forced false, stray
  now-playing fields from callers are dropped (Copilot review)
- Stop Stalker radio status from leaking an unrelated TV channel's EPG:
  the ITV-keyed bulk cache survives itv->radio navigation and Ministra
  ids collide across the two lists, so EPG fields publish for itv only
- Publish the reset snapshot when the M3U active channel clears in
  place (e.g. quitting external MPV), not only on route destroy
- Consider a live external session in the M3U volume gate: a
  diagnostic-recovery MPV/VLC launch owns the audio even while a web
  player is configured; republish capability on session start/end
- Share one REMOTE_CONTROL_RESET_STATUS constant across all four
  integrations instead of four hand-copied literals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

* fix(remote-control): external session outranks DASH in M3U volume gate

The managed clear-DASH MPV/VLC fallback (Shaka browser-support preflight
failure) leaves activeChannelIsDash() true while the external session
owns the audio, so the DASH shortcut bypassed the session check and kept
advertising remote volume support. The live-session check now precedes
the DASH branch; radio stays first because its inline audio element is
always mounted and remains audible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 08:18:20 +02:00
4grayandClaude Fable 5 9aeb83e515 fix(m3u): forward playlist-level custom headers to external players (#1397)
* fix(m3u): forward playlist-level custom headers to external players

The custom User-Agent/Referer/Origin stored on an M3U playlist only
reached the built-in web players (via the Electron webRequest override).
MPV/VLC and the embedded MPV player make their own HTTP requests and
received only the per-channel #EXTVLCOPT values, so a playlist-wide
custom User-Agent was silently dropped for UA-locked providers (#1221).

External launch payloads now resolve each header independently: the
channel-level #EXTVLCOPT value wins, the playlist-level value is the
fallback, blank values count as absent — matching the semantics the
unified favorites/recent stream resolver already had. Covers the
auto-launch and catch-up effects in m3u-state, the manual MPV/VLC
fallback and the embedded MPV payload in VideoPlayerComponent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL

* fix(playback): send Origin as a real VLC header and cover header IPC in E2E

Review follow-ups: VLC only used the Origin value as an :http-referrer
fallback while MPV already sent it via --http-header-fields; both VLC
paths (fresh spawn and RC enqueue) now emit the same
buildHttpHeaderFields list, so a real `Origin: ...` header reaches the
provider, deduplicated against an explicit headers-map Origin. The
legacy origin-as-Referer fallback stays.

The dash-clearkey Electron E2E now asserts the new IPC contract (blank
channel-level headers arrive as undefined, not empty strings) and gains
a scenario that sets a playlist-level User-Agent through the source
editor and verifies the captured MPV fallback launch carries it across
the renderer/main IPC boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011id2tdJtkJYRYX8dwYYKwL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-09 23:34:03 +02:00
4gray 6ad9f3ff8a feat(stalker): discover portal endpoints on add and edit (#1391)
* feat(stalker): discover portal connection on edit

* docs(stalker): document smart endpoint discovery

* fix(stalker): make edited connection persistence atomic

* fix(stalker): serialize edit discovery

* fix(stalker): fence all edit authentication

* fix(stalker): serialize overlapping edits

* fix(stalker): hydrate playlist identity before edit

* test(stalker): await edit hydration

* fix(stalker): release abandoned edit fences

* fix(stalker): reject stale repairs before discovery

* fix(stalker): fence stale portal modes

* test(stalker): align simple portal session guard

* fix(stalker): reject superseded portal responses

* test(stalker): await settled append failure

* fix(stalker): retain abandoned auth fences

* fix(stalker): fence abandoned discovery retries

* fix(stalker): retire restored repair overrides

* fix(stalker): verify repair override retirement

* fix(stalker): preserve edit-owned repair tokens

* fix(stalker): defer repair retirement during edits

* fix(stalker): fence repair history reads

* fix(stalker): fingerprint portal URL credentials

* fix(stalker): persist submitted identity after navigation

* fix(stalker): merge late connection saves

* fix(stalker): keep edits off Xtream save path

* fix(stalker): preserve concurrent edit state

* fix(stalker): reject replaced late edit targets

* fix(stalker): guard every resolved edit write

* fix(stalker): make pwa edit guard transactional

* fix(stalker): migrate pwa flags transactionally

* fix(stalker): reserve pwa edits across tabs

* fix(stalker): coordinate playlist replacements with edit

* fix(stalker): reserve lazy repairs across tabs

* fix(stalker): drain local repair before edit lock

* fix(stalker): block queued repairs during edit drain
2026-08-09 22:34:49 +02:00
4gray d73acd6bfc fix(playback): clarify external player launch feedback (#1388) 2026-08-09 13:33:07 +02:00
4grayandClaude Fable 5 1a6af75761 feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

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

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

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

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 09:34:03 +02:00
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