Commit Graph
562 Commits
Author SHA1 Message Date
4gray de87c1e4d3 Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa
# Conflicts:
#	docs/architecture/pwa-self-hosted.md
2026-10-13 09:14:50 +02:00
4grayandClaude Opus 5.5 8bf92f9d65 fix(backup): export and restore Xtream user state in the PWA (#1905)
* 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>
2026-10-13 09:07:49 +02:00
4grayandClaude Opus 5.5 ebb1ab3467 fix(portals): hide provider placeholder metadata (N/A, -, 0) in details and cards (#1923)
* 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>
2026-10-13 08:06:03 +02:00
70e771ff27 perf(search): one 300 ms debounce between typing and the global search query (#1899)
* 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>
2026-10-13 08:04:48 +02:00
4grayandClaude Opus 5.5 586ba74ea7 fix(server-storage): adopted writes without a known version conflict instead of overwriting
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>
2026-10-13 00:37:05 +02:00
4grayandClaude Opus 5.5 2320b64e01 fix(server-storage): adopt a late previous page load instead of restoring it
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>
2026-10-13 00:28:00 +02:00
4grayandClaude Opus 5.5 d45210c4f7 fix(server-storage): take over a reload's writes once the previous page load stops looking live
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>
2026-10-13 00:12:40 +02:00
4grayandClaude Opus 5.5 37c14d7d89 fix(server-storage): keep duplicated tabs off live journals, drain writes around remove-all, normalize legacy Stalker rows on the move
- 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>
2026-10-12 23:59:18 +02:00
4grayandClaude Opus 5.5 f956758cce fix(server-storage): refuse user state for deleted playlists
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>
2026-10-12 23:43:43 +02:00
f60d797011 fix(workspace): don't flash the import overlay on a cached Xtream open (#1906)
* 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>
2026-10-12 23:32:50 +02:00
4grayandClaude Opus 5.5 716580cb16 fix(server-storage): favorite toggles as deltas, early edits as steps on recovered writes, release kept journal copies
- 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>
2026-10-12 23:30:31 +02:00
4grayandClaude Opus 5.5 115465755b fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes
- 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>
2026-10-12 23:11:26 +02:00
4grayandClaude Opus 5.5 434cbf3681 fix(playlist): keep cleared optional fields cleared on desktop (#1904)
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>
2026-10-12 23:08:35 +02:00
b546fe0123 fix(m3u): keep groups hidden in Manage groups after a restart (#1894) (#1907)
* 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>
2026-10-12 23:06:39 +02:00
4grayandClaude Opus 5.5 067190e1df fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied
- 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>
2026-10-12 21:31:49 +02:00
4grayandClaude Opus 5.5 7afb9758f4 fix(server-storage): conditional playlist metadata writes, retried on a fresh read
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>
2026-10-12 21:17:18 +02:00
4grayandClaude Opus 5.5 2d6f28f8c8 fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store
- 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>
2026-10-12 21:00:33 +02:00
4grayandClaude Opus 5.5 70fabb1424 fix(server-storage): apply a caller's edits to the cache as it is now
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>
2026-10-12 20:45:52 +02:00
8508010ea5 fix(playback): resume an episode whose connection drops instead of skipping it (#1902) (#1908)
* 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>
2026-10-12 20:23:30 +02:00
4grayandClaude Opus 5.5 8deb194312 fix(server-storage): keep steps of possibly-landed writes apart, queue closed tabs' edits even when the cache shows them
- 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>
2026-10-12 10:16:13 +02:00
4grayandClaude Opus 5.5 72b0d2102a fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory
- 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>
2026-10-12 10:03:41 +02:00
4grayandClaude Opus 5.5 b1b05600d6 fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability
- 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>
2026-10-12 09:49:21 +02:00
4grayandClaude Opus 5.5 487c98ab96 fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start
- 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>
2026-10-12 09:31:45 +02:00
4grayandClaude Opus 5.5 a8b6b86e43 fix(server-storage): keep tags with their values, rebase page-hide writes, hold deletes
- 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>
2026-10-12 09:10:48 +02:00
4grayandClaude Opus 5.5 0183ddc61d docs(server-storage): conditional user-state writes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:58:16 +02:00
4grayandClaude Opus 5.5 f49ad59549 fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases
- 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>
2026-10-12 08:33:32 +02:00
4gray cedc468325 Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa
# Conflicts:
#	libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
2026-10-12 08:19:36 +02:00
4grayandClaude Opus 5.5 2c805e0b61 fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids
- 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>
2026-10-12 08:17:29 +02:00
6fe684af51 fix(workspace): slide the content and header into place when a source opens (#1895)
* 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>
2026-10-12 08:15:18 +02:00
4grayandClaude Opus 5.5 50fd1c4a63 fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out
- 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>
2026-10-12 08:05:24 +02:00
4grayandClaude Opus 5.5 21f640ccc1 fix(server-storage): locks follow merged playlists, typed legacy Xtream ids, instance-scoped cache
- 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>
2026-10-12 07:52:35 +02:00
a9f4ae38ea perf(database): rebuild the title index once per profile, not again on the second launch (#1900)
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>
2026-10-12 07:37:27 +02:00
5f55553f41 test(e2e): wait for the persisted Xtream import status, not only the overlay (#1897)
* 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>
2026-10-12 07:37:22 +02:00
4grayandClaude Opus 5.5 abae207d02 fix(server-storage): unsent writes survive reloads, guarded move, lazy storage
- 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>
2026-10-12 07:36:18 +02:00
4grayandClaude Opus 5.5 72c84a3eba fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies
- Moving M3U favorites keeps URL favorites and anything the server cannot
  resolve; only older channel ids are translated.
- Queued user-data edits are sent before the move writes combined values.
- A re-read that does not fit the cache keeps the old cache and baseline
  together.
- A response body cut off after its headers counts as offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:00:07 +02:00
4grayandClaude Opus 5.5 4afd4bcc33 fix(server-storage): cache baseline before first read, versioned retries, M3U favorite ids
- Before the first user-data read finishes, an edit is compared with the
  cache, so it sends only its own playlist entry.
- Queued entries carry their edit number; a failed write is retried only
  when nothing newer replaced it.
- Moving M3U favorites and history translates channel ids through the
  stream URL (ids are random per import).
- The server-storage E2E runs in Chromium only: one shared database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:47:19 +02:00
4grayandClaude Opus 5.5 6820b87005 fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes
- Moved playback positions are matched by content type and Xtream id and
  name the server playlist.
- Page-hide writes report whether the server took them; refused ones go
  back into the queue. Re-reads run one at a time.
- The access guard's 401 carries WWW-Authenticate: Bearer
  realm="iptvnator"; the interceptor and PwaService's raw fetches turn it
  into sign-in, so a token changed on the server is noticed on provider
  calls too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:29:55 +02:00
4grayandClaude Opus 5.5 fc32934ce3 fix(server-storage): merge user data on move, keep failed writes, sign-in route gate
- A merged playlist gains the browser document's favorites and history;
  Xtream values for one server entry are combined (lists unioned, maps
  merged, the server winning conflicts) and written once.
- A failed user-data write stays queued and survives a re-read; the
  page-hide flush waits for a batch still travelling.
- The start-up screen lets the sign-in route through whatever its check
  reports.
- The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:56 +02:00
4grayandClaude Opus 5.5 676e3adcd4 fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes
- Moving browser playlists matches them to the server's by id or source and
  carries the legacy Xtream maps under the server id; it no longer goes
  through the backup format, whose PWA export has no Xtream user data. A
  partial move stays on offer.
- User-data batches go out in order, and a re-read keeps entries edited
  while it was in flight.
- A keepPayload write keeps its cached copy only when the server replaced
  the version its payload came from (new X-Replaced-ETag header).
- 502/503/504 from the proxy count as offline.
- The start-up screen lets the sign-in page through while a token is
  required.
- Settings → Server offers a reload after a move.
- The probe detects Electron through RuntimeCapabilitiesService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:56 +02:00
4grayandClaude Opus 5.5 3bfd5394ef fix(pwa): stay in server mode when a known server does not answer
A failed start-up probe used to fall back to browser mode, which showed an
empty library while the backend restarted and let new playlists land in the
browser. The browser now remembers the server; without an answer it starts
in server mode, offline, and reloads once the server is back. Settings →
Server shows the backend address instead of the page's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:56 +02:00
4grayandClaude Opus 5.5 8714b3b445 docs(self-hosted): server storage mode, sign-in and the release note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:56 +02:00
4grayandClaude Opus 5.5 b88a1c81e5 feat(self-hosted): server storage API in web-backend (#1898)
* feat(interfaces): add the server storage HTTP contract

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): read server storage configuration

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): open the server database and track revisions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): playlist and user-state repositories

Playlist documents use the shared desktop row codec, so the server writes
the same rows. keepPayload upserts take the stored playlist/items/header
fields when the client leaves them out. Xtream user data lives in the
server-only server_user_state table, one JSON value per namespace and
playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): storage error mapping and access token guard

Storage errors map to JSON status bodies and are logged by name and code
only. An optional bearer token guards every route except /, /health,
/config.js and /v1/instance, provider proxy routes included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): serve the /v1 server storage API

/v1/instance is always public and reports whether server storage is on.
With storage, /v1/playlists serves whole documents with ETags and 304s,
accepts keepPayload upserts, and deletes playlists with their user state;
/v1/user-state and /v1/app-state store the rest. The proxy routes are
unchanged unless an access token is configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web-backend): open server storage on start and close on shutdown

With IPTVNATOR_STORAGE=server, main.ts opens the database before it
listens and exits with a clear message when the data directory is not
writable. SIGTERM and SIGINT close the server and the database. The
native better-sqlite3 module stays outside the esbuild bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): match weakened ETags on playlist reads

nginx weakens the ETag of a gzipped response, and clients send the weak
form back in If-None-Match. The route now compares tags weakly, as RFC 9110
requires for GET, so it answers 304 before reading the document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(docker): persist server storage on a volume

The image builds better-sqlite3 in a native stage per target platform,
enables server storage on the /data volume, and raises the nginx body
limit to 64 MB for /api with gzip for JSON. The compose file mounts a
named volume and documents the optional access token. The PWA does not
use the storage yet; docs say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web-backend): type the storage route responses

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): harden server storage reads, deletes and app state

- Metadata reads return the stored document without its payload fields,
  so fields that live only in the JSON survive a metadata round-trip.
- A conditional GET checks that the playlist exists before answering 304.
- Deleting a playlist and its user state commits in one transaction; the
  ETag moves only after the commit.
- Browser app state is stored under a client: prefix and the instance id
  is repaired when empty, so a client write cannot stop the backend.
- User-state buckets have no prototype, so ids like __proto__ round-trip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(self-hosted): keep the access token unset until the PWA sends it

Add the release note for the Docker server storage volume.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): bound shutdown and reject padded playlist ids

