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>
* 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>
* 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>
The offline banner's own stylesheet added a <style> element to every
launch, one of the two mutations the launch journey counted on top of
master. Its rules move into the workspace shell's stylesheet.
The other one is a parser-inserted modulepreload link: the new lazy
server-storage chunks split one shared initial chunk in two. A/B on
macOS: master 555, this branch 556 (was 557); the baseline note now
says so instead of blaming @if anchors, which cost nothing here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): resume an episode whose connection drops instead of skipping it (#1902)
Embedded MPV sessions run with keep-open, so a provider that closes the
connection mid-episode (and refuses ffmpeg's own reconnect) reaches the app
as eof-reached: status `ended` at the drop position. The reconnect policy
counted `ended` as a loss only for live playback, so on VOD it stood for
the end of the file and series autoplay started the next episode.
- The reconnect policy now treats a VOD `ended` with more than
max(10 s, 1 % of the duration) left as a dropped connection and reloads
it at the last position. A reload that ends again before getting 5 s past
where it resumed proves the file ends there (bitrate-estimated duration),
so it is left as the end and autoplay continues after one attempt.
- The service passes the snapshot duration to the policy.
- The player emits playbackEnded only for an `ended` session without a
pending reconnect.
- Xtream mock: `streamdrop` scenario whose episodes drop a third of the way
into a local 60 s clip and refuse the retry for 4 s; Electron E2E covers
the native-view engine (skips without an Embedded MPV runtime).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(playback): keep the early-EOF retry on a refused reload or after a seek
The resume point that tells a reload ending where it resumed from a new
drop was recorded before the reload ran, so a reload the addon refused made
the original EOF look like the end and the next poll cancelled the retry.
Record it only once the reload started, and forget it when the user seeks:
an EOF after a seek is judged on its position alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): fail the stream-drop resume check when the reload restarts the episode
A reload that started the episode over would also reach the 25 s mark
within the timeout, since the mock drops each episode only once. Every
position played after the drop must now stay within 3 s of it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
* fix(workspace): slide the content and header into place when a source opens
Opening an Xtream portal from the dashboard inserted the 323 px context
panel and the header Back in the frame that swaps the page: the content
pane jumped right by the panel's width and the switcher, search and actions
by Back's slot. That one frame was J2's whole layout shift (0.2327: 0.222
for the content pane, 0.011 for the header, scored with the pane's
distance), plus 0.0001 when the category search and sort buttons appeared
with the categories.
LayoutSlideDirective animates these moves as a 180 ms FLIP: the new layout
applies at once and only a transform animates, so nothing waits and no
layout shift is recorded. The content surface covers the strip it uncovers
while sliding, Back fades in, and reduced motion keeps the jump. The
category search and sort buttons render disabled while the list loads.
Locally J2 renderer.layoutShiftScore reads 0 (was 0.233), J1 0 / 0, J3
0.001, J4 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* perf(journeys): lower J2 layoutShiftScore to 0
The slides land on the runner: run 38147072417 (d2cb9445d) reads
open-source/renderer.layoutShiftScore 0 with no shift recorded in all 5
measured iterations (J1 0 / 0, J3 0.001, J4 0 unchanged). Ratchet the
baseline from 0.233 to 0 and record the perf:compositing evidence for the
slide in the shell contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
- 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>
upgradeContentTitleFtsTokenizer recreates and rebuilds content_title_fts and
records migration:content-title-fts-remove-diacritics:v1, but not
migration:content-title-fts-trigram:v1. On a new profile the tokenizer
upgrade therefore did the first rebuild, and on the second launch
ensureContentTitleFts found its own marker missing and rebuilt the whole
index again, synchronously inside initDatabase on the main thread, right
after the first catalog import. A trigram rebuild costs about 4.5 s per
million titles (sqlite3 3.51, synthetic titles). Every J4 iteration paid it
too, since each launches a copy of a freshly seeded profile.
The upgrade's transaction now records both markers. The skipped-release
upgrade harness counts rebuilds across every initialization of a profile
and requires exactly one; it fails for all six cases without the fix.
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): wait for the persisted Xtream import status, not only the overlay
waitForXtreamImportToFinish gave the loading overlay 5 s to appear and then
treated "no overlay" as finished. The overlay appears only once the portal's
account check answers; a freshly started mock generates the large catalog on
that first request, so J4's seed sometimes closed the app while the series
import was still running. The template then had no series rows, and J4's
query for "system" (series titles only) returned an empty list on the first
launch after seeding.
On an Xtream route the wait now also requires the import status the app
persists to read completed for live, movie and series. The new
xtream-import-wait.e2e.ts holds get_account_info past the overlay window and
fails without the fix.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): judge the Xtream catalog complete the way the app does
Review follow-ups on #1897:
- A refresh deletes the catalog and re-imports it without resetting the
persisted import statuses, so `completed` alone proves nothing after one.
A type now counts as imported when its status reads completed and it has
rows, the same rule the app uses before reusing its cache; a type without
categories (a live-only panel) counts by status, and a portal without any
category is not imported yet. A second spec refreshes a portal behind the
slowed account check. Today the refresh keeps its preparation overlay up
across that check, so it passes with the earlier wait as well; it guards
the helper's contract.
- The proxy is closed in its own finally, so a failed launch or close no
longer leaves it listening.
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>
- 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>