Commit Graph
26 Commits
Author SHA1 Message Date
4grayandClaude Opus 5.5 8098ea8bb8 feat(self-hosted): keep playlists on the server for every device (#1903)
* feat(services): server storage connection state and access token

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

* feat(services): server kind for the playlist storage backend

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

* feat(services): server storage API client

The @iptvnator/services/server-storage sub-entry keeps server mode out of
the eager services barrel.

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

* feat(services): playlist document store over the server API

Full documents are cached by ETag (weak tags included) and served as
private copies; an upsert whose payload is the one the store handed out
sends keepPayload=1 without it and retries in full on payload_missing.

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

* feat(services): server-synced user data storage

Synced Xtream maps live in a write-through cache under the
iptvnator-server: prefix; writes send only the playlist entries that
changed, debounced, and flush with keepalive when the page hides.

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

* feat(services): server storage session and token interceptor

The session plugs the HTTP document store into PlaylistStorageBackend,
hydrates synced user data before the first read (bounded to 3 s), retries
while offline, flushes on hide, and routes to sign-in when the server
answers 401. The interceptor adds the access token to HttpClient calls to
the backend.

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

* feat(services): move browser playlists to the server

An empty server gets the browser playlists with their ids plus the
legacy Xtream user-data maps; a server that already has playlists goes
through the backup export and import, so duplicates merge. Browser data
is never deleted; the decision is remembered per server instance.

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

* feat(pwa): probe for server storage and wire server mode

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

* feat(pwa): server sign-in page and migration dialog

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

* feat(settings): server section for self-hosted storage

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

* feat(workspace): server status in the rail and an offline banner

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

* feat(settings): warn that delete-all clears the server

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

* feat(i18n): server storage strings

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

* test(e2e): server storage across browsers and the migration dialog

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

* docs(self-hosted): server storage mode, sign-in and the release note

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

* 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>

* 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>

* 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>

* 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>

* fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe

- Moving Stalker favorites and history keys items by id and category, so
  a channel and a movie with one id both survive.
- Writes after a page-hide flush wait for its keepalive requests.
- A probe whose body is cut off counts as unreachable and keeps the
  remembered server.
- Settings → Server renders without @defer so settings search can reveal
  its rows on the first visit.

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

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* fix(server-storage): conditional user-data writes, scoped token, deletes

- 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>

* docs(server-storage): conditional user-state writes

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

* 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>

* 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>

* 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>

* 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>

* 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>

* perf(workspace): style the server banner from the shell, baseline 570

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(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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* fix(server-storage): a re-read sent before a playlist delete skips that playlist

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(server-storage): keep a playlist id like __proto__ when filtering a read

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>

* fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps

- 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>

* fix(server-storage): one registry key per page load, server token only in server mode

- 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>

* fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records

- 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>

* fix(server-storage): no token for an unverified instance, sign-in remembers its server

- 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>

* fix(server-storage): check a remembered instance before sign-in sends the token

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>

* fix(server-storage): read migration maps by own playlist ids only

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>

* fix(server-storage): bind the token to its instance; adopted edits yield to newer changes

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>

* fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read

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>

* fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move

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>

* fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline

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>

* fix(server-storage): a failed sign-in keeps another tab's token; moved history newest first

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>

* fix(server-storage): recheck the instance after offline, back-forward cache pages, Stalker server rows

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>

* fix(server-storage): adopted edits keep yielding through conflicts; newest view per item; same-token sign-ins

A conflict replay collapsed every step into one edit, so a closed tab's
adopted step lost `yields` after the first conflict and a second one
replayed its old position over a newer one. Its steps now stay as they
are, and only this page's own collapse into one edit on top of them.

The move combines recently viewed items one per item, keeping the newest
view and its artwork, then newest first; before, the server's older copy
of an item won over a later browser view.

A failed sign-in clears the stored token only on a 401: a network
failure says nothing about the token, and another tab may have stored
the same one meanwhile.

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

* fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait

A background tab the browser suspended past the record lifetime can have
its writes adopted elsewhere, like a back-forward cache page: shown
again, it now checks resumed() and reloads instead of sending them.

Conflict replays appended a newly viewed item at the end of the history,
where the next 50-item cut dropped it; a replayed recently viewed list is
now kept newest first.

isFavorite() waits for the first user-data read like the list reads, so
a detail page opened during a slow first read does not toggle an
existing favorite the wrong way. A parked backup restore replayed when
the playlist opens waits too, and stays parked while no read succeeded.

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

* fix(server-storage): offline recheck checks only the instance; sign-out always navigates

The recheck on going offline ran a full refresh: when /instance answered
but /user-state kept failing, each failed read set the page offline
again and started the next one at once, bypassing the retry interval.
It now checks the instance only; reads wait for the retry.

Sign-out navigates to sign-in itself, also when the page is signed out
already: a guard (unsaved Settings) may have cancelled the redirect the
first time, and the state change that drives it does not repeat.

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

* fix(server-storage): a superseded page sends and saves nothing; re-reads keep other tabs' newer saves

A suspended tab's retry timer could run before it was shown again: touch()
recreated the record another tab had removed when it adopted the page's
writes, so the page sent them again over newer edits. The facade now
treats a page whose record is gone as superseded: the queue sends nothing
(maySend hook), its journal and record are not written again, and the
retry timer reloads it like a page shown again.

A re-read answered after another tab saved an entry no longer writes the
older answer over the shared cache: entries changed while the read
travelled are kept, with an unknown tag, so the next write here
conflicts and replays instead of removing what the other tab saved.

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

* test(web-e2e): never reuse another backend as the storage E2E backend

server-storage.e2e.ts deletes every playlist before each test. Reusing a
backend already listening on STORAGE_BACKEND_PORT outside CI could have
wiped a real library; an occupied port now fails the run instead.

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

* fix(server-storage): an outage on the sign-in page also unverifies the instance

reportOffline() left `verified` set while the page was unauthorized, so
a sign-in retried after an outage skipped the instance check and could
send the old instance's token to a replacement database. Verification
is now cleared on any outage; only the state change stays guarded, so
the workspace remains locked.

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

* fix(server-storage): interceptor and raw fetches report backend outages too

Only the storage API client reported an unreachable backend as offline.
When the first sign of an outage was an HttpClient call through the
interceptor (provider targets, playlist parsing) or one of PwaService's
raw fetches (Stalker, connectivity reset), the instance stayed verified,
and the next request after a replacement instance came up at the same
address would carry the old token.

Both now report no answer (status 0, a failed fetch) and a gateway's
502/503/504 as offline, so the session checks /instance first. The
backend's own JSON 502 for a failed provider is an answer and changes
nothing; neither does a fetch this page aborted on its own timeout.

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

* fix(server-storage): the immediate offline check runs once per retry interval

A response whose headers arrived but whose body was cut off reports
success and then offline, which started the next immediate instance
check at once: repeated cut-off answers kept requests going and the
connection indicator flickering. The check on going offline now runs at
most once per retry interval; later failures wait for the timed retry.

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

* fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying

A write whose headers said saved but whose body (with the new tag) was
cut off was retired without confirmation: a newer queued edit kept its
steps and old tag, and a later conflict replayed the saved step over a
newer value from another device. It now counts as saved on an unknown
version: the landed steps drop out and the next write conflicts, so
only edits made after it replay. Writes that were unconditional stay so.
The keepalive path does the same.

A late answer to a request sent before an outage can set an unverified
page online; with nothing to sync the retry then never ran and the token
stayed withheld. The retry now also runs for online pages that are not
verified.

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

* fix(web): keep server storage off the startup bundle; form-field defaults on sign-in

PwaService imported isBackendUrl from the @iptvnator/services/server-storage
barrel, which pulled the whole storage subsystem (sync, migration, API
client) into main, though server mode loads it by dynamic import. The
helper now lives with the token helpers in @iptvnator/services
(re-exported from the interceptor): renderer.initialBytes 1,392,902 ->
1,357,795 bytes locally.

The remaining 11,988 bytes over #1931's baseline are what must run
before the first route (instance probe, connection status, token bound to
its instance, storage backend and write serialization); the baseline is
raised with a note, under the PR's perf-baseline-increase label.

The sign-in component provides the form-field defaults per component, as
master's check (#1928) now requires.

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

* fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move

The Stalker metadata migration wrote server rows back through the
unconditional bulk upsert, from the list read: a device that edited a
Stalker playlist in between (or a second browser migrating the same
copied database) lost its change. Server rows are now read again one by
one and written as conditional mutations on that version; a conflict
leaves the flag unset, so the next start retries.

Moving a document's history kept the server's copy of an item both held,
even when the browser had viewed it later, and skipped the write when the
length did not change. The combined history is now newest first by
added_at, one entry per item, and any content change is written.

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

* fix(server-storage): M3U favorites by URL and history channel ids in the move; restores wait for the server

The move's M3U remap swapped references for server channel ids, but the
app looks favorites up by stream URL and history entries carry the URL
as their id: legacy favorites became invisible and history duplicated.
Legacy id favorites now become their channel's URL on both sides, and
history keeps its URL id with the server's channel id as channel_id.

A restore in server mode reported success once its lists were queued:
a list the server refused (413) left the old data while the parked
restore was consumed. Both restore paths now wait for that playlist's
writes (UserDataStorage.flushed, a few flush rounds for conflict
replays) and stay parked when the server did not take them.

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

* fix(server-storage): a closed page record stays closed when a late write rewrites it

On pagehide the page flushes with keepalive and then marks its record
closed. When writes were held (offline), the keepalive write fails at
once and its requeue saved the journal again a microtask later, which
rewrote the record without `closed`: the reload took the previous page
load for live and left its favorites and progress unsent until the
record aged out (ten minutes). markUnconfirmed now keeps an existing
record's closed flag.

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

* fix(server-storage): the move uploads playlists create-only and merges into a copy moved first

Two tabs or devices moving browser copies with the same playlist id both
planned an upload; the browser documents carry no served version, so
both PUTs were unconditional and the later one overwrote the first copy
(and any change made on it since), with both moves reporting success.

The backend's playlist PUT now honours `If-None-Match: *` (412 when the
playlist exists; checked and written in one synchronous handler). The
move uploads with it through ServerPlaylistDocumentStore
.dbCreateAppPlaylist, and an upload that finds its id taken merges its
favorites and history into that copy instead, counted as merged.

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

* fix(server-storage): Delete All waits for repairs holding a reservation; backups read the server again

Delete All raised its write barrier before it held the exclusive
authority reset. A Stalker repair already holding its row reservation
then waited on that barrier for its conditional write, while the reset
waited for the reservation: neither finished, and later playlist
operations stalled behind them. The barrier now goes up inside the
reset, once repairs in flight have finished; writes queued so far land
first and new ones wait for the delete, as before.

ready() said yes after any earlier read, so an export could miss what
another device added since, and restoring an empty list did nothing
when the cache was already empty. Backup export, restore and the
restore replay now ask for a fresh read (ready(true)) and fail, the
restore staying parked, when it does not succeed.

The facade's adoption loop moves to the adopt module to keep the
facade under its line limit.

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

* fix(services): Delete All drains queued writes before its reset; reserved writes skip its barrier

The previous commit raised Delete All's barrier inside the exclusive
authority reset and drained queued writes there, but a queued deletion
or import takes the shared side of that lock: the reset waited for it
while it waited for the reset.

Delete All drains the writes queued before it outside the lock again,
then takes the reset, with its barrier up from the start. The deadlock
with a Stalker repair (or Edit) that holds a row reservation and writes
under it is broken on the other side: such a write does not wait for
the barrier (holdsPlaylistAuthority, counted per reservation in this
context), and the reset waits for the reservation as before.

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

* fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out

A re-read checked other tabs' unconfirmed entries only after its answer
arrived. A tab whose write was unconfirmed when the read went out (its
edit already in the snapshot) and confirmed before the old answer came
back matched neither check, so the stale answer replaced its saved value
in the shared cache; that tab's next edit, holding the newer tag, then
wrote the stale list and deleted the item on the server. The entries
unconfirmed when the read is sent are kept too.

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

* fix(services): writes wait for Delete All before they queue; restores replace only settled lists

Delete All, third variant: a write that arrived after Delete All waited
for it inside its playlist's queue. A Stalker repair that reserved that
playlist meanwhile skipped the barrier, but its write still queued
behind that waiting one, while the reset waited for the repair's
reservation. While a Delete All runs, writes now wait for it before they
join their queue, so no queue holds work blocked on the reset; a write
under a reservation held here joins at once.

A restore replaced lists while this page's own write for the playlist
was still unsent (refused, or lost): that write's edits joined the
replacement and a conflict replay kept items the backup did not have.
Both restore paths now require the playlist's writes to be on the
server first (flushed), and otherwise stay parked.

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

* fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines

When storage refused a reload's copy of the last page load's saved
writes, the old copy stayed but only this page's memory named it: the
tab's pointer already named the new page, so a second reload before any
copy was saved recovered from the empty new page, and without a
registry record no other tab could find the old one. The pointer now
also lists the page loads a page keeps (keepPages), and a reload takes
them over, oldest first, before its last page load. The pointer helpers
move to server-page-pointer.ts.

Both merges of master resolved journey-baselines.json by taking this
branch's whole file, which dropped #1929's cdTicksToFirstCard,
cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with
only this branch's initialBytes and domMutationsToFirstCard raises.

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

* fix(server-storage): a duplicate leaves a running tab's kept page loads to it

A duplicated tab copies the tab's session storage, so after a reload
that kept older page loads its pointer lists them too, and the duplicate
took them over at once, although the tab still running owns those edits
and may have newer ones queued; its replay could then write older watch
progress over newer. When the previous page load still runs, the kept
ones now wait with it and `touch` adopts them, oldest first, once it no
longer looks live.

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

* fix(server-storage): a restore merged by a conflict stays parked; first-read tests wait for the read

A restore whose write conflicted with an edit another device made during
it had the conflict replay merge that edit in, and flushed() still said
the lists were taken, so the restore was consumed with lists that were
not the backup's. Both restore paths now compare the stored lists with
the ones they wrote once the server took them, and a difference keeps
the restore parked to run again on a fresh read.

The two first-read tests released the mocked read after a fixed number
of microtask turns, which could come before the read started; they now
wait until it has.

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

* fix(server-storage): page-hide keepalive writes report an outage too

sendKeepalive() turned a failed fetch, a timeout or a gateway's
502/503/504 into a failed outcome without telling the status, so the
instance stayed verified: when the hidden tab came back, its refresh
skipped the /instance check and resent the queued writes, which could
reach a replacement database behind the same address. Like requests, a
keepalive write now reports these as offline, so the instance is
checked before anything goes out again.

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

* fix(server-storage): a keepalive answer cut off after its headers counts as offline

A page-hide write whose headers said saved but whose body broke off was
returned as saved with the read error dropped, so the instance stayed
verified. Like readJson() for requests, it now reports offline (the
outcome stays saved on an unknown version), and the instance is checked
before the next token or write goes out.

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

* fix(server-storage): kept page loads replay oldest first; restore checks compare normalized reads

A page load and an older one it kept could both hold steps for one
entry. Adopting them, each adopted page went ahead of what was queued,
so adopting the oldest first put the newer page's steps before the
older's; restoring them after a reload collapsed the newer page's steps
into one, so its removal of an item the older page had added was lost
on a conflict replay. Adoption now goes newest first, and a reload
appends a later page's steps (and early edits) after an older copy's.

restoreUserData() compared the lists it wrote with reads that drop
duplicate favorites, so a saved restore listing one twice never
finished, also in browser-only mode. It now compares reads right after
writing with reads after the save.

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

* fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move

A flush scheduled in the same turn as a page-hide flush was chained on
the queue before the keepalive batch, which went out at once beside it:
a later edit (removing a favorite the keepalive batch added) could land
first and be undone when the keepalive write arrived. A scheduled flush
now waits for a page-hide batch sent at once before it takes entries.

A keepalive write refused with 401 returned failed without telling the
status; like requests it now reports the token it sent as refused.

The move copies a parked Xtream restore (a browser backup import that
could not finish) to the server playlist id a playlist merged into, so
it is applied and exported there; one parked there already wins.

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

* fix(server-storage): a slow server does not reload a page started from memory forever

The start probe gives /v1/instance 2.5 s. A remembered server slower
than that started the page from memory; the recovery check (with the
request timeout) then got an answer and reloaded, and the reloaded page
timed out again: an endless reload loop for a server answering in 2.5
to 20 s. The reload now leaves a one-time mark in sessionStorage, and
the next probe waits as long as requests do.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:57:15 +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 Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): add programme guide implementation plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add window-scoped guide programme queries

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): harden guide query scoping, caps and row mapping

- Scoped guide programme/coverage queries now include legacy
  (unsourced) rows via source_url IN (...) OR IS NULL OR '',
  mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
  the normalized, capped window.channelIds instead of the raw
  request, so a key cut by the cap is absent rather than [] — an
  invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
  and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
  parameter and moved (with guideWindowOverlapSqlText) into
  epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
  into epg-program-row.util.ts, used by both EpgQueryService and
  EpgGuideQueryService so invalid start/stop rows are dropped
  identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
  text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): render the guide predicate in tests and document its scope

Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): expose guide programme and coverage reads over the bridge

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): separate coverage chunk size in the guide plan

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide source contract, day layout maths and preferences

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): key guide IPC answers by trimmed, present keys only

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): guide search hits carry a row id

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide geometry DST-safe and tighten the contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): cache guide programmes per day with batched loading

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add guide keyboard navigation controller

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): make guide programme cache robust to first-run effects and coverage failures

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add the programme guide grid components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(epg): add a Guide button to the timeline toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): adapt the playlist channel list to the guide contract

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): guard the guide's initial group scope and track language changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* feat(m3u): open the programme guide in place with a docked player

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): scope guide keys to the grid, clip the now-line and re-measure on resize

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): remove the multi-EPG overlay and the channel-range IPC

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): document the programme guide and its release note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* i18n(epg): translate the programme guide

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): let the guide own the keyboard and gate its entry points

While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): cover the programme guide flow

Imports a two-channel playlist with XMLTV, opens the guide from the
timeline toolbar and asserts the row list, the "Only with EPG" filter, a
channel switch that keeps the guide open, the hidden-but-mounted sidebar,
and that the player element survives both the mode and channel switches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(epg): tidy guide docs, palette gating and the unbound output

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): match guide favorites by channel URL and skip re-activating the playing row

Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* perf(epg): let the guide window predicate use the programme time index

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): stabilise guide row identity, seed the sidebar group and provide translations in every player fixture

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* refactor(epg): split the guide shell, add a roving focus model and offset-aware search times

The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.

Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.

Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(m3u): make guide row ids collision-proof and gate the G shortcut

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): keep guide keys on the grid, reconcile focus with filtered rows and wrap the toolbar

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(epg): describe guide row ids as scope-local

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): clear guide search on scope change, match the active duplicate by url, keep failed coverage unknown

Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): tell duplicate guide rows apart by group, keep G out of dialogs, use prototype-safe answers

The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let coverage reject on lookup failures and compare whole entries for the active guide row

EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): let the guide return catch-up to live and normalise programme-search rows

The guide source contract gains an optional livePlayback signal: while the
host plays a catch-up URL, the active row may be activated again, which is
how the M3U host returns to live. EPG_DB_SEARCH_PROGRAMS now maps the raw
snake_case rows to the EpgProgram shape the bridge promises (plus the joined
channel name), so search hits resolve their channel and keep descriptions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): name search hits, keep guide coverage strict on mapping failures

- Search results and the unresolved programme dialog show the channel's
  display name (playlist row name, else the XMLTV display name the search
  joined in) instead of the raw XMLTV id.
- The guide coverage read resolves manual mappings through a strict variant
  that rejects on database failure, so a mapped channel can never be reported
  as uncovered and hidden by "Only with EPG".
- Architecture doc describes the tiered active-row resolution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(epg): offer the Guide action in the list view too

The EPG list view mirrors the timeline's input/output contract, but the Guide
action was bound only in the timeline branch, so Settings → EPG → Guide view =
List lost the in-panel entry point. The list toolbar now carries the same
icon-only Guide button behind `guideAvailable`/`openGuide`, and the M3U
host binds it in both branches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00
4gray d73acd6bfc fix(playback): clarify external player launch feedback (#1388) 2026-08-09 13:33:07 +02:00
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00
4gray 96facd6f49 feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
2026-08-03 08:53:44 +02:00
4gray 347e3cd2f8 docs(website): define unofficial sites cover design 2026-08-02 12:10:02 +02:00
4gray 760099358b feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
2026-08-01 18:09:31 +02:00
4gray 0c59aace71 fix(playback): structure MPEG-TS diagnostics (#1327)
* docs(playback): design structured mpegts diagnostics

* docs(playback): plan structured mpegts diagnostics

* fix(playback): structure mpegts error evidence

* fix(playback): structure HTML5 mpegts errors

* fix(playback): share mpegts evidence across players

* fix(playback): render structured mpegts evidence

* docs(playback): document structured mpegts diagnostics

* chore(playback): keep diagnostics lint clean

* docs(playback): complete mpegts diagnostics plan
2026-08-01 17:13:16 +02:00
4gray 9f4e11d6de fix(playback): structure Shaka diagnostics (#1318)
* docs(playback): design structured Shaka diagnostics

* fix(playback): structure Shaka diagnostics

* docs(playback): document Shaka evidence boundary

* docs(playback): fix Shaka validation commands

* fix(playback): preserve Shaka fallback evidence

* fix(playback): preserve Shaka text error evidence
2026-07-31 21:25:06 +02:00
4gray 9a50e7385b fix(playback): structure Video.js diagnostics (#1317) 2026-07-31 09:15:49 +02:00
4gray 46c7713841 fix(ui): preserve EPG in narrow channel rows (#1312)
Preserve current-program context and enabled actions in narrow channel rows while aligning loaded rows, skeletons, and virtual-scroll geometry across M3U, Xtream, Stalker, Favorites, and Recent views.
2026-07-31 08:27:36 +02:00
4gray 2ac0de752f fix(skills): align repository guidance with implementation (#1315)
* docs(skills): design implementation synchronization

* docs(skills): plan implementation synchronization

* fix(release): filter internal notes from public body

* docs(release): synchronize release workflow guidance

* fix(stalker): normalize catalog series flags

* fix(stalker): preserve progress with scoped episode IDs

* fix(playback): expose strict position persistence

* docs(stalker): record series position compatibility

* test(skills): validate repository skill contracts

* fix(database): keep SQL trace values private

* docs(skills): refresh Nx and SQLite ownership

* docs(skills): align provider and UI guidance

* docs(skills): tighten validated guidance

* docs(release): require exact release pushes

* style(electron): remove trailing blank line

* fix(ci): classify repository skills coverage
2026-07-31 08:00:59 +02:00
4gray 99d167993d fix(playback): structure HLS diagnostics (#1316)
* docs(playback): design structured HLS diagnostics

* docs(playback): plan structured HLS diagnostics

* fix(playback): structure HLS diagnostics

* docs(playback): document structured HLS evidence

* fix(playback): keep HLS startup logs private
2026-07-31 07:33:05 +02:00
4gray bf13849d69 fix(playback): avoid false codec diagnostics (#1314)
* docs(playback): design accurate native diagnostics

* docs(playback): plan accurate native diagnostics

* fix(playback): classify native source errors from evidence

* fix(playback): preserve Video.js HTTP error context

* fix(playback): show explicit HTTP playback errors

* docs(playback): document native error evidence
2026-07-30 19:55:35 +02:00
4gray e55d55b47f feat(mock-data): add shared screenshot-safe poster catalog (#1271)
Moves the fictional movie catalog into `libs/shared/marketing-fixtures` so the
Xtream and Stalker mocks describe the same titles, and adds 20 rendered posters
plus the shared fixture types behind them.

Supporting changes made while getting it green:

- `shared-marketing-fixtures` is classified Tier B in the coverage policy. Not
  Tier A: it is fictional fixture data, so a statement percentage over it means
  nothing, and a Tier A entry would pull it into the merged coverage map and the
  ratchet. Tier B still runs its spec in CI. `stalker-mock-server` needs no entry
  of its own — it is already Tier C and the Tier B/C runner falls back to
  `pnpm nx test <project>`, so its new `marketing-poster-url.spec.ts` runs.
- Two release-capture defects the catalog reorder introduced, both fixed in
  `tools/release/capture-app-driver.ts`:
  - VOD stream ids are `MARKETING_VOD_STREAM_ID_BASE + index` and the generator
    now lists the showcase movies first, so 62000-62002 became Black Harbor, The
    Paper Astronaut and Summer Static while the dashboard seeding still mapped
    those ids to the previous titles' backdrops.
  - the raw `tsx` spawn of the Xtream mock lacked `--tsconfig
    tsconfig.base.json`, so the mock could not resolve
    `@iptvnator/shared/marketing-fixtures` and the capture never started. Both
    mock projects' own serve targets already passed the flag.
2026-07-27 08:10:57 +02:00
4gray 08b868d6c1 test(electron): harden runtime boundary coverage (#1267)
Adds contract-focused regression coverage for the Electron HTTP server,
remote-control events, settings events, and managed download paths, and makes
Tier A coverage fail closed when instrumentation fails or a runtime-owning
production file disappears from a project or from the merged Istanbul report.

The old `coverage:ci` exited 0 despite a `Failed to collect coverage`
diagnostic: libs/m3u-state/src/lib/effects.ts was simply absent from the merged
map. All 30 Tier A reports are now required, the merged map covers 710 files,
and effects.ts is reported as 0/159 instead of silently disappearing.

Also fixes remote static-file path containment for encoded, malformed, NUL,
POSIX and Win32-style traversal inputs, with behavior-preserving testability
seams.

Statements 69.27% -> 69.54%; http-server.ts 0% -> 90.21%,
remote-control.events.ts 0% -> 96.55%, settings.events.ts 59.25% -> 96.29%.
2026-07-26 22:27:50 +02:00
4gray d308749e2c fix(stalker): preserve is_series episode metadata (#1218) 2026-07-21 07:51:37 +02:00
4gray 48ff4b9cc5 fix(portal): remove redundant catalog type badges (#1217)
Remove redundant LIVE, VOD, and SERIES badges from homogeneous Xtream and Stalker catalog grids while preserving mixed-content badges and type-driven grid behavior.
2026-07-20 21:52:00 +02:00
4gray c266eaa680 fix(packaging): preserve Flatpak Electron ELF for Zypak (#1205)
Fixes the launcher/Zypak regression reported in #1203. The additional GPU/video.js behavior remains tracked separately in that issue.
2026-07-18 22:42:45 +02:00
4gray 8fdac824fd feat(packaging): ship Linux embedded MPV frame-copy runtime (#1200)
* docs: design Linux frame-copy packaging

* docs: plan Linux frame-copy packaging

* feat(packaging): define Linux frame-copy profiles

* fix(packaging): reject inherited profile names

* feat(embedded-mpv): validate staged Linux runtime

* fix(embedded-mpv): require Linux source packages

* fix(embedded-mpv): harden Linux runtime staging

* feat(embedded-mpv): build LGPL Linux runtime

* fix(embedded-mpv): pin Linux runtime inputs

* feat(embedded-mpv): build relocatable Linux helper

* fix(embedded-mpv): require bundled Linux runtime

* fix(embedded-mpv): make Linux runtime portable

* feat(packaging): ship Linux frame-copy artifacts

* fix(embedded-mpv): verify Linux helper linkage

* fix(packaging): enforce Linux frame-copy isolation

* fix(embedded-mpv): pin Linux display data

* docs(embedded-mpv): document Linux frame-copy packaging

* feat(embedded-mpv): probe Linux frame-copy runtime

* test(embedded-mpv): smoke packaged Linux frame-copy

* docs(embedded-mpv): clarify Linux system runtime baseline

* fix(embedded-mpv): harden Linux runtime capability gate

* ci: verify Linux frame-copy packages

* test(embedded-mpv): harden packaged Linux smoke

* test(embedded-mpv): preserve packaged GL mode

* test(packaging): harden Linux package probes

* fix(embedded-mpv): enable private Snap shared memory

* fix(embedded-mpv): sanitize Linux helper environment

* fix(packaging): enforce private Snap memory semantics

* fix(packaging): reject ambiguous Snap memory metadata

* fix(embedded-mpv): prioritize trusted Snap GL

* fix(packaging): reject advanced Snap YAML semantics

* fix(packaging): reject arbitrary Snap YAML aliases

* feat(packaging): ship Linux runtime license notices

* docs(embedded-mpv): document Linux runtime distribution

* fix(packaging): parse Snap trailing comments safely

* fix(release): gate Snap publish on public source release

* fix(packaging): strip VCS metadata from source bundle

* docs(packaging): clarify Linux source release gate

* test(embedded-mpv): smoke missing bundled libmpv

* style(embedded-mpv): format final validation inputs

* fix(e2e): satisfy fixture index signature typing

* fix(ci): declare fontconfig gperf generator

* fix(embedded-mpv): hash runtime cache identities

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): tighten runtime delivery gates

* fix(ci): decouple Linux runtime matrix

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): validate Linux frame-copy runtimes

* fix(packaging): scope Snap Electron library checks

* feat(packaging): ship Linux frame-copy runtimes

* fix(packaging): improve Linux runtime smoke diagnostics

* fix(packaging): expose bounded helper probe details

* test(packaging): trace Snap EGL probe failures

* fix(packaging): prefer core22 ABI in Snap helper

* fix(packaging): bound helper probe capture

* fix(packaging): harden Linux frame-copy releases

* fix(packaging): canonicalize libplacebo submodule identity

* fix(packaging): make source archive inspection portable

* fix(packaging): harden Snap release verification
2026-07-18 17:28:22 +02:00
4gray c6ed504723 feat(playback): add shared picture-in-picture controls (#1199)
* docs(playback): design shared web picture-in-picture

* docs(playback): keep picture-in-picture exit available

* docs(playback): plan shared web picture-in-picture

* feat(playback): add picture-in-picture controls contract

* feat(playback): add shared web picture-in-picture

* feat(playback): expose shared picture-in-picture action

* docs(playback): document shared web picture-in-picture

* test(playback): cover shared picture-in-picture flow

* test(playback): wait for PiP video in Electron E2E

* refactor(playback): extract picture-in-picture controller
2026-07-17 22:11:29 +02:00
4gray beb62db314 feat(settings): add shared web player controls toggle (#1198)
* docs(playback): design shared controls setting

* docs(playback): plan shared controls setting

* feat(settings): persist shared web controls preference

* test(settings): harden shared controls normalization coverage

* feat(settings): expose shared web controls toggle

* fix(settings): label shared controls toggle

* feat(playback): resolve shared controls from settings

* test(playback): cover shared controls setting

* docs(playback): document shared controls preference

* fix(playback): await settings before host creation

* fix(settings): normalize shared controls updates
2026-07-17 13:38:06 +02:00
4grayandLars Emig 4e572c60ca feat(videojs): add feature-flagged shared controls (#1195)
* feat(videojs): add feature-flagged shared controls

Rebuild the Video.js shared-controls integration on the current player lifecycle with Tech rebinds, source-scoped tracks, reset ordering, volume preservation, diagnostics gating, and default-off compatibility.

Credits and supersedes the stacked implementation proposed in #1153.

Co-authored-by: Lars Emig <lars.emig@pickware.de>

* fix(videojs): harden MPEG-TS reset lifecycle

---------

Co-authored-by: Lars Emig <lars.emig@pickware.de>
2026-07-16 23:08:18 +02:00
4gray c49ea2f6a8 feat(html-player): add feature-flagged shared controls (#1194)
* feat(html-player): bridge engine state to shared controls

* fix(html-player): avoid HLS subtitle event reentry

* fix(html-player): restore delayed HLS default subtitles

* refactor(html-player): split controls bridge collaborators

* feat(html-player): add feature-flagged shared controls

* test(html-player): cover shared-controls source ownership

* feat(html-player): pass shared-controls playback metadata

* docs(player-controls): describe HTML5 shared-controls bridge

* docs(player-controls): clarify HTML5 rollout effect

* fix(html-player): reveal diagnostics from fullscreen

* fix(html-player): defer HLS event resolution

* refactor(html-player): isolate video element session
2026-07-16 21:30:41 +02:00
4gray f611ea3d7c feat(embedded-mpv): use shared controls for frame-copy (#1193)
* docs(embedded-mpv): plan frame-copy shared controls

* feat(embedded-mpv): adapt frame-copy sessions to shared controls

* fix(embedded-mpv): correlate recording control updates

* fix(embedded-mpv): accept recording ack before command resolve

* fix(embedded-mpv): serialize delayed recording commands

* fix(embedded-mpv): latch buffered recording outcomes

* feat(embedded-mpv): use shared controls for frame-copy

* fix(embedded-mpv): isolate recording ticks by engine

* fix(embedded-mpv): reset controls on engine handoff

* docs(embedded-mpv): document frame-copy shared controls

* docs(embedded-mpv): normalize shared-controls plans

* fix(embedded-mpv): isolate legacy feedback on handoff

* fix(player-controls): block toggles while stalled

* refactor(embedded-mpv): isolate controls timing

* fix(player-controls): reset recording feedback on handoff

* fix(embedded-mpv): preserve newer session snapshots
2026-07-16 18:47:32 +02:00