- SIGTERM closes idle connections at once and cuts the rest after five
  seconds, so a long provider download cannot keep the backend running.
- Playlist ids with surrounding whitespace are rejected: the row codec
  trims ids, so storage and ETags would track different keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): clear optional columns on whole-document upserts

The row codec maps empty values to undefined, which ON CONFLICT DO UPDATE
skips, so a cleared User-Agent, Referrer or credential kept its old column
value and came back on the next read. Server upserts now write NULL to
every replaceable column the document no longer sets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(self-hosted): linear token parsing, no-store app state, longer start

- The access guard reads the Bearer token by string scanning instead of a
  regular expression that CodeQL flagged as polynomial on long whitespace.
- GET /v1/app-state/:key sends Cache-Control: no-store like the other
  state routes, so devices never reuse a stale value.
- The entrypoint waits up to IPTVNATOR_BACKEND_START_TIMEOUT (300 s) for
  /health, because the backend migrates its database before it listens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(self-hosted): metadata reads skip payloads, redact JSON parse warnings

List playlist metadata from a server-only payload-free copy of each
document instead of running json_remove over every payload blob, and log
only the error name when a stored JSON value fails to parse, since the
parser's message quotes the stored text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web-backend): copy carried-over panel clock into playlist metadata

When an upsert keeps the stored serverTimezone, build the metadata copy
from the saved payload so metadata and full reads agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:05:35 +02:00
4grayandClaude Opus 5.5 4e9795ac54 fix(playback): wait for mpv's reply to every reused-instance IPC command (#1876)
Every reused-instance MPV IPC command now carries a request_id and waits, within the existing 2 s bound, for mpv's matching reply; an error or missing reply falls back to a fresh launch like a socket failure, and the racing post-loadfile seek is gone. MPV launches run one at a time through a launch queue so overlapping launches never act on the same child; Stop on a queued launch delegates to the in-flight launch's own closer, a failed exit stays retryable, and the dock keeps the newest launch. Position polling and output-error reporting moved out of mpv-session.service.ts, which leaves the max-lines baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:42:43 +02:00
4grayandClaude Opus 5.5 94570bc387 refactor(storage): seams for server-backed playlist storage (#1896)
* docs(storage): server storage design and implementation plans

Design for keeping playlists and user data on the self-hosted server
(#305), revised against master, plus three implementation plans: storage
seams, the web-backend storage API, and the PWA server mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(database): share the playlist row codec

Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers
from the Electron database operations into libs/shared/database
(@iptvnator/shared/database/app-playlist) so the self-hosted web backend
can write the same rows. The Electron modules re-export them; behavior is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(database): read the data root from IPTVNATOR_DATA_DIR

Deployments that keep the database on a mounted volume, such as the
self-hosted web backend in Docker, can now set the data root. The E2E
override still wins; without either variable the root stays ~/.iptvnator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(services): add the playlist storage backend resolver

PlaylistStorageBackend becomes the single place that decides whether
playlists persist through the Electron bridge or IndexedDB, and defines
the PlaylistDocumentStore contract a server store will implement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(services): resolve playlist storage through one backend

PlaylistsService asks PlaylistStorageBackend for its document store
instead of reading window.electron and supportsSqlite directly. Paths that
only make sense on the desktop (legacy IndexedDB import, worker deletion
with progress, the favorites-channel query) check for the Electron kind
explicitly. Behavior is unchanged: the backend still resolves to the
Electron bridge or IndexedDB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(xtream): store PWA user data through USER_DATA_STORAGE

PwaXtreamDataSource and PwaCollectionStorage read and write favorites,
recently viewed items, collection snapshots and playback positions through
the new USER_DATA_STORAGE token (default: localStorage) instead of calling
localStorage directly, so a deployment can substitute a server-synced
storage. Behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(pwa): document the storage seams

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(database): bracket access in the shared playlist codec

libs/shared/database compiles with noPropertyAccessFromIndexSignature,
which the Electron backend did not enforce on the moved code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(electron): alias the shared playlist codec in the worker bundle

The database worker is bundled by esbuild with an explicit alias table;
without the new entry, @iptvnator/shared/database/app-playlist resolved
under the library's index file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): read localStorage lazily in USER_DATA_STORAGE

When the browser denies storage access, reading localStorage itself
throws. The default factory returned the global, so injecting the token
failed and PwaXtreamDataSource could not be constructed. The adapter now
reads the global inside each call, where the data source already guards
its reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(database): describe /data as an example data root

The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with
the server storage backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:03:58 +02:00
4grayandClaude Opus 5.5 633fde3e0e test(e2e): wait for scrollend between scroll keys (#1893)
Chromium drops a keyboard scroll pressed in the frame or two after the
previous smooth scroll lands but before Blink handles the compositor's
finish: ScrollAnimator retargets the finished curve
(kRunningOnCompositorButNeedsUpdate), then the late
CompositorAnimationFinished treats that state as finished and resets it.

The Live TV responsiveness spec pressed the next key as soon as an
expect.poll saw scrollTop change, so a poll that sampled the final frame
sent PageDown or Home into that window (2 of 5 local runs on master, all
three CI attempts on PR #1886).

pressScrollKey presses a scroll key and waits for that scroll's
scrollend. The Live TV spec uses it for PageDown, PageDown and Home with
the same position assertions; the VOD detail spec swaps its stable-frame
fence for it. The validation map documents the race.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:04:48 +02:00
4grayandClaude Fable 5.1 dd815676b4 perf(tmdb): send catalog title matches in small requests (#1891)
CatalogTitleMatchService.matchTitles sent a caller's whole batch as one DB_MATCH_TITLES request, and the DB worker answers one request at a time: a recommendations batch held every other read for 95 s on a 3.9M-title library. The service now deduplicates the titles, keeps the 200-title cap, and sends requests of five titles one after another, concatenating the answers in order. The answer stays all-or-nothing: a failed request returns no matches, a lock that starts withholding everything stops the batch, and a batch starts over when ParentalLockService.version() changes between requests (no matches after three changing attempts).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:04:31 +02:00
4grayandClaude Opus 5.5 43358e10f2 fix(ui): welcome screens and drop overlay follow the app theme (#1886)
* fix(ui): welcome screens and drop overlay follow the app theme

The welcome dashboard, the empty Sources page and the playlist drop
overlay switched on `prefers-color-scheme`, so with the app set to dark on
a light OS (or light on a dark OS) they painted the other theme's colours.
They now read `--app-*` tokens, which follow the `.dark-theme` class set
from Settings, and the hard-coded blues are derived from the selection
colour (a local "strong" accent mixed toward the heading ink keeps chips
and filled labels at 4.5:1 in both themes).

White rgba() fills that vanished in the light theme (season empty panel,
catalog refinement chips and menu divider, Xtream archive banner and
disabled paginator icons, shell download-activity track, search field)
now use the widget surface, on-surface mixes or the search tokens.

Reads of custom properties that nothing declares are fixed: the release
notes error, the search-layout empty state and the collection reload dim
now use declared tokens; unset hooks are replaced by their fallback value.

New guard `pnpm run styles:theme-references:validate` (CI) rejects
undeclared var() reads and prefers-color-scheme outside the settings
resolver. Electron E2E os-color-scheme.e2e.ts flips the OS scheme under
each explicit app theme and checks colours, contrast and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): retry the rejected-drop comparison when the card dismisses mid-read

The rejection hides itself after 1.8s, and the OS-scheme comparison reads
the overlay twice with an emulateMedia call between. A slow runner could
lose the card between the reads; the comparison now drops again until both
reads see it. A colour that follows the OS still fails every attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): measure welcome text contrast from rendered pixels

The feature and source cards paint gradients, which a backgroundColor
walk ignores, so card titles, descriptions and chips could pass while
falling short on the actual card. Every text on the three surfaces is now
measured against the pixels under it, as the filled button labels were.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): scan only runtime sources in the theme-references guard

The guard read every tracked file under apps/ and libs/ except specs, so
an E2E, mock-server or test-helper declaration could satisfy a runtime
var() read that nothing in the app declares (dashboard-rail-focus.e2e.ts
sets --cover-rail-width), and a test-only read could fail the check. It
now skips spec/test/e2e files, test helpers and stubs, testing projects,
the E2E and mock-server apps and the marketing website.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(xtream): set live paginator tokens through mat.paginator-overrides

The live layout hand-declared --mat-paginator-* tokens, which the UI
guidelines route through the overrides mixin so a mistyped name fails the
build instead of silently doing nothing. Same values, same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tools): skip every test-only naming convention in the theme guard

The runtime scan still read .stub, .harness, .fixtures, .spec-stubs,
.spec-helpers, .spec-fixtures, test-setup and test-double sources, and
test-stubs/ or *fixtures/ directories, so a declaration in one could mask
an undeclared runtime read. A file is now test-only when a dot segment
after its name marks it (spec, stub, fixture, harness, mock, spec-*,
test-*, *-fixtures), its stem is a test bootstrap, or it sits in a test
directory. Runtime names such as xtream-connection-test.service.ts stay
in the scan; no runtime source imports an excluded file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep welcome card text legible while hovered

At the 16%/10% hover tint the dark theme's body text on a feature card
measured 4.45:1. The hover fill steps up to 10%/6% instead (4.78:1 in
dark, 6.6:1 in light); lift, border and shadow carry the rest. The E2E
now measures feature and source card text while hovered, in both themes;
with the old tint it fails at 4.45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:22:23 +02:00
4grayandClaude Opus 5.5 f1a5b29be4 fix(playback): diagnostic cards wrap translated labels and fit short players (#1892)
* fix(playback): size diagnostic cards to the player and wrap their labels

The recovery cards ellipsized every translated action wider than their
124px label column (de, ru, hu, pt, pl, by and others), and the banner
took its padding from the window rather than the player. In a short inline
player just wider than the 760px breakpoint that squeezed the content
until it overflowed, and align-items: center pushed its top above the
scroll origin.

The panel host is now a size container over the player: padding uses
container units, a compact layout applies to players up to 560px tall,
auto margins centre the content without clipping its top, labels wrap,
and the cards grow to 240px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep short diagnostic players at full content width

Measure the layout E2E with the whole banner translated, not only the
card labels: the badge, headline, description, hints and utilities set the
banner's height too. With them, a by banner in a 920px-wide, 404px-tall
player had 45px to spare, because 18cqi side padding left a 588px column.
Short players now grow their side padding from 16px at the 760px
breakpoint, which keeps the 720px column and about 90px spare.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:32:04 +02:00
4grayandClaude Opus 5.5 c76cf357b7 refactor(workspace): remove standalone-layout branches no route can reach (#1889)
* refactor(workspace): remove standalone-layout branches no route can reach

Every routed view sits under the /workspace route (the other top-level
paths are redirects), so isWorkspaceLayoutRoute() was true for every
caller. Global search became a route on 2026-01-12 and the last route
outside the workspace went with the home component on 2026-03-02;
nothing opens SearchResultsComponent in a MatDialog.

Removed as unreachable:
- isWorkspaceLayoutRoute() and the route's data.layout marker; the
  channel list, Xtream live layout and unified collection read the q
  query param directly, the clear-view command no longer checks a flag
  that was always true, and the M3U EPG guide header action keeps only
  its supportsEpg gate.
- The playback sidebar's playlist header (Home button and playlist
  switcher, showPlaylistHeader, its stylesheet and the macOS padding
  rule). The switcher itself stays: the workspace header uses it.
- Xtream search's dialog mode (MAT_DIALOG_DATA initialQuery, dialogRef,
  close buttons) and inline search input; Back is offered whenever the
  search is not the global one, as before.
- Stalker search's inline-input flag (Back is always offered, as before).
- SearchLayoutComponent's showSearchInput, showCloseButton, closeClick,
  searchTermChange and focusSearchInput, and SearchFormComponent, which
  only it rendered.
- SETTINGS.BACK_TO_HOME and PORTALS.SEARCH in all 19 locales, the
  zoneless checklist entry for the deleted form, the specs that only
  covered these paths, and two Electron E2E assertions that the deleted
  form is absent.

Behaviour of every reachable path is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): drop the macOS host class and switcher rule nothing uses

The app root's `macos-platform` host class only fed the removed sidebar
header's padding rule, so no stylesheet reads it any more. The shared
portal-sidebar partial's `.sidebar-header .switcher` rule matched no
element: the only `switcher` class lived in that removed header, which
used `.current-playlist`, not `.sidebar-header`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:07:57 +02:00
4grayandClaude Opus 5.5 7abf479de9 fix(ui): one focus ring colour everywhere via the shared mixin (#1882)
* fix(ui): one focus ring colour everywhere via the shared mixin

#1866 gave the app a keyboard focus ring token (`--app-focus-ring`, at
least 3:1 on every surface), but 59 component rings still drew their own
colour: the selection blue under several names (`--app-selection-color`,
`--mat-sys-primary`, `--app-selection-border`, the EPG's `$accent-blue`,
`--embedded-mpv-accent`, `--apd-accent`, the hero's `--accent-color`),
the heading colour, or the overlay's literal text colour. The selection
blue falls to 2.6:1 on the stronger selection tint.

- Every one now includes `focus-ring.focus-ring-declarations`, keeping its
  offset; the projects that newly import `libs/ui/styles` declare
  `ui-styles`.
- The mixin reads the token alone: it is declared on `html` in both
  themes, and the fallback chain cost bytes in every ring.
- `tools/nx/check-focus-ring-colour.mjs` joins `styles:focus-visible:
  validate`: an outline in a focus rule must use the token or, over
  video, the player's `--pc-*` palette. Three deliberate exceptions are
  listed with their reasons, and a stale one is reported. On master it
  reports these 59 rings.
- The keyboard-focus E2E asserts each ring's colour equals the token
  where it is drawn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): search fields and shadow rings take the focus colour too; guard reads them

Local review (Greptile P2): the ring-colour guard read only `outline`, so a
focus indicator drawn as a shadow or a border kept any colour. Search
fields showed focus as a `--mat-sys-primary`/selection border with a 12-16%
halo, the EPG guide's keyboard cell as an inset `$accent-blue` shadow, and
the downloads cards tinted their artwork border with the M3 primary.

- Every one now uses `--app-focus-ring` (the halos and tints keep their
  strength through `color-mix()`); over video the panel's search border
  mixes the overlay's own ring colour.
- The guard reads a focus rule's outline, its unblurred ring or line
  shadows and its border colours. Neutral boundaries (separator and
  widget-border tokens, transparent, currentColor) and blurred lift
  shadows are not indicators. On the previous commit it reports these 21
  declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check every focus colour on its own; count exceptions only where they excuse a ring

Greptile on #1882 (2×P2):

- The guard joined a declaration's colours and accepted the lot when the
  token appeared anywhere, so `border-color: var(--app-focus-ring) red`
  or a `color-mix()` of the token and red passed. It now splits each
  declaration into plain colours, every `color-mix()` argument included,
  and checks each one: the token, the player palette or a neutral
  boundary. An outline or shadow ring without a colour is drawn in the
  text colour, which only a border may keep.
- An exception counted as used when its value appeared in any
  declaration (the diagnostic's amber is also a text colour), so a stale
  one was never reported. It now counts only where it excuses an
  off-token focus indicator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(settings): the row a search result reveals takes the app focus ring

Codex on #1882 (P2): choosing a settings search result with Enter focuses
its row by script, and the keypress keeps `:focus-visible`, so the row's
60%-transparent selection outline was a real keyboard focus ring under
3:1, not the passive marker its guard exception described. The row now
includes the shared mixin (keeping its 12px radius), the exception is
gone, and the keyboard-focus E2E reveals a row with Enter and asserts the
app ring on it; with the old rule it fails on the 60% colour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): accept focus colour tokens by exact name

Greptile (local, P2): the guard matched `--app-focus-ring` and the neutral
boundary tokens by prefix, so `var(--app-focus-ring-other, red)` or
`var(--app-separator-strong)` passed. It now reads the property a `var()`
names and accepts exactly `--app-focus-ring`, a `--pc-*` palette token or
one of the four neutral boundary tokens; their fallbacks are never drawn,
since the tokens are always declared, so they are not checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): check the body of a focus mixin, the shared ring included

