Commit Graph
234 Commits
Author SHA1 Message Date
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
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 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
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 340b1c3efa 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>
2026-10-12 08:57:48 +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
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
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
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 676733cd7b 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>
2026-10-12 00:38:18 +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 57f2a33396 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>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 fadb6e5ae2 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>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 533222d137 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>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 ead47697bd 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>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 b74584fdb2 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>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 ab2eef26d2 feat(services): server kind for the playlist storage backend
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:55 +02:00
4grayandClaude Opus 5.5 872d8edc68 feat(services): server storage connection state and access token
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:16:55 +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 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 e1d4f00d93 fix(i18n): follow runtime language switches in stored and memoized labels (#1872)
* fix(i18n): follow runtime language switches in stored and memoized labels

A label translated once and kept kept its language after Settings ->
Language until the data reloaded or the app restarted. The Stalker store
baked `translate.instant('PORTALS.ALL_CATEGORIES' | 'PORTALS.ALL_RADIO')`
into its category list, so the every-item genre stayed English in the
rail, the live header, the fullscreen panel title, the catalog title and
the search scope.

Stalker: the every-item genre now carries `labelKey` and an empty
`category_name`; the category views render the key through the translate
pipe. `getSelectedCategoryName` becomes `getSelectedCategoryLabel`
({ name, labelKey }) and every text consumer goes through
`stalkerCategoryLabelText()` inside a computed that reads a language
signal, so no consumer can show the empty name or a stale translation.

Same class elsewhere (computed or stored `instant` text without a language
signal): the Xtream import overlay title and progress (shell-provided),
the Settings About version note (stored in a signal on the page where the
language changes), the remove-all-playlists progress, the context panel's
status/error text and category search, and the movie/series heroes
(`createVodDetailsHeroState`, `createSeriesHeroState`, the Xtream movie
presenter), cast & crew "Director", the M3U sidebar count, the M3U movie
hero and the collection panel title. These read
`toSignal(onLangChange.pipe(startWith(null)))` and also recover when the
translation file lands after the first render.

Documents the rule in the UI guidelines and the Stalker store contract.

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

* docs(stalker): record getSelectedCategoryLabel in the store API baseline

getSelectedCategoryName is gone without an alias: a name-only selector is
empty for the every-item genre, whose label is a translation key.

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

* fix(i18n): one translation tick that also follows late dictionaries

Greptile asked for coverage of a dictionary that lands after the first
render. That exposed a gap: every hand-written tick listened to
`onLangChange` only, but a start-up without a saved language never calls
`use()` - the default dictionary landing fires `onDefaultLangChange` alone,
so computeds that ran before it kept raw keys. Dictionary updates
(`onTranslationChange`) were missed the same way.

`injectTranslationTick()` in `@iptvnator/pipes` merges the three events the
translate pipe re-renders on (the embedded MPV player already did). All 37
ticks use it now, including the hero factories' `language` dependency. New
specs cover the delayed default-language load, a late `use()` dictionary
and a dictionary update; the hero specs add the start-up case.

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

* test(i18n): stub the Xtream selection the merged search scope reads

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

* fix(i18n): series view reads the shared translation tick

#1871 added an onLangChange-only tick for the synthetic episode titles; a
start-up dictionary that lands without use() would leave raw keys.

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

* refactor(i18n): move injectTranslationTick to @iptvnator/services

The tick injects TranslateService and subscribes, so it is injectable
runtime state; nx-workspace-boundaries.md reserves type:util for pure
helpers and contracts. @iptvnator/services is the shared type:data-access
project every consumer domain may depend on, and it imports none of them.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:31:38 +02:00
4gray 2fa03c8efc fix(xtream): preserve pending backups and restore positions atomically (#1873)
* fix(xtream): preserve pending backups and restore positions atomically

* fix(xtream): retain sparse duplicate playback metadata on restore

* fix(xtream): require atomic restore in SQLite capability gate

* fix(xtream): preflight atomic restore without changing storage
2026-10-10 16:27:01 +02:00
4grayandClaude Opus 5.5 2b400cb81d fix(i18n): translate the remaining hard-coded labels (#1871)
* fix(i18n): translate hard-coded labels, snackbars and missing keys

Catalog screens, snackbars and external player messages showed English
in every locale, and seven keys used in code were missing from en.json,
so ngx-translate rendered them raw.

- Catalog: "All items", item and channel counts, channel sort menus and
  tooltips, unnamed-category and empty-category labels, LIVE/PAUSED
  badges and the Xtream global search summary are translated. The Xtream
  and Stalker stores no longer bake an English name into the every-item
  sentinel; the facades return a null title and the view translates it.
- Snackbars: Xtream/Stalker request failures, the 413 upload error,
  backup export/import results and the category visibility failure use
  keys; every "Close" action uses CLOSE.
- External player: the main process sends an error code instead of an
  English sentence (player-error event, session errorCode, and a tag in
  rejected launch errors); the renderer, dock and VOD primary button
  translate it. The external player info dialog is translated.
- Adds the seven missing keys and 43 new ones, translated in all 18
  locales; seven legitimately identical values are baselined.
- tools/i18n/check-usage.mjs fails on keys used in code but missing from
  en.json; it runs in i18n:check (and so in CI through i18n:validate).

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

* fix(i18n): translate catalog counts in the template and skip inline template comments

- The category subtitle hands a key to the translate pipe instead of
  caching translate.instant(), so a cold start re-renders it once the
  language file loads.
- The Xtream live root count uses the singular/plural item keys, so one
  result no longer reads "1 channels".
- check-usage.mjs strips HTML comments inside inline templates of
  TypeScript files, so a commented-out key no longer fails the check.

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

* fix(i18n): check keys in parenthesised translate pipe operands

`(expanded() ? 'SHOW_LESS' : 'SHOW_MORE') | translate` yields keys that
neither precede the pipe directly nor contain a dot, so the usage check
missed them. It now reads the ternary and fallback branches of a
parenthesised operand; a literal compared in the condition is not read
as a key.

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

* test(e2e): expect the translated external player failure in the dock

A launch failure without an error code now shows the translated generic
status in the playback dock, with the raw main-process detail as its
tooltip. The ClearKey DASH flow asserted the raw English text.

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

* fix(playback): keep the IPC error tag out of the stored session detail

A tagged launch failure reached ExternalPlayerSession.error unchanged, so
the dock tooltip showed "[iptvnator:external-player:start-failed]". The
registry now strips the tag when it stores the detail; the rejected IPC
error keeps it for the renderer to read the code.

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

* test(web): expect translated portal request failure toasts

#1861's new resolved-failure specs asserted the English toast text; the
toasts now go through PORTALS.REQUEST_ERRORS keys, so the specs check
the key and its message/status params.

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

* fix(i18n): read only returned branches of a grouped translate operand

A literal at the start of a condition, as in
`('ERROR' === status() ? 'CLOSE' : 'CLOSE') | translate`, was taken for
a translation key, so a valid template could fail the usage check. A
grouped literal now counts only when it ends its operand (end of group,
`:`, `||` or `??`).

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

* fix(i18n): translate the remaining hard-coded labels

Follow-up to the UI-26 pass. The remaining English UI literals now come
from translation keys, translated in all 18 locales:

- Windows/Linux window controls, playlist switcher title and actions
  menu, the portal status tooltip, dashboard carousel roles and rail
  scroll buttons, the search placeholder, the card remove tooltip, the
  EPG offset unit, the REC chip, the Stalker EPG source label and the
  export file type.
- Embedded MPV failures raised in the renderer and the release-notes
  dialog without a bridge. Settings never showed its English reasons,
  so they are dropped.
- Unnamed Stalker episodes: data access leaves the title empty and the
  series view labels it after the TMDB overlay. Synthetic season names
  stay, because they key persisted episode progress.
- The phone remote-control page, which follows the first browser
  language with a translation and sets <html lang>.

Removes the dead getStatusMessage(), the unused favorites layout and the
translateWithFallback() helpers whose keys now exist.

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

* fix(remote-control): keep the remote build off shared-interfaces

The language resolver imported the Language enum, which made
remote-control-web depend on shared-interfaces. Its build-performance
chain then hit Nx's recursive-invocation guard through the existing
shared-interfaces/shared-logging build loop, failing the Electron E2E
and performance journey builds. The resolver now keeps its own list of
translation codes, and a spec checks it against the locale files the page
loads.

Also drops the deleted favorites layout from the zoneless checklist,
whose guard spec requires ticked entries to exist.

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

* perf(journeys): raise the launch DOM mutation baseline for translated attributes

Launch now sets 569 DOM mutations before the first dashboard card instead
of 557 (identical in all three CI iterations). The extra twelve come from
attributes this PR moved from static English to translated bindings on
the launch path: the window-control labels and tooltips on Linux, the
hero carousel and slide roles, and the rail scroll-button labels. A
translated attribute is a binding set after the element is attached, so
each costs a mutation. Accepted as a deliberate trade-off; it needs the
perf-baseline-increase label.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:22:31 +02:00
4grayandClaude Fable 5.1 50d45bc7c0 feat(details): redesigned episode list, section rhythm and hero fade (#1859)
* fix(details): continue the hero artwork under the first section

The vertical scrim stopped at the hero's bottom edge, so a bright
backdrop ended in a visible band above the Episodes heading. The hero
now grows by a 140px tail that the shell pulls the sections up over,
and the scrim resolves to the exact page surface behind the heading.

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

* feat(details): one section header and a 56px rhythm below the hero

Episodes, Cast & crew and Similar now share app-detail-section-header:
an 18px/600 title, a muted tabular counter and lead/end slots. The
Episodes counter adds the watched count ("8 episodes · 3 watched"),
and sections sit 56px apart, with 56px after the last one.

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

* feat(details): flat episode rows with a reserved action slot

Each episode is now one app-episode-item, a list row or a grid card:
- a number column, a 168px thumbnail with a watched check, a progress
  bar only for started episodes and a play overlay
- title with "46 min · 12 Jan" (time left once started) and a plot
  clamped to two lines at 74ch
- mark watched, download and a "…" menu in a 112px slot that is always
  reserved and shows on hover or keyboard focus, so nothing shifts
- the whole item is one stretched button: Tab focuses it, Enter plays
- the playing or last unfinished episode is highlighted

The "…" menu holds Episode details and, for a started episode, Play
from beginning, which Xtream and Stalker now start at 0. TMDB's episode
runtime fills the meta line when the provider sends none. The list is
the default view for anyone who has not chosen one.

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

* feat(details): segmented list/grid switch and a 30px season pill

The view toggle becomes a 2px-padded segmented track with 30×26
segments and a neutral checked segment; the season pills and the season
dropdown share the section header's 30px pill.

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

* feat(details): no season synopsis that repeats the series description

Providers often send the series plot, or its first sentences, as every
season's description, so the same text showed twice a few hundred
pixels apart. The season strip now drops a synopsis that equals the
hero's description, or is cut short from it, and clamps its own to two
lines at 72ch.

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

* fix(details): draw the episode thumbnail hairline as a border

An inset shadow over the artwork is invisible to the surface-contrast
checks and to forced-colors mode; a 1px border is what both read.

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

* test(e2e): follow the list-first, flat episode items

The list is now the default view, rows and cards share .episode-item
classes, and grid titles no longer carry the "N." prefix. The surface
checks measure the thumbnail hairline instead of a row border, assert
that hovering a row does not move its title, and capture list, grid and
hover screenshots.

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

* docs(ui): episode list, section rhythm and hero tail contracts

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

* fix(dashboard): continue the hero artwork under the first rail

The dashboard hero's fade ended at its edge, so the artwork stopped
just above the Continue Watching heading. Like the details hero, it now
grows by a 160px tail that the rails are pulled up over: the art fades
behind the first rail's heading and reaches the page colour before its
cards, so the rail's scroll-edge fades never show as a box. The narrow
layout, whose slide carries its own scrim block, keeps no tail.

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

* docs(changes): note the smoother hero fade

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

* feat(details): season chips up to four, a counts menu from five, no season art

The season picker drops every poster: chips for up to four seasons, a menu
button from five whose rows read the season and "N episodes · M watched".
The season synopsis loses its cover, sits on the number column 24px above
the list and renders nothing without a plot of its own. The player's episode
panel shares the picker and loses its dropdown thumbnails too; its season
strip stays.

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

* feat(details): bare rows for seasons without metadata, skeletons while it loads

A season whose final episode data has no plot and no usable still (the
series poster or season cover repeated as a still counts as none) renders
44px rows: number, title, meta and an inline check or progress bar, the same
action slot, no grid toggle. While the provider list or a TMDB lookup that
could still fill a bare-looking season is outstanding, the episodes are
skeleton rows at the full row's exact geometry instead of a spinner; a
season the provider already describes renders at once.

Xtream tracks the show match and each season's enrichment in the store;
Stalker tracks its show match in the selection state and settled season
fetches in its TMDB service, and now reports a regular series' season
request as loading instead of an empty series.

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

* fix(details): loading skeletons laid out like the loaded page

The hero skeleton targeted ngx-skeleton-loader's old `.loader` class, so
its blocks kept the library's light default fill and margins; `display:
block` stacked the chips and buttons into columns; and the details column
sat at the top while the loaded one is bottom-aligned, so the title dropped
~150px on load. It is now plain shimmer blocks at the loaded hero's
geometry with the stage height kept while loading. The Xtream series page
shows an episodes section skeleton under it, and the Stalker series hero
holds the Play button's place while seasons load.

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

* test(e2e): the modeled Stalker series renders bare rows

Its episodes repeat the series poster and carry no plots, so the shared
series surface check now expects 44px bare rows without thumbnails or a
grid toggle in both themes. The thumbnail hairline and grid checks stay in
the Xtream suite, whose episodes have stills.

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

* style(details): format the season picker stylesheet

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

* fix(details): episode skeletons never outlive their metadata

The loading state added for TMDB-enriched episode rows could stick or
flicker:
- an empty season is never enriched, so its host never settled it and the
  season showed six skeleton rows forever instead of its empty state;
- Xtream re-marked an enriched season pending on every selection write,
  turning rendered rows back into skeletons (and dropping row focus) for
  each cache read;
- a superseded lookup of a reopened series, or of a Stalker item without
  an id, could settle a newer lookup's pending state or never clear it;
- TMDB requests have no timeout, so a blocked TMDB host held provider
  episodes back for minutes.

Metadata now only holds back a season that has episodes, the wait is
capped at 4s per season, only the latest lookup of a key (or selection)
settles it, a settled Xtream season stays settled within a visit and a
new visit starts its seasons afresh.

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

* fix(details): described seasons render at once; room for titles on phones

Metadata lookups held every season as skeletons until TMDB answered,
even one the provider had already described with plots and stills. The
state now waits for metadata only when the current data would render
bare; described rows show at once and the metadata lands in place.

At a pane width of 480px or less the list row kept the 112px thumbnail
and the action slot beside the text, leaving a 320px phone about 18px
for the title. The thumbnail shrinks to 96px there and the actions take
their own row under the text.

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

* fix(details): keep phone-width grid cards stacked

The phone-width grid-area placements applied to grid cards too, which
declare no template areas, so a card's artwork and text overlapped in a
narrow pane. The placements are scoped to list rows; the surface check
now also asserts a card's text stays under its artwork at 360px.

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

* fix(details): a still that only one episode carries is not a repeat

The distinct-stills check counted unique image URLs and treated a single
one as the series poster repeated, so a season where one episode has a
genuine still and the rest have none lost that still and went bare,
with its grid toggle. One image on one episode is now a still; only one
image on several episodes counts as a repeat.

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

* fix(details): phone-width skeleton rows match the rows they precede

At a pane width of 480px or less the finished list row uses a 96px
thumbnail and an action row under the text, but its skeleton kept the
112px single-row layout, so pictures and text moved when loading ended.
The skeleton row now carries an empty 34px action slot and mirrors the
phone layout.

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

* fix(details): no air date from a placeholder or an impossible day

The Date constructor turned a provider's "0000-00-00" into a day in
1899 and rolled "2025-02-31" into March, so the episode meta line
showed dates nobody sent. An ISO day is now validated part by part and
an invalid one leaves the date out. The season menu's container colour
goes through mat.menu-overrides(), as the UI guidelines require.

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

* style(details): spinner colours through mat.progress-spinner-overrides()

The episode item set the spinner's indicator colour as raw --mat-*
declarations; the UI guidelines want Material tokens set through the
component's overrides mixin, which rejects unknown names at build time.

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

* fix(details): unknown season counts, resume position, hero-skeleton spec

A lazy Stalker VOD season the portal has not answered yet showed
"0 episodes" in the season menu: the picker now takes the per-season
load states and shows no count for such a season. A started episode
whose duration nobody knows (no provider or TMDB runtime, none saved
with the position) lost its saved position from the meta line; it now
reads "Resume at 12:34". The downloads offline-detail spec queried the
ngx-skeleton-loader the hero skeleton no longer uses; it queries the
hero-skeleton test id.

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

* fix(playback): a reused external player starts each load at its own offset

MPV and VLC are launched with a global --start / --start-time for a
resumed title, and a reused process applies that to every later load:
"Play from beginning", the next episode and a later resume all began
at the first launch's offset. The seek sent right after loadfile does
not help — mpv rejects it before the file is loaded, and the command
sender never reads the reply (verified against a real mpv over IPC).

Every reuse load now carries a per-file start (0 unless an offset is
requested): mpv's loadfile options and VLC's :start-time input option.

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

* fix(details): loading flags follow the detail container, menus know unloaded seasons

The fullscreen episode picker reports its season through the same
onSeasonSelected as the detail container, so the Stalker view's
episode-list and metadata loading flags followed whichever season was
picked last: choosing another lazy season in fullscreen turned the
detail page's loaded season into skeletons until the portal answered.
Both flags now follow the detail container's own selection.

The fullscreen panel also shares the season picker but never forwarded
its per-season load states, so a lazy season read "0 episodes" in its
menu; the states are forwarded now.

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

* fix(details): Xtream metadata loading follows the detail container too

Like the Stalker view, the Xtream seasons service keyed its metadata
loading flag off the season selected last, which the fullscreen episode
picker also sets: picking another season there could hide the detail
page's settled bare rows behind skeletons for up to four seconds. The
flag now reads the detail container's own selection; the picker's
choice still gets enriched.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:44:05 +02:00
4grayandClaude Fable 5.1 bc5a7fcbf9 fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive (#1803)
* fix(playback): keep the saved Embedded MPV player when the mpv check is inconclusive

On Linux native-view the support check runs `mpv --version` by bare name
and waits for the login shell PATH first. Since #1784 that lookup is
asynchronous with a 10 s budget; when it ran out, the check ran on the
inherited PATH and answered a plain `supported: false`. The settings store
took that as a verdict and persisted the default player over a saved
Embedded MPV selection. The main process probed again once the shell
answered, but nothing restored the setting.

`EmbeddedMpvSupport` now carries `inconclusive`. The native service sets it
on a missing mpv while its probe has only seen the inherited PATH; the IPC
handler declares that state before probing and registers the re-probe
before the check, so a throwing check cannot leave it stuck. Every other
answer stays final.

Consumers no longer settle on an inconclusive answer: the settings store
keeps the saved player, and the command palette and the settings search
probe again on their next use instead of caching it for the session.

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

* fix(playback): keep asking for Embedded MPV support while the answer is inconclusive

Keeping the saved player on an inconclusive answer left a mounted player
stuck on it: the session controller asked for support once, in its
constructor, and the session effect never starts while unsupported, so the
player did not recover after the login shell answered. The settings page
held its one answer the same way.

`watchEmbeddedMpvSupport()` asks again every 3 s until the answer is final
or the surface is destroyed. The player controller and the settings page
facade load support through it, so playback starts by itself and the
Embedded MPV option appears without reopening the page.

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

* fix(settings): follow an inconclusive Embedded MPV answer to a final decision

The settings store checked a saved Embedded MPV selection once. After an
inconclusive answer it kept the selection and never looked again, so when
mpv turned out to be really missing the player stayed on Embedded MPV
instead of falling back to the default one.

The store now follows the answer with `watchEmbeddedMpvSupport()` until it
is final and only then decides. It acts on an answer only while Embedded
MPV is still the saved player, so a player picked meanwhile, also while
the first answer was pending, is never overwritten.

The watch backs off from 3 s to 30 s between rechecks, so a login shell
that never answers does not keep the app polling at the first rate, and it
no longer schedules a recheck after its answer handler stopped it.

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

* fix(settings): keep the settings search following an inconclusive Embedded MPV answer

The settings page asks the search service for Embedded MPV support once,
when its search facade is created. After an inconclusive answer the service
only probed again on its next call, so with the page left open the
Embedded MPV rows stayed unsearchable after the login shell answered,
while the player option on the same page already updated.

The service now follows the answer with `watchEmbeddedMpvSupport()` until
it is final, which updates the open page and the command palette alike. A
call made while the answer is still inconclusive restarts the watch, so it
asks at once as before.

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

* fix(settings): follow Embedded MPV support for search only while the settings page is open

The settings search service is provided in the root injector, so the
watch it started on its first use had no owner: with a login shell that
never answers it kept asking every 30 s until the app quit, long after
the settings page or the command palette that needed the answer was
closed. A failed recheck also ended the watch as if it were a final
answer, hiding the Embedded MPV rows for the rest of the session.

The service now separates the two uses. `ensureEmbeddedMpvSupportLoaded()`
is a single request again, for the command palette. The settings page
calls `followEmbeddedMpvSupport()` and ends it when the page is destroyed.
Only a final answer is kept: after an inconclusive one or a failed
request the next use asks again, for the palette's player commands too.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 10:54:19 +02:00
4gray ab8460338a feat(ui): cinematic movie and series details pages and dashboard hero (#1792) 2026-10-03 23:08:16 +02:00
4gray d1e79bdc3e fix(parental-lock): per-flow PIN dialog labels and visible mismatch error (#1777) 2026-10-01 11:21:27 +02:00
4grayandClaude Opus 5.5 525ca7bc44 fix(playback): show the Up next card near the real end of an episode (#1768)
* fix(playback): show the Up next card near the real end of an episode

The card appeared a fixed 8 minutes before the end: most of a short
episode, and far into the story of a long one, with no way to hide it.

- Adaptive lead: 4% of the episode, clamped to 40 s … 3 min.
- A closing-credits chapter in the last third, when timeline segments
  carry one, brings the card forward to its start (capped at 5 min).
- Close button and Escape dismiss the card for the current next episode.
- After 10 s (not while hovered) the card collapses into a one-line pill.
- Seconds countdown in the last minute.
- New playback setting "Up next card" (default on) turns it off.

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

* fix(playback): address Up next card review feedback

- Offer the Up next card setting for Embedded MPV only under the
  frame-copy engine; native view never mounts the shared controls.
- Hovering pauses the collapse delay instead of restarting it.
- Document that the card stays visible when the controls auto-hide.

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

* fix(playback): restart the Up next collapse delay for a new episode

- A card that stays mounted while its next episode changes gives the new
  item the full delay instead of the previous item's leftover.
- The setting description no longer promises credit-based timing: no
  current series path supplies chapters yet.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:56:48 +02:00
4gray 484920f10a fix(dashboard): read M3U favorites after queued favorite writes (#1754) 2026-09-30 07:05:48 +02:00
d60c80746b perf(services): share the startup inventory read (J1) (#1716)
* perf(workspace): share the startup inventory read and defer non-first-card IPC (J1)

Journey J1 / renderer.ipcCallsToFirstCard: 12 -> 7.

- PlaylistsService.getAllPlaylists() shares one in-flight SQLite read
  between concurrent callers (the playlist effect and the XMLTV source
  reconciliation at startup). Settled reads are never reused and every
  SQLite write detaches the pending read.
- getM3uFavoriteChannels() stops re-reading the write-once IndexedDB ->
  SQLite migration receipt once it has been seen.
- StartupDeferralService holds the download list, app update status and
  the dashboard's recent items and favorites until one task after the
  render that reveals the routed content (5 s safety timeout).
  IPTVNATOR_DISABLE_STARTUP_DEFERRAL=1 is the kill switch.
- reconcileEpgSources and the two distinct migration-flag reads stay on
  the critical path: the first is the #1548 revision fence, the second
  are different keys, not duplicates.

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

* fix(services): trust a migration receipt written in this session; correct J1 note

- Mark the IndexedDB -> SQLite receipt as confirmed after an empty-store
  receipt write or a committed dbMigrateAppPlaylists, not only when it was
  already present, so M3U favorites skip the per-playlist re-read on the
  first launch after an upgrade too.
- The release note no longer claims a wall-clock speedup the J1 benchmark
  did not show.

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

* perf(services): keep only the shared startup inventory read (J1)

Drop the startup deferral gate, its kill switch and the deferred loads:
they lowered renderer.ipcCallsToFirstCard but moved neither
spawnToFirstCardMs nor load->card beyond drift, and they grew
renderer.initialBytes. Keep the shared in-flight inventory read, inline
it in PlaylistsService with short property names, and copy the joiner's
result with structuredClone (the Electron renderer has it; the services
test setup polyfills it for jsdom).

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

* docs(performance): describe the shared inventory read and the J1 validation result

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

* fix(services): drop the migration receipt memo for M3U favorites

Skipping the receipt read let the dashboard ask the database for M3U
favorite channels before a just-toggled favorite was written: the write
waits in the per-playlist queue and the cross-context lock, and the
dashboard does not reload when the store's favorites are unchanged.
The extra round trip had been masking that race, and the Windows E2E
run hit it (epg.e2e.ts "dashboard live rails find a programme that only
another playlist's XMLTV carries"). The memo was off the first card's
path, so it bought nothing measurable for J1; restore the per-call read.

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

* fix(services): share only the startup inventory read

A pending read was shared with any concurrent caller, but not every
playlist write goes through PlaylistsService: the settings reset deletes
all playlists through DatabaseService, so a caller could join a read
taken before that deletion (Codex review). Share only the first read,
which the startup pair (playlist effect and XMLTV reconciliation) needs
while the startup screen still hides every writing action; sharing ends
when it settles or a PlaylistsService write starts.

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-09-29 17:41:42 +02:00
bcc6186c88 fix(settings): hide cached title matches the parental lock withholds (#1735)
* fix(settings): hide cached title matches the parental lock withholds

Cross-playlist title matches are cached by their consumers (Actor and
Discover routes, the dashboard trending and recommendation rails and
the four "similar in your portals" rails), so matches found while
unlocked kept advertising locked titles and their playlist names after
a relock. Each consumer now filters on read through a reactive
predicate on the match's provider category, so a relock hides them at
once and an unlock shows them again, without a re-query.

Closes #1723

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

* fix(settings): fall back to unlocked copies of a withheld title match

The dashboard trending and recommendation rails and the "similar in your
portals" rails picked one match per title at load, so hiding a withheld
match lost a copy of the same title in an unlocked portal. They now keep
every row the lookup returned and pick the match on read from the rows
the parental lock does not withhold. Adds the release note.

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

* fix(settings): derive the recommendations seed heading from the visible cards

The rail kept only the seeds that contributed a card to the original
selection, so a seed whose recommendations filled in after a relock (or
an offline prune) was missing from the "Because you watched" heading.
Every seed of the load is kept in order and the heading lists those that
contribute a card now.

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-09-29 10:34:10 +02:00
650da4a1d3 ci(test): type-check Jest spec programs and gate it in CI (#1705)
* build(test): make spec tsconfigs resolve what Jest resolves

Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.

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

* ci(test): gate spec type-checking with typecheck:spec

Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.

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

* test: fix the spec type errors surfaced by typecheck:spec

With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.

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

* test(playback): use the ESM setup's jest global in the controls fixtures

The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.

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

* test: type the parental lock doubles merged since the gate was written

The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 20:54:27 +02:00