A re-read checked other tabs' unconfirmed entries only after its answer
arrived. A tab whose write was unconfirmed when the read went out (its
edit already in the snapshot) and confirmed before the old answer came
back matched neither check, so the stale answer replaced its saved value
in the shared cache; that tab's next edit, holding the newer tag, then
wrote the stale list and deleted the item on the server. The entries
unconfirmed when the read is sent are kept too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit raised Delete All's barrier inside the exclusive
authority reset and drained queued writes there, but a queued deletion
or import takes the shared side of that lock: the reset waited for it
while it waited for the reset.
Delete All drains the writes queued before it outside the lock again,
then takes the reset, with its barrier up from the start. The deadlock
with a Stalker repair (or Edit) that holds a row reservation and writes
under it is broken on the other side: such a write does not wait for
the barrier (holdsPlaylistAuthority, counted per reservation in this
context), and the reset waits for the reservation as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delete All raised its write barrier before it held the exclusive
authority reset. A Stalker repair already holding its row reservation
then waited on that barrier for its conditional write, while the reset
waited for the reservation: neither finished, and later playlist
operations stalled behind them. The barrier now goes up inside the
reset, once repairs in flight have finished; writes queued so far land
first and new ones wait for the delete, as before.
ready() said yes after any earlier read, so an export could miss what
another device added since, and restoring an empty list did nothing
when the cache was already empty. Backup export, restore and the
restore replay now ask for a fresh read (ready(true)) and fail, the
restore staying parked, when it does not succeed.
The facade's adoption loop moves to the adopt module to keep the
facade under its line limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two tabs or devices moving browser copies with the same playlist id both
planned an upload; the browser documents carry no served version, so
both PUTs were unconditional and the later one overwrote the first copy
(and any change made on it since), with both moves reporting success.
The backend's playlist PUT now honours `If-None-Match: *` (412 when the
playlist exists; checked and written in one synchronous handler). The
move uploads with it through ServerPlaylistDocumentStore
.dbCreateAppPlaylist, and an upload that finds its id taken merges its
favorites and history into that copy instead, counted as merged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves tools/performance/journey-baselines.json: master's #1938 lowered
renderer.initialBytes to 1,329,249; the merged tree measures 1,338,342
locally, so the branch's raise is now 9,093 bytes (0.7%), with its note
updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The audio player's play/pause control was the app's only mat-fab. It is now
a mat-icon-button drawn as the same 56px primary circle (icon-button
overrides: 56px state layer, 32px white icon, 50% shape, selection-colour
fill, hover scale), so the eager button chunk no longer carries the fab
directives and the theme no longer emits the fab tokens:
renderer.initialBytes 1,349,048 -> 1,329,249 bytes (-19,799: -13,799 in
button.mjs, -6,028 in the stylesheet), measured locally against the merge
base. The fab also reset the keyboard focus ring with `outline: none`; the
icon button keeps the app ring.
- tools/nx/check-material-theme-scope.mjs: `button` brings button and
icon-button; `fab` is required only while a runtime template renders
mat-fab / mat-mini-fab (matFab / matMiniFab), found through the component
templates the way check-form-field-defaults.mjs resolves them. That
resolution moves to tools/nx/component-templates.mjs, shared by both.
Templates know HTML comments only, so the scan strips just those: a `//`
in text or an attribute value no longer hides a fab on the same line.
- apps/web/src/m3-theme.scss drops `fab` from $material-components.
- apps/web-e2e: the Stalker radio test checks the control's size, shape,
fill, icon and keyboard focus ring.
- journey-baselines.json: renderer.initialBytes 1,345,807 -> 1,329,249.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On pagehide the page flushes with keepalive and then marks its record
closed. When writes were held (offline), the keepalive write fails at
once and its requeue saved the journal again a microtask later, which
rewrote the record without `closed`: the reload took the previous page
load for live and left its favorites and progress unsent until the
record aged out (ten minutes). markUnconfirmed now keeps an existing
record's closed flag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The move's M3U remap swapped references for server channel ids, but the
app looks favorites up by stream URL and history entries carry the URL
as their id: legacy favorites became invisible and history duplicated.
Legacy id favorites now become their channel's URL on both sides, and
history keeps its URL id with the server's channel id as channel_id.
A restore in server mode reported success once its lists were queued:
a list the server refused (413) left the old data while the parked
restore was consumed. Both restore paths now wait for that playlist's
writes (UserDataStorage.flushed, a few flush rounds for conflict
replays) and stay parked when the server did not take them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Stalker metadata migration wrote server rows back through the
unconditional bulk upsert, from the list read: a device that edited a
Stalker playlist in between (or a second browser migrating the same
copied database) lost its change. Server rows are now read again one by
one and written as conditional mutations on that version; a conflict
leaves the flag unset, so the next start retries.
Moving a document's history kept the server's copy of an item both held,
even when the browser had viewed it later, and skipped the write when the
length did not change. The combined history is now newest first by
added_at, one entry per item, and any content change is written.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PwaService imported isBackendUrl from the @iptvnator/services/server-storage
barrel, which pulled the whole storage subsystem (sync, migration, API
client) into main, though server mode loads it by dynamic import. The
helper now lives with the token helpers in @iptvnator/services
(re-exported from the interceptor): renderer.initialBytes 1,392,902 ->
1,357,795 bytes locally.
The remaining 11,988 bytes over #1931's baseline are what must run
before the first route (instance probe, connection status, token bound to
its instance, storage backend and write serialization); the baseline is
raised with a note, under the PR's perf-baseline-increase label.
The sign-in component provides the form-field defaults per component, as
master's check (#1928) now requires.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A write whose headers said saved but whose body (with the new tag) was
cut off was retired without confirmation: a newer queued edit kept its
steps and old tag, and a later conflict replayed the saved step over a
newer value from another device. It now counts as saved on an unknown
version: the landed steps drop out and the next write conflicts, so
only edits made after it replay. Writes that were unconditional stay so.
The keepalive path does the same.
A late answer to a request sent before an outage can set an unverified
page online; with nothing to sync the retry then never ran and the token
stayed withheld. The retry now also runs for online pages that are not
verified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A response whose headers arrived but whose body was cut off reports
success and then offline, which started the next immediate instance
check at once: repeated cut-off answers kept requests going and the
connection indicator flickering. The check on going offline now runs at
most once per retry interval; later failures wait for the timed retry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the storage API client reported an unreachable backend as offline.
When the first sign of an outage was an HttpClient call through the
interceptor (provider targets, playlist parsing) or one of PwaService's
raw fetches (Stalker, connectivity reset), the instance stayed verified,
and the next request after a replacement instance came up at the same
address would carry the old token.
Both now report no answer (status 0, a failed fetch) and a gateway's
502/503/504 as offline, so the session checks /instance first. The
backend's own JSON 502 for a failed provider is an answer and changes
nothing; neither does a fetch this page aborted on its own timeout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolves app-startup-status.component.ts: master replaced the progress
spinner with static markup (its import goes), the branch's router import
for the sign-in route stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
reportOffline() left `verified` set while the page was unauthorized, so
a sign-in retried after an outage skipped the instance check and could
send the old instance's token to a replacement database. Verification
is now cleared on any outage; only the state change stays guarded, so
the workspace remains locked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A suspended tab's retry timer could run before it was shown again: touch()
recreated the record another tab had removed when it adopted the page's
writes, so the page sent them again over newer edits. The facade now
treats a page whose record is gone as superseded: the queue sends nothing
(maySend hook), its journal and record are not written again, and the
retry timer reloads it like a page shown again.
A re-read answered after another tab saved an entry no longer writes the
older answer over the shared cache: entries changed while the read
travelled are kept, with an unknown tag, so the next write here
conflicts and replays instead of removing what the other tab saved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The recheck on going offline ran a full refresh: when /instance answered
but /user-state kept failing, each failed read set the page offline
again and started the next one at once, bypassing the retry interval.
It now checks the instance only; reads wait for the retry.
Sign-out navigates to sign-in itself, also when the page is signed out
already: a guard (unsaved Settings) may have cancelled the redirect the
first time, and the state change that drives it does not repeat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A background tab the browser suspended past the record lifetime can have
its writes adopted elsewhere, like a back-forward cache page: shown
again, it now checks resumed() and reloads instead of sending them.
Conflict replays appended a newly viewed item at the end of the history,
where the next 50-item cut dropped it; a replayed recently viewed list is
now kept newest first.
isFavorite() waits for the first user-data read like the list reads, so
a detail page opened during a slow first read does not toggle an
existing favorite the wrong way. A parked backup restore replayed when
the playlist opens waits too, and stays parked while no read succeeded.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
A conflict replay collapsed every step into one edit, so a closed tab's
adopted step lost `yields` after the first conflict and a second one
replayed its old position over a newer one. Its steps now stay as they
are, and only this page's own collapse into one edit on top of them.
The move combines recently viewed items one per item, keeping the newest
view and its artwork, then newest first; before, the server's older copy
of an item won over a later browser view.
A failed sign-in clears the stored token only on a 401: a network
failure says nothing about the token, and another tab may have stored
the same one meanwhile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Going offline now marks the instance unchecked: the token and writes
wait until /instance answers with this page's instance (another one
reloads the page). A replaced volume at the same address no longer gets
the old token and queued user-state writes on reconnect. The session
checks as soon as it goes offline, so a blip costs no retry interval.
A page restored from the back-forward cache whose record another tab
adopted meanwhile (its timers were frozen, so the record went stale)
reloads instead of sending those writes again after newer edits.
The Stalker playlist metadata migration also runs on server rows, once
per server: a desktop database copied to the server can predate it, and
its Stalker rows lacked isFullStalkerPortal.
The take-over and change-diff loops move out of the user-data facade,
and the adoption specs get their own file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sign-in that fails clears the token only while it is still the one
this attempt stored: another tab that signed in meanwhile keeps its
valid token.
The move orders the combined Xtream recent items by viewedAt, newest
first, as the app keeps them: browser views appended after the server's
history were cut by the next view once the server held 50.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Takes Angular Material off the renderer's startup path: renderer.initialBytes
1,596,201 -> 1,344,067 bytes (-252,134, 15.8%) at the branch point; merged with
master, where #1928 lands the form-field defaults per component, the branch
measures 1,345,807 (-188,797, 12.3% against master's baseline). The bundler
keeps each module in one chunk, so one eager import of any export carries the
module's whole code; each item removes the last eager importer of one Material
module.
- EPG progress and update panels: eager host + `@defer (when visible)` view
with an `@error` notice; the startup spinner is static markup copied from
mat-spinner.
- LazyMatDialog: the confirm dialog service, player service, parental lock
prompt and shell actions resolve MatDialog on first use.
- The theme emits tokens for the 21 imported components only, through a
`$material-components` list and meta.apply; tools/nx/check-material-theme-scope.mjs
(CI) keeps it in step with the imports in both directions.
- No provideAnimations(): Material 22 animates with CSS and reads
prefers-reduced-motion itself; apps/electron-backend-e2e/src/material-motion.e2e.ts
proves dialogs, selects, menus, snack-bars and tooltips animate in and out and
stop under reduced motion. Its renderer had deferred every DOM removal to the
end of the tick, so the catalog list now tracks the grid offset from scroll
events and the player settings panel decides "focus was inside" in a
microtask, both with specs that detach the view first.
- The snack-bar stays, and with it button, the icon-button chunk and the CDK
overlay (about 190 KB): root services inject it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Startup waits for the first user-data read only a few seconds. ready()
now waits for a first read in flight without starting one (an offline
page is not held up) and tells whether one succeeded:
- PwaXtreamDataSource awaits it before reading favorites, recent items
and playback positions, so a new device whose /user-state read is slow
does not show empty lists or start playback without its resume point.
- A PWA backup export and restore fail while no read succeeded; the
restore stays parked, since its replacement lists would otherwise
merge into the server's (an empty favorites list changed nothing).
A page-hide keepalive write gets the request timeout: a server that
takes it and never answers no longer holds every later write, re-read
and delete behind it until reload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PwaService joins paths to the backend URL without its trailing slashes,
so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names
another host, and it attaches the server token only when the request URL
resolves to the backend (isBackendUrl), like the interceptor.
The move's item key adds the Stalker series flag to id and category: a
provider category can hold a movie and a series of one id, and the
series favorite was dropped as a duplicate.
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>
The user-data cache is keyed by server instance
(`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on
the old backend after BACKEND_URL changed no longer writes over the new
server's cache, which the new tab's next edit would have sent there.
claimCache also removes the old owner's keys and the unscoped keys of
earlier builds.
touch(), which runs every 30 s, adopts closed tabs' saved writes once
this tab has read: a tab with nothing to sync never re-reads, so they
waited until a tab switch or reload.
UserDataStorage gains an optional ready(); the server storage resolves
it after its first read and rejects while that fails. A PWA backup
export awaits it, so a fresh browser whose /user-state read is slow or
failed cannot export empty favorites, history and positions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The access token is stored with the instance id it was given for, and a
page reads it only for its own instance: a tab still open on the old
backend after BACKEND_URL changed no longer sends the new server's
token there. The API client hands every 401 to reportAccessDenied(),
which now also sends a page without a token for its instance to sign-in,
while one that holds its token back until the instance is checked keeps
it.
A closed tab's adopted edits carry `yields`: replayed, they change only
what is still as that tab saw it, so they never replace progress this
tab or another device saved since. The flag survives the journal.
The sync spec's fake server and memory storage move to a test-helpers
file to keep the spec under its line limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser's legacy maps and the server's user-state entries are read
into null-prototype maps, so a playlist id such as `toString` is not
taken for an entry and a missing server value no longer resolves to an
inherited function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page started from memory (startedOffline) withholds the token until the
instance is verified, so sign-in on that page sent no token and the
server's 401 cleared the right one. Sign-in now first calls
ServerStorageSession.ensureVerified(): it reads /instance without a
token, marks the status verified when the instance id matches, and
reloads the page when another server answers at the address.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ServerStorageStatusService.accessToken() returns the stored token only
while server mode runs on an instance this start verified. The API
client, the HttpClient interceptor and PwaService's raw fetch calls use
it, so after an offline start from a remembered instance no request
(including the /instance reachability check) carries the token to
whatever answers at that address, and a 401 then does not clear it.
- Sign-in remembers the server along with the token; when storage refuses
either, it reports SIGN_IN_STORAGE instead of reloading into a start
that would drop the token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A server that answers as another backend URL or instance id than the
remembered one makes the browser forget the stored token, so server A's
token never reaches server B.
- The session installs its page-hide and heartbeat listeners before it
returns for sign-in, so a page holding another page load's writes still
marks itself closed on unload.
- Closed tabs are found by stale records only again: saved writes without
a record may belong to a running tab whose record storage refused, and
are left to that tab's own reload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The unconfirmed-writes registry stores each page load's record under
its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed
through a new keys() on the user-data storage adapter, so tabs never
overwrite each other's records. Saved writes without a fresh record,
also when the record was lost, are adopted as a closed tab's (#1920).
- PwaService sends the stored server token on its raw fetch calls only
while server mode runs, and a backend that answers without server
storage makes the browser forget the token.
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>
- PlaylistEffects.setFavorites$ applies the drag's order through
transformPlaylistFavorites (reorderFavorites): favorites added on another
device stay, ones removed there stay out.
- The remembered server matches the backend URL by origin and path
without trailing slashes, so /api and /api/ start offline in server
mode alike.
- Maps keyed by playlist id are built without a prototype (idMap), so an
id such as __proto__ is an ordinary key in the cache, conflict replays,
re-reads and tags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
withoutDeleted() builds its maps without a prototype, so a surviving
playlist whose id is __proto__ stays a key instead of changing the
object's prototype and losing its favorites and tag.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ServerUserDataStorage records each successful delete; a re-read sent
before it drops the deleted playlist's entries and tags from the server's
answer (withoutDeleted), so a late answer cannot write the deleted
favorites and positions back into the cache.
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>
* 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>
A page that never read the server has no version tags, so an adopted
write went out unconditionally and replaced newer server data with its
cached value. Adopted edits now use the never-matching version when this
page knows none (and for a queued write without a tag), so the server
answers 412 and the edits are replayed on its value.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When touch() finds that the previous page load no longer looks live, this
page may already have read and queued newer edits. Its saved writes and
early edits are now adopted like a closed tab's (their steps ahead of this
page's newer ones, queued at once) instead of restored as if this page
had just started, which replaced newer progress with older and parked
early edits that were never sent after the first read. Cache forgetting
moves into UserDataCache.forget().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the close flag could not be saved (storage full), a reload took the
previous page load for a running duplicated tab and left its saved writes
unsent. The page now remembers that page load and its 30 s touch takes it
over as soon as its record is closed, stale or gone, so the session's
retry sends the writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A page marks its unconfirmed-writes record closed on pagehide (not into
the back-forward cache); a new page takes over the previous page load's
saved writes only when that record is closed, stale or missing, so a
duplicated tab, which copies sessionStorage, leaves the running tab its
writes.
- PlaylistsService.removeAll lets queued playlist writes land first and
makes writes issued meanwhile wait until it finished; the server store
refuses a document without an id.
- Moving browser data sets the explicit Stalker portal-mode flag on rows
from before it (withExplicitStalkerPortalFlag, now shared with
PlaylistsService).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PUT /v1/user-state/:namespace/:playlistId answers 404 playlist_missing
when the server has no such playlist, so an edit queued on another device
(including its conflict replay with If-None-Match: *) cannot recreate the
state of a playlist deleted meanwhile. The client drops such an edit and
its cache entry instead of retrying; page-hide writes report it as gone.
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>
- PlaylistEffects.updateFavorites$ applies the toggled channel through
transformPlaylistFavorites, on the favorites read at write time, instead
of writing the reducer's whole array: a conflict retry keeps another
device's favorites (#1919).
- Edits made before the first read on top of this tab's recovered unsent
write are appended to it as their own steps, so a removal of an item the
recovered write adds survives a conflict replay.
- Journal copies of earlier page loads kept because this page could not
save its own are removed once it can.
- adopt/extend move into server-user-data-adopt.ts over a small queue API
(unsent, isQueued, put); the queue exposes its tag map.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A served copy carries the version it came from (a symbol property that
spreads keep and JSON drops), so a mutation of a portal document names
the version it was built on, not the latest read.
- A 401 for a token replaced since (a new sign-in) neither clears the new
token nor reports unauthorized, in the API client, the HttpClient
interceptor and PwaService's proxy calls.
- Moving browser data counts a browser user-data map that cannot be read
as a failure, so the move stays on offer.
- An edit made before the first read on top of this tab's recovered
unsent write is applied to that write, not to the server's value.
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>