Greptile (local, P2): a mixin body has no selector and its includes are
not expanded, so the shared `focus-ring-declarations` itself escaped the
colour check; turning it red would change every ring and pass. The
walker now names the mixin a declaration sits in, and the colour guard
treats the body of a mixin whose name mentions focus as a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read logical border sides in the focus colour guard

Greptile on #1882 (P2): only `border`, `border-color` and the physical
sides were read, so `border-inline-start: 2px solid red` in a focus rule
passed. The guard now reads every colour-carrying border property: the
shorthand and the physical and logical sides, with or without `-color`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): an exception excuses its own colour, not the declaration

Greptile (local, P2): an exception matched the whole declaration, so in
the player stylesheet `box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px red`, or
the white mixed with red, passed. Exceptions now apply to each plain
colour, by exact value, like every other check; an exception counts as
used only where it excuses one of a focus indicator's colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): tell a var() or calc() width from the ring colour

Greptile (local, P2): `outline: var(--ring-width) solid var(--app-focus-
ring)` failed the guard, since any non-length token counted as a colour.
Math functions now count as lengths, and a shorthand with one colour slot
takes its literal colour, else an accepted token (the other `var()`s are
widths), else reports every candidate it cannot prove; `border-color`
still checks a colour per side.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a shadow ring whose spread is computed

Codex (local, P2): with `box-shadow: 0 0 0 calc(1px + 1px) red` or a
`var()` spread, only three literal zeros were left as lengths, so the
shadow looked flat and was skipped. A shadow is now skipped only when it
is provably not a ring: a literal non-zero blur in the third place, or
every length a literal zero with no `var()` that could be a spread. The
test with a variable-width token ring now asserts it is read, not skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read shadow lengths by type, with the colour first or no blur

Greptile (local, P2): the guard took a shadow's first three tokens as its
lengths, so `0 2px` (a line in the text colour, no blur) was skipped as
blurred and a colour-first lift shadow was read as a ring. Lengths are
now read by type: a colour sits before or after them, never between, and
a missing blur is zero. A `var()` counts as a possible length, so a
shadow is skipped only when the first three possible lengths prove a
blur, or every length and the first four possible ones are zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): a negated focus condition does not make a focus rule

Greptile (local, P2): the colour guard read `:focus-visible` inside
`:not()` as a focus rule, so a hover style such as
`.button:hover:not(:focus-visible) { border-color: red; }` failed. The
selector is now tested without its `:not()` arguments; a focus condition
elsewhere, `:has()` included, still makes a focus rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(tooling): read a width in any CSS length unit

Greptile (local, P2): only px, em and rem were lengths, so `outline: 1pt
solid var(--app-focus-ring)` took `1pt` for the colour and failed. Every
CSS length unit (absolute, font-relative, viewport and container) now
counts as a length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): the open-in-playlist chip rings in the app focus colour

Codex on #1882 (P2): the chip's inline component styles drew its focus
ring in `--app-selection-color`, out of the colour guard's reach (it reads
stylesheets). It was the only inline-style focus ring in the repository;
it now uses `--app-focus-ring` like every other ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): move the open-in-playlist chip styles into a stylesheet

Codex on #1882 (P2) suggested this over scanning TS inline styles: the
chip was the only component with a focus ring in inline `styles`, which
the colour guard does not read. Its styles now live in
`open-in-playlist-chip.component.scss` unchanged, the ring through the
shared `focus-ring-declarations` mixin, so the guard covers it (a drifted
colour there is reported).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:40:08 +02:00