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>
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>
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>
- 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>
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>
- 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>
* 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>
- Every playlist mutation is sent on the version it was read at: the
payload's ETag, else the playlist's last read (portal documents have no
payload). Refused versions are tracked per playlist, so a conflict on
one never disables the check on another at the same revision. Imports
and start-up migrations (dbUpsertAppPlaylists) stay unconditional.
- A closed tab's adopted edits replay before this tab's queued ones, from
the base of its first step, so newer progress is not replaced by older.
- A reload or adoption removes the old page load's saved writes only when
this page saved its copy; each closed tab is adopted once per page.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A favorite or rename (keepPayload PUT) is sent with If-Match on the
version it was read at. The server answers 412 when another device wrote
since; the store throws PlaylistWriteConflict and PlaylistsService's
per-playlist write queue runs the mutation again, which reads the
playlist afresh, so two devices' metadata edits both survive. A retry on
the same unread copy writes unconditionally, as before; refresh and
import writes stay last write wins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The 30 s retry also re-reads and flushes while online when the first
user-data read failed or writes the server refused (a 5xx) are queued;
the same failure notice shows at most every five minutes.
- Moving browser data adds a legacy snapshot alias only where no typed
snapshot exists, so a newer typed copy is not overwritten.
- writeServerAccessToken reports whether storage kept the token; the
sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales)
instead of calling a right token rejected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ServerUserDataStorage remembers the map getItem handed out. When the
cache changed since (another tab saved, a re-read), a write applies only
the entries the caller changed to the current cache, so a map read
before another tab's save no longer reads as deleting that save. The
cache moves into UserDataCache.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Steps of a write whose answer was lost, of saved writes taken over after
a reload, and of a closed tab's saved edits are marked uncertain, so a
later edit never joins them: adding and then removing an item after a
lost answer still removes it.
- A closed tab's saved edits are queued ahead of this tab's own even when
the cache already shows them, so a conflict replays them too.
- The tag map moves into UserStateTags.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A queued write keeps its edits as steps: an edit continuing the last one
joins it, one made behind a travelling write or on another tab's change
stays apart, and a conflict replays them in order. Saved writes and
early edits store the steps too.
- The instance probe treats any refusal but 404 (a proxy's 429 or 403) as
unreachable, so a browser keeps its remembered server.
- Cache reads that browser storage refuses fall back to memory, so a
re-read and later edits still work.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Unconfirmed writes and early edits are saved in localStorage per page
load; the tab remembers its page load in sessionStorage, so a reload
takes them over, and another tab replays a closed tab's writes once its
record is stale. Live tabs renew their record every 30 s.
- An edit made on another tab's unsent change, and an entry a re-read
keeps from the cache, are sent on a version no entry has, so the write
conflicts and is replayed instead of carrying another tab's change past
a later removal.
- Moving browser data counts the parental-lock step as failed while the
lock store cannot be read, so the move stays on offer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An edit queued behind a travelling write keeps its own before → after
and carries the travelling write's edit as `prior`, also in the saved
writes. A conflict replays both in order, so adding and then removing a
favorite removes it whether or not the first write landed, and a lost
write is not dropped.
- Page-hide writes now report their new tag or a 412 like normal writes:
a confirmed one rebases the next edit, a conflict is replayed.
- After an offline start from a remembered instance, writes are held and
the retry only checks /instance before reloading, so saved writes never
reach another database behind the same address.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A re-read that keeps an entry from the cache (another tab's or this
tab's unconfirmed value) no longer takes the server's tag for it, so the
next edit conflicts and is replayed instead of overwriting another
device's change.
- Page-hide writes behind a travelling one are taken from the queue when
it is answered, so its new version becomes their baseline.
- A playlist delete holds that playlist's user-data edits instead of
discarding them up front; only a successful delete drops them, a failed
one sends them as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- User-state entries carry a content tag; PUT/DELETE accept If-Match or
If-None-Match: * and answer 412 with the current value and tag. The PWA
sends each edit on the tag it read and replays it (before → after) on
the server's value after a conflict, so another device's change to the
same playlist's favorites or positions is kept instead of overwritten.
- The access token goes only to the backend URL (same origin and path),
never to another host or a sibling path.
- Deleting a playlist (or all) first drops its queued user-data edits and
waits for writes in flight, so none recreates deleted data.
- The migration copy writes on the tags it read.
- The write queue moves out of ServerUserDataStorage into
UserDataWriteQueue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Unconfirmed writes and early edits are kept in the tab's own
sessionStorage, so tabs never clear each other's; a shared registry of
unconfirmed entries (one record per tab, ignored after ten minutes)
keeps a re-read in one tab from replacing another tab's values.
- A cache value too big for localStorage stays readable from memory, so
later edits are diffed against what the app reads.
- Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so
a reference not yet rewritten still resolves and a retry can type it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Edits made before the first read are saved (before → after) and
restored after a reload; a second early edit keeps the first one's
baseline, even an empty one; a cache-full first read still applies them
to the server's value.
- Queued writes are saved as soon as they are queued, and a re-read keeps
entries another tab has not confirmed yet.
- Legacy Xtream ids stored as numeric strings are typed too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Edits made before the first successful user-data read are kept as
before → after and applied to the server's value once it arrives
(applyEdit), so a new device or a stale cache adds a favorite instead of
replacing the server's list.
- Saved unsent writes keep only the newest per entry; an answered older
write no longer retires a newer one still travelling.
- A late failure after sign-out no longer turns unauthorized into offline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Moving a playlist under another server id takes its parental locks along
(they stay in the browser).
- Bare legacy Xtream ids in each copy are typed from that copy's own
snapshots before favorites, history and snapshots are combined, so a
live channel and a movie sharing an id both survive.
- Writes still travelling are saved on page hide too; saved writes and the
cache belong to one server database (cache owner = instanceId).
- A late successful answer no longer turns unauthorized back to online.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- User-data writes the server has not confirmed are kept under
iptvnator-server:unsent-writes and sent first on the next visit.
- Moving browser data does not start while user-data writes are unsent,
and a duplicate of a failed upload counts as failed, not merged.
- The session reaches localStorage through the lazy adapter, so a browser
that denies storage still boots.
- Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail
server shortcut and the offline banner each leave an @if anchor in every
build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>