59 Commits
Author SHA1 Message Date
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
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
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
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
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 9f950a1530 fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures

* fix(migration): keep inventory reload failures recoverable

* test(migration): type the deferred recovery hook

* fix(migration): reconcile failed EPG cleanup before recovery

* fix(migration): await settings before inventory readiness

* style(migration): add a subtle theme-aware startup watermark
2026-09-07 21:24:34 +02:00
4gray 0ba5107561 fix(m3u): use custom User-Agent for URL import and refresh (#1535) 2026-09-05 14:49:23 +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
4grayandClaude Opus 5 f80eb4d1b9 fix(playlist): open playlists handed over by the OS (#1299)
Opening an .m3u/.m3u8 file from the command line or a file association did
nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event
that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged
it as an unknown type and dropped it.

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

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

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

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 20:28:29 +02:00
4gray 0579d253c4 fix(electron): fail closed on unknown performance completions 2026-07-26 22:37:52 +02:00
4gray f9e71a6d8d fix(electron): isolate refresh performance correlation 2026-07-26 22:37:52 +02:00
4gray 08b868d6c1 test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
2026-07-26 22:27:50 +02:00
4gray 3032cfa88d fix(m3u): avoid persisting hydrated favorites (#1232) 2026-07-26 02:20:32 +02:00
4grayandClaude Fable 5 bd07e17857 feat(m3u): DASH + ClearKey playback via Shaka Player (#1225)
* feat(m3u): extract ClearKey DRM from #KODIPROP playlist lines

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: document DASH + ClearKey playback architecture

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

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

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

Addresses Codex review on PR #1225.

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

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

Two review findings on the ShakaVideoSession lifecycle:

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

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

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

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

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

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

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

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

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

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

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

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

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

Two Codex round-2 findings:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: retrigger CI

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 20:31:18 +02:00
4grayandClaude Fable 5 c55acfb1d9 fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures (#1133)
* fix(epg): wire catch-up Watch button in unified live tab, surface resolve failures

The favorites/recent live views (UnifiedLiveTabComponent) passed
archivePlaybackAvailable/archiveDays to the EPG timeline and list view, so
the "Watch" button and the dialog's "Watch from start" action rendered —
but neither (programActivated) nor (returnToLive) was bound. Clicking
Watch emitted an event nobody listened to: no error, no feedback, no
playback change.

- Handle programActivated in the unified live tab: resolve the catch-up
  URL via resolveM3uCatchupUrl, swap the inline player's playback target
  (or hand it to the configured external player), show the
  "Archive playback" summary label, "From archive" block tag and
  "Return to live"; clear the override on channel switch/close/return.
- Never fail silently: both the unified tab and the m3u-state effect now
  show an EPG.TIMELINE.CATCHUP_FAILED snackbar when the replay URL can't
  be resolved. New i18n key translated into all 17 locales via the
  i18n-fill workflow.
- Extract the effect's resolve branch into a pure
  resolveActiveEpgProgramAction util and the unified tab's EPG summary
  helpers into unified-live-epg-summary.util.ts (component shrinks
  598 → 572 lines despite the new feature).
- Regression coverage: 6 component tests for the catch-up flow,
  3 unit tests for the resolver action; docs updated in
  m3u-playlist-module.md.

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

* fix(epg): address PR review — untrack progress tick in archive summary, open live external without prior timeshift

- liveEpgPanelSummary no longer tracks the 30s progressTick while an
  archived programme is shown (Greptile P2): the archive summary is
  frozen, so re-running the computed each tick only produced needless
  re-renders.
- returnToLivePlayback no longer early-returns when no timeshift is
  active (Codex P2): with an external player configured and
  openStreamOnDoubleClick enabled, the programme dialog's "Watch live"
  action now launches the external player instead of being dropped.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 14:47:34 +02:00
4gray 3cb228d931 fix(m3u): highlight active archive epg program (#1097)
fix(m3u): highlight active archive epg program

* Store the active archive EPG program with the resolved playback URL so the clicked M3U catch-up row stays highlighted during archive playback.
* Show archive playback state and return-to-live controls in the inline player panel.
* Hide duplicate EPG program rows that share the same time slot while keeping the richer provider entry.
2026-06-28 14:34:51 +02:00
4gray e801028005 feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
2026-06-21 22:53:07 +02:00
4gray 003a8774a1 fix(electron): tighten navigation review feedback
Restrict packaged file navigation to the app renderer, guard redirects, and handle fire-and-forget header override IPC failures.
2026-05-23 19:05:38 +03:00
4gray 3c5f7ab92e fix(electron): preserve playlist header defaults
Keep playlist-level request header overrides active when channel-scoped headers are cleared, and cover layered override behavior.
2026-05-23 15:57:29 +03:00
4gray fc449ca73e feat(electron): harden renderer security
Scope playback request header overrides to active stream origins and document the Electron runtime security contract.
2026-05-22 23:31:41 +03:00
4gray 0ec9b29993 fix(playlist): preserve auto-refresh on manual refresh (#947)
* fix(playlist): preserve auto-refresh on manual refresh

* test(playlist): cover disabled auto-refresh preservation
2026-05-15 23:10:35 +02:00
4gray d24c77a143 chore(nx): enforce scoped workspace boundaries (#942) 2026-05-15 09:59:06 +02:00
Tedd Johnson 626948f2f5 Add double-click stream open setting 2026-05-07 20:03:33 -07:00
4grayandClaude Opus 4.7 d6a91d79e9 perf(bundle): dynamic-import iptv-playlist-parser to drop 130KB validator from eager bundle
iptv-playlist-parser statically imports the entire validator library
(~130KB across 113 files). Because PlaylistsService is eager (re-exported
through the 'services' barrel that AppComponent imports), validator was
landing in the cold-start preloaded chunk even though parse() only runs
on user-triggered playlist imports (FILE/URL/TEXT add).

Switch handlePlaylistParsing() to dynamic-import the parser. The two
callers (playlist-backup.service and the parsePlaylist$ NgRx effect)
needed minor adjustments — the effect now uses mergeMap(from(...)) to
flatten the Promise back into the action stream.

Cold-start preloaded chunk: 1511KB -> 1374KB (-9%).
Validator now sits in a separate ~129KB chunk that loads only when
the user adds a playlist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 3ec205264762
2026-05-01 23:10:10 +02:00
4gray c8c93798f9 feat: enhance playlist backup export functionality and UI feedback
Entire-Checkpoint: c6e522b4276c
2026-04-21 08:54:03 +02:00
4gray 1870ce2aea feat: enhance EPG service with channel metadata fetching and update M3U module documentation
Entire-Checkpoint: c6e522b4276c
2026-04-20 00:24:25 +02:00
Eliran Sapir 3c73a4051b Harden EPG channel lookup and invalid rows 2026-04-15 17:08:24 -04:00
4gray 0e98af2e1a feat(m3u): implement group management dialog and hidden group titles functionality for m3u module 2026-04-11 22:59:58 +02:00
4gray 0f72efb749 refactor(m3u): add loading state for channel list and improve playlist handling
- Implemented a loading state for channel lists in various components, enhancing user experience during data fetching.
- Added `ChannelListLoadingStateComponent` to display loading skeletons for channels and groups.
- Updated `M3uWorkspaceRouteSession` to manage loading states more effectively when fetching playlists.
- Modified `VideoPlayerComponent`, `SidebarComponent`, and `ChannelListContainerComponent` to utilize the new loading state.
- Enhanced tests to cover new loading behaviors and ensure proper dispatching of loading actions.
2026-04-11 13:20:22 +02:00
4gray 4005687f18 refactor: improve archive playback support and enhance EPG item display 2026-04-10 07:53:48 +02:00
4gray 91cd650c1a feat: enhance EPG and portal features with error suppression and status resolution
- Added suppressErrorLog option to EPG API calls in withEpg feature.
- Introduced resolvePortalStatus function to streamline portal status checks in withPortal feature.
- Updated checkPortalStatus to return PortalStatusType instead of void.
- Enhanced XtreamStore to preserve cancelled content initialization block.
- Expanded XtreamState to include contentInitBlockReason for better state management.
- Added XtreamContentGateComponent to manage content initialization states.
- Implemented tests for XtreamContentGateComponent to ensure correct rendering based on contentInitBlockReason.
- Created PlaylistRefreshService to handle playlist refresh operations and events.
- Introduced new IPC commands for playlist refresh functionality.
- Added interfaces for playlist refresh events and payloads to improve type safety.
2026-04-04 13:40:34 +02:00
4gray c073f174c3 refactor(effects): replace combineLatestWith with withLatestFrom for improved readability
Entire-Checkpoint: 84693b066142
2026-03-29 15:46:33 +02:00
4gray 0c9fd18677 refactor: streamline playlist management and navigation logic 2026-03-28 21:41:49 +01:00
4gray 7eaa9552e6 feat(playlists): remove unused removeAll$ effect and associated snackbar notification 2026-03-28 15:45:43 +01:00
4gray d76287e63d feat(epg): implement timeout for channel program retrieval and refactor EPG list component with signals 2026-03-28 15:42:08 +01:00
4gray 7ac2fd66b8 refactor: update nx monorepo structure 2026-03-09 23:24:34 +01:00
4gray 256f0e0136 refactor(nx): update repository structure
Introduce new libraries to improve the boundaries and dependencies in mono repo
2026-03-08 20:01:42 +01:00
4gray 9e1ef0b0bd refactor: enhance playlist favorites handling and introduce portal activity items
- Updated the `selectFavorites` selector to filter favorites ensuring they are strings.
- Added new interfaces for `PortalActivityItem`, `PortalRecentItem`, and `PortalFavoriteItem` to standardize activity item representation.
- Introduced `StalkerPortalItem` interface to represent items in Stalker portals.
- Created utility functions for extracting and normalizing data from Stalker portal items.
- Refactored `DashboardDataService` to utilize new interfaces and utility functions for managing recent and favorite items.
- Removed deprecated mapping functions and replaced them with direct usage of new interfaces.
- Added navigation utilities for recent and favorite items to streamline routing logic.
2026-03-08 15:48:37 +01:00
4gray c6bd861b49 refactor: streamline layout and styling for live stream component
- Consolidated SCSS styles for the live stream layout, reducing redundancy and improving maintainability.
- Introduced a new portal layout mixin for better structure.
- Enhanced sidebar and content container styles for improved user experience.
- Updated TypeScript component to utilize new styles and improve readability.
- Refactored channel list and recently viewed components for consistency in design.
- Optimized playlist service and store interactions for better performance.
- Added new translations for enhanced user interface clarity.
- Improved database schema with additional indexing for EPG programs.
2026-03-08 11:25:26 +01:00
4gray 086eab9306 feat(storage): add SQLite-backed m3u playlists for Electron with IndexedDB migration 2026-03-02 23:41:02 +01:00
4gray 129848c4d4 feat: remove home component, and update workspace UI and routing 2026-03-02 22:31:10 +01:00
4gray 992b996ecd feat: general development, update core application components, and enhance build scripts. 2026-03-01 22:52:33 +01:00
4gray edcd7cb148 feat: improve global favorites handling and channel display logic in the UI 2026-02-08 19:18:56 +01:00
4grayandClaude Opus 4.5 0643acac3a feat: add o_name support and Stalker playback position tracking
- Add o_name (original title) field to Stalker VOD interfaces
- Prefer o_name over name in adapters and grid list display
- Add playback position tracking for Stalker VOD content
- Support resume playback from last position in vod-details
- Add real-time position updates via IPC listener
- Add progress indicators for Stalker VOD items in grid view
- Add debug logging for VOD series seasons resource

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:55:45 +01:00
4gray 68b87040ad feat(m3u-state): reducers and add selectors
Move playlist reducers into a dedicated reducers module and re-export a
composed playlistReducer built from smaller reducer arrays (epg,
channel, playlist, favorites, filter). This extracts and organizes the
previously monolithic reducer implementation into focused reducer files,
improving modularity and maintainability.

Add simple selectors in the new reducers index for commonly accessed
pieces of PlaylistState (playlists, selected playlist id, epg availability,
active channel, current EPG program, channels list, playlists loaded flag,
current playlist id) so callers can import them from a single place.

These changes make reducer logic easier to reason about, enable
incremental testing and extension of individual reducer groups, and
centralize basic selectors for consistent usage across the app.
2025-12-20 23:59:40 +01:00
4gray 7ee2cc4eea feat(db): add API to delete all playlists and related rows
Add a new IPC handler_DELETE_ALL_PLAYLIST that removes all
and related data from the SQLite database in a safe order that respects
foreign key relationships. The handler deletes favorites and recently
viewed entries first, then content, categories, and finally playlists.
Errors are logged and propagated.

Expose the new operation in the renderer preload API as
dbDeleteAllPlaylists() to allow UI code to invoke the purge. This
provides a single-call way to clear playlists and their dependent
records for testing or user-driven reset.
2025-12-06 14:46:37 +01:00
4gray 34ef9c57d1 fix: preserve favorites after auto-refresh 2025-11-22 11:40:16 +01:00