- 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>
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>
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>
- 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>
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>
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>
- 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>
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>
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>
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>
- 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>
- 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>
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>
- 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>
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>
* 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>
Electron 44 publishes no linux-armv7l binary, so the 44.x update fails
the Linux packaging jobs on the armv7l AppImage, DEB and Snap targets.
Ignore Electron majors in Dependabot until 32-bit ARM support is
dropped deliberately; 43.x minor and patch updates still arrive.
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
The series view re-normalizes the selected item through
createStalkerInfo() on every render, and that mapping carried only seven
hand-picked tmdb_* fields. tmdb_genres, tmdb_countries, tmdb_media_type
and tmdb_supplied_release_date were dropped right after enrichment, so
the clickable Discover chips never appeared on Stalker series.
The carried keys now come from a Record over StalkerVodInfo's tmdb_*
keys, so a new enrichment field fails to compile until it is listed.
Saved Stalker entries now also keep tmdb_supplied_release_date, and the
recommendations exclusion index (trustedReleaseYear) skips a date TMDB
supplied, as the flag's contract requires.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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(backup): export and restore Xtream user state in the PWA
Without an Electron bridge the backup wrote empty Xtream favorites,
history and positions, and import only parked the state until the
playlist was next opened.
PwaXtreamBackupStateService reads and writes the four PWA maps through
USER_DATA_STORAGE, so self-hosted server storage is covered as well.
Export converts typed keys, resolves legacy ids only through their
snapshot, and fails on an unreadable map instead of writing []. Import
applies at once through applyAndConsume, remapped to the imported
playlist id; a failed write keeps the snapshot parked for the store.
Title snapshots travel in a new optional collectionItems field that
older builds ignore. The storage key names are shared with the portal
data source as XTREAM_USER_DATA_KEYS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): write PWA title snapshots before the Xtream lists
A browser restore that failed after its first list write kept the
pending state parked for the Xtream store, but that replay carries no
title snapshots, so they were lost. Merge them first; a damaged cache
is replaced, while a storage that cannot be read still fails the entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): keep the artwork of PWA recently viewed items
The browser build saves a backdrop with each view and shows it ahead of
the title snapshot's, but the export dropped it, so a restore showed
older artwork. Carry it as an optional backdropUrl on the archive's
recently viewed items; the desktop build ignores it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): keep view artwork in the PWA pending restore state
A failed browser restore is replayed by PwaXtreamDataSource.restoreUserData,
which rewrote recently viewed items without their backdropUrl; a refresh
dropped it the same way in clearPlaylistContent. Both now carry it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): restore PWA Xtream lists before the snapshot cache
Writing the optional title snapshots first could take the storage space
the favorites, history or positions needed, failing a restore that fits
without them. The lists now go first, as the snapshot-storage contract
asks; after a failed list write the snapshots are still merged for the
store's replay, unless storage is full.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(backup): pick the Xtream backup path by the SQLite data source
The backup chose its Xtream path by the mere presence of window.electron,
while XTREAM_DATA_SOURCE falls back to the browser storage whenever the
bridge lacks the Xtream SQLite API. On such a partial or stale preload the
export read the wrong store and the restore took the SQLite path. Both now
follow RuntimeCapabilitiesService.supportsXtreamSqliteDataSource.
Also casts the test helper's prototype instance through unknown, which the
spec typecheck requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): restore PWA playback positions under the target playlist
A PWA restore that is replayed by the Xtream store kept each position's
exported playlistId. When the backup was imported under another id, the
VOD details page rejected those positions as belonging to a different
playlist. restoreUserData now stamps the playlist it restores into.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
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(portals): drop provider placeholder metadata at the mapping layer
Stalker and Xtream portals send "N/A", "-" or an unrated "0" score for
fields they have nothing for, and the detail templates rendered them as
data. Normalize them where provider rows are mapped: createStalkerInfo,
the shared VOD view-model adapters and the Xtream detail selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(portals): hide placeholder ratings on catalog cards
formatGridRating passed a provider "N/A" through as the badge text and
rendered an unrated zero as "0.0". Read the value through the shared
readProviderRating so both hide the badge and resolveGridRating reaches
the generic rating behind a placeholder rating_imdb.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(xtream): keep placeholder ratings off the sparse VOD fallback page
A get_vod_info made only of placeholders is now stripped to sparse and
routed to the fallback page, which read the top-level catalog ratings
raw: "N/A" survived and a zero became "0.0". Read every candidate
through readProviderRating so the next usable rating wins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(search): one 300 ms debounce between typing and the global search query
Typing in the header search box waited twice before a query ran: 350 ms in
the workspace shell (SEARCH_INPUT_DEBOUNCE_MS), then another 300 ms in
SearchResultsComponent. On J4 (large catalog, "system") the query and first
page take 30-40 ms; about 650 ms of the ~690 ms from the last key to settled
results was waiting.
The results component now searches as soon as the applied term reaches the
store, and the shell debounce is the only one, lowered to 300 ms: above the
gap between keys of steady typing, so a word typed in one go still queries
once. Superseded queries are dropped by the existing request versions. The
component's route sync reads the store term untracked, so a term the shell
applied before writing q is not pushed back to the stale q and re-queried.
J4 locally (3 runs per build): lastKeystrokeToSettledMs P50 692-695 ms ->
336-337 ms, ipcCallsPerSearch 1 and sqlStatementsPerSearch 2 unchanged. At
400 ms per key both builds run five queries; only typing with 300-350 ms
gaps queries per key where it did not before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(journeys): enforce J4 ipcCallsPerSearch 1 and sqlStatementsPerSearch 2
Both counters were identical in all 816 J4 iterations of the 136 CI journey
jobs since #1816 and in all 18 iterations of three runner runs of this PR
(run 38165392046, attempts 1-3). They guard the single debounce: a search
that queries per keystroke or twice for one term raises them. Guard-only
note, since the wall-clock gain came without moving them. Added to the
Performance journeys --only list; the direction check reports 2 added,
nothing weakened.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(search): run a prefilled in-portal search again once the catalog has loaded
Review follow-up on #1899 (Greptile P1). Without the results component's
300 ms timer, an in-portal search applied while the portal's catalog is still
loading (a reload or deep link with q) runs against the incomplete catalog,
and nothing re-ran it afterwards. The old timer only helped when loading
finished within 300 ms. The component now runs the current in-portal search
once more when nothing of the catalog is loading or importing any more, the
readiness the Discover route already uses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(search): one search effect for a new term and the loaded catalog
Review follow-up on #1899 (Greptile). With separate effects, a term and the
end of catalog loading arriving in the same flush each ran executeSearch for
the same term: the request version dropped the first answer, but the
catalog scan or desktop search still ran twice. One effect now tracks both
triggers and searches once.
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>
* fix(workspace): don't flash the import overlay on a cached Xtream open
Opening an already-imported Xtream portal reads the saved catalog in tens
of milliseconds, but the session made the shell mount the full-screen
import overlay right away, so it flashed for about two frames between the
skeleton and the loaded page (J2 open-source).
A session that has only read the saved catalog (loading-cached, store
isImporting still false) now shows the overlay after a 300 ms delay.
Remote import phases and refresh preparation still show it immediately,
and a slow or stuck cache read still blocks and stays cancellable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(workspace): keep the overlay delay while another playlist refreshes
Refresh preparation skipped the cached-load delay for any playlist, so
opening a cached portal while another playlist prepared a refresh still
flashed the overlay. Only preparation of the shown playlist (any on the
dashboard) now shows it at once.
Also words the J2 layout-shift history from #1895 in the past tense: the
cached-load overlay no longer mounts on a J2 click.
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>
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>
Clearing an optional playlist field (User-Agent, Referrer, Origin,
credentials, EPG URL lists, file path) did not persist in Electron: the
codec maps an empty value to `undefined`, `ON CONFLICT DO UPDATE` skips
undefined keys, and `parseAppPlaylist` prefers the stale column over the
updated payload, so the old value came back on the next read.
`playlistConflictUpdate` now sets every optional column the document
leaves empty to NULL, so both desktop upserts (`DB_UPSERT_APP_PLAYLIST`
and `DB_UPSERT_APP_PLAYLISTS`) replace the stored row. The cleared-columns
list moves from the web backend's playlist repository into the shared
conflict module, and the server now uses the shared version.
`date_created`, `name`, `type`, `autoRefresh`, `payload` and `last_usage`
are never cleared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
- 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>
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>
- 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>
- 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>
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>
* fix(playback): resume an episode whose connection drops instead of skipping it (#1902)
Embedded MPV sessions run with keep-open, so a provider that closes the
connection mid-episode (and refuses ffmpeg's own reconnect) reaches the app
as eof-reached: status `ended` at the drop position. The reconnect policy
counted `ended` as a loss only for live playback, so on VOD it stood for
the end of the file and series autoplay started the next episode.
- The reconnect policy now treats a VOD `ended` with more than
max(10 s, 1 % of the duration) left as a dropped connection and reloads
it at the last position. A reload that ends again before getting 5 s past
where it resumed proves the file ends there (bitrate-estimated duration),
so it is left as the end and autoplay continues after one attempt.
- The service passes the snapshot duration to the policy.
- The player emits playbackEnded only for an `ended` session without a
pending reconnect.
- Xtream mock: `streamdrop` scenario whose episodes drop a third of the way
into a local 60 s clip and refuse the retry for 4 s; Electron E2E covers
the native-view engine (skips without an Embedded MPV runtime).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep the early-EOF retry on a refused reload or after a seek
The resume point that tells a reload ending where it resumed from a new
drop was recorded before the reload ran, so a reload the addon refused made
the original EOF look like the end and the next poll cancelled the retry.
Record it only once the reload started, and forget it when the user seeks:
an EOF after a seek is judged on its position alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): fail the stream-drop resume check when the reload restarts the episode
A reload that started the episode over would also reach the 25 s mark
within the timeout, since the mock drops each episode only once. Every
position played after the drop must now stay within 3 s of it.
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>
* fix(workspace): slide the content and header into place when a source opens
Opening an Xtream portal from the dashboard inserted the 323 px context
panel and the header Back in the frame that swaps the page: the content
pane jumped right by the panel's width and the switcher, search and actions
by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222
for the content pane, 0.011 for the header, scored with the pane's
distance), plus 0.0001 when the category search and sort buttons appeared
with the categories.
LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout
applies at once and only a transform animates, so nothing waits and no
layout shift is recorded. The content surface covers the strip it uncovers
while sliding, Back fades in, and reduced motion keeps the jump. The
category search and sort buttons render disabled while the list loads.
Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3
0.001, J4 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(journeys): lower J2 layoutShiftScore to 0
The slides land on the runner: run 38147072417 (d2cb9445d) reads
open-source/renderer.layoutShiftScore 0 with no shift recorded in all 5
measured iterations (J1 0 / 0, J3 0.001, J4 0 unchanged). Ratchet the
baseline from 0.233 to 0 and record the perf:compositing evidence for the
slide in the shell contract.
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>