Commit Graph
75 Commits
Author SHA1 Message Date
e45cd85a78 feat(settings): PIN-protected parental lock for categories (#285) (#1601)
* feat(settings): add PIN-protected parental lock for categories (#285)

Locks are per category (Xtream category ids, Stalker genre ids, M3U group
titles) and kept in one renderer lock store persisted to app_state /
localStorage; `categories.locked` is the SQLite index re-stamped from it.
While the lock is active the DB worker filters every content read, the PWA
data source, the Stalker store and the M3U channel list filter in memory,
and the enforcement service reloads the stores and steps off withheld
selections. Settings → Parental lock sets the PIN (PBKDF2, never in
Settings), the relock timeout and Lock now; lock toggles live in the
Xtream/M3U management dialogs and a new Stalker lock dialog, all behind
the PIN. Backups carry the locks per playlist entry.

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

* fix(settings): harden the parental lock after review

- The M3U group dialog opens only after the PIN, like the Xtream and Stalker
  dialogs: it lists locked group names and can rewrite the locks.
- Change PIN and Disable always verify the stored hash, even while the
  session is unlocked, so an app left unlocked cannot lose its lock.
- Stalker paging judges progress on the raw portal page: withheld ids the
  list has not seen count as progress, a page made only of locked rows
  requests the next one itself, and the VOD total is reduced by withheld
  ids so the grid stops asking once every visible row is in.
- Parental lock contract linked from the agent context map after the
  guidance reorganization; bridge helpers split out to stay under the
  file-size cap.

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

* fix(settings): guard locked categories on routes, PWA search and paging

- Xtream and Stalker `:categoryId` routes carry a parental-lock guard: a
  locked category reached by URL prompts for the PIN and redirects to the
  section root on refusal (Electron row ids are mapped to provider ids).
- PWA search filters withheld categories like the catalog reads.
- Electron warm-cache detection confirms an empty, lock-filtered read with
  the unfiltered existence check instead of refetching from the provider.
- A Stalker lock flip past page 1 drops withheld rows at once and restarts
  the list from page 1 instead of appending onto stale pages.

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

* fix(settings): compile the PIN hashing helper in the Node backend build

The web backend compiles the shared interfaces library without DOM typings,
so the DOM-only `SubtleCrypto` / `BufferSource` names broke its Docker
build. The helper now describes the WebCrypto surface it needs structurally
and reaches it through `globalThis`.

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

* fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

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

* fix(settings): cover PWA cold navigation, Stalker search and the PWA lock editor

- The Xtream detail guard hydrates the PWA session cache before judging an
  item on a cold navigation and fails closed when the catalog cannot place
  the item.
- The dedicated Stalker search route filters withheld genres, re-fires on
  lock changes, judges paging on the raw page and restarts from page 1 on a
  lock flip.
- The Xtream category dialog loads its lock candidates through the
  capability-selected data source; the PWA source now lists its raw
  categories with lock flags, so locks can be configured there too.

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

* fix(settings): arm the relock timer on enable and harden Stalker search relock

- The idle timer follows the unlocked transition instead of `active`, so the
  session that just enabled the lock still locks itself later.
- Stalker search closes an open detail whose genre became withheld on
  relock and advances by itself past pages made only of locked rows (only
  while they add ids the list has not seen).

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

* fix(settings): close lock editors on relock and clear withheld details opened from All

The Xtream, Stalker and M3U category editors are gated by the PIN only when
they open; an idle relock left them on screen listing locked names with a
lock-rewriting Save. Each now closes itself when the session relocks.

ParentalLockEnforcementService also judges the selected Xtream/Stalker
item by its own category: a detail opened from All, recently added or
search has no selected category to vanish with, so it stayed open after a
relock.

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

* fix(settings): fail closed on unreadable settings and finish relock clean-up

- Unreadable settings (IndexedDB load failure) left the feature switch at
  its default and announced "unlocked" to the main process. A stored PIN
  now stands in for the switch, and without one nothing is announced, so
  the worker keeps its mirrored locked default.
- Lock applies run one at a time and abandon superseded results; the
  Electron data source keys its in-flight share by lock version so a
  relock can never reuse an unlock refresh's unfiltered rows.
- The stored in-portal Xtream search is re-run on a lock change.
- Stalker live/radio selections are judged by tv_genre_id, and both live
  layouts drop the playback of a channel whose category became withheld.

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

* fix(settings): fail closed on an unreadable lock store and make lock writes reliable

- A lock store that cannot be read is no longer treated as empty: while
  the lock is active every category is withheld (renderer predicates and
  set-based filters alike) until the PIN is entered or the store reads
  again, and writes are refused meanwhile so an empty in-memory store can
  never wipe the persisted locks. The lock set now lives in its own
  ParentalLockLockStore service.
- The M3U group dialog's lock write is awaited and a failed save is
  reported in a snackbar instead of being silently dropped.
- The Electron categories.locked re-stamp clears and re-locks inside one
  transaction, so a failed restamp keeps the previous index.

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

* fix(settings): drop pre-relock Stalker search pages and fail closed on a corrupt lock store

- A Stalker search page issued before a relock was filtered with the
  pre-relock withheld set and could still be applied after it; the
  staleness check now includes the parental lock version.
- A lock store payload that does not parse or is not an object is a
  failed read (everything withheld until it reads again), no longer an
  empty store.

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

* fix(settings): close the startup, re-stamp, relock-refresh and switch-persistence gaps

- The window before the initial lock store read settles now withholds
  everything, like an unreadable store: settings can report the feature as
  on before the locks are known.
- The store commits before the SQLite index re-stamp; a failed re-stamp
  now rolls the store back, a failed rollback re-stamps on the next
  access, and every launch re-derives the index from the store.
- Xtream category/content reloads fail closed: a rejected reload empties
  the affected lists (content types drop back to idle) instead of keeping
  rows read under the previous lock state.
- Enabling/disabling the feature persists through one guarded path that
  undoes the in-memory switch and skips the Electron mirror on a failed
  settings write.

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

* test(xtream): move the parental-lock reload specs beside the content spec

The content feature spec sits at the 1200-line spec cap.

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

* fix(settings): await the startup lock-index reconciliation and withhold genre-less rows when failing closed

- The lock store is readable only once the SQLite index has been re-derived
  from it, and a re-stamp that keeps failing keeps the session fail-closed,
  so catalog reads can never serve rows stamped unlocked by a stale index.
- While everything is withheld, Stalker rows without a genre are withheld
  as well (the store filter and the renderer predicate).

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

* fix(settings): await the enablement mirror, restore partial lock stamps and validate nested lock-store entries

- The Electron mirror of the feature switch is awaited; a mirror that
  cannot be written undoes the settings write, so a reload never starts
  from a mirror that disagrees with the persisted switch.
- A failed multi-type re-stamp rolls the store back AND re-stamps every
  touched type from it, since earlier types may already carry the new
  locks; a failed rollback keeps the playlist stale (fail-closed).
- A persisted lock store whose nested entries are not what writeLocks
  produces is a failed read, not an empty store.

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

* fix(settings): withhold the Xtream catalog at relock time, keep exact M3U titles in backups, roll back a failed relock-timeout save

- A relock now fails closed immediately: the selected detail is stepped
  off against the lock store, the catalog lists and stored search results
  are emptied, and the filtered reloads publish only while the captured
  lock version is still current.
- Backups carry M3U lock titles verbatim (exact dedup), since the locks
  match group titles exactly.
- A relock-timeout write that fails reverts the in-memory value and shows
  the settings save-failure snackbar.

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

* fix(settings): clear the lock index before a playlist's last lock leaves the store, retry failed PIN reads, guard backups on the lock store

- A write that removes a playlist's last lock clears the SQLite index
  first and drops the store key afterwards, so an interruption between the
  two can only leave a state the startup reconcile repairs toward locked.
- A PIN hash read failure is distinct from an absent PIN: the session stays
  locked and every PIN-protected step re-reads it first.
- Backup export awaits parental lock initialization and refuses to run
  while the lock store is not readable, since an absent lock field means
  "no opinion" on restore.

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

* fix(settings): withhold Electron Xtream reads while locks are unknown, persist the switch when settings are unreadable, re-stamp after a recovered read

- ElectronXtreamDataSource serves no categories, content or search hits
  while the lock store withholds everything; its SQLite index may still
  carry a stale stamp.
- setupPin decides whether to persist the switch from the settings value
  before the PIN is stored, since enabled follows hasPin while the switch
  is unknown.
- A lock store recovered by a later read marks its playlists stale so the
  index is re-derived, a persisted entry must carry all three lists, and a
  stale Stalker search page is dropped before touching the withheld-id
  bookkeeping.

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

* fix(settings): keep unlocked category routes reachable and defer a relock reload that overtakes the initial hydration

- The Xtream category guard no longer runs the item check on category-only
  routes (Number(null) is 0), which prompted for the PIN on every unlocked
  VOD and series category while the lock was active.
- A lock change during the initial Xtream hydration withholds the rows the
  hydration publishes and runs the filtered reload once it has settled,
  on every path that marks the content initialized.

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

* fix(settings): resolve hidden live categories before relocking playback and reload categories in the deferred hydration path

- The Xtream live layout resolves a playing channel's category through
  the unfiltered rows when the visible list lacks it (search can play a
  hidden category's channel); until that lookup lands the category is
  unknown and a relock stops the channel.
- A relock that overtakes the initial hydration now withholds the
  category publications too and reloads categories with the content.

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

* fix(settings): step off the M3U channel and Stalker selection before awaiting the Xtream relock reload

The Xtream store stays populated after leaving that portal, so its reload
runs on every apply; a locked M3U channel no longer keeps playing behind a
slow database or provider read.

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

* fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries

- The workspace route resolver awaits ParentalLockService.initialize()
  next to the settings load, so no route or catalog activates before the
  PIN and lock store are known.
- Every lock write re-reads a failed store before building its edit, so a
  recovered store is edited rather than overwritten.
- The deferred hydration reload runs under the publish guard of the
  request that deferred it.
- Backup import validates every parental lock entry and rejects a damaged
  list instead of erasing the persisted locks on restore.

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

* fix(settings): discard stale hidden-category lookups and key withheld Stalker rows by their real identity

- A hidden-category lookup that lands after a later playback (same
  provider id, another playlist) no longer overwrites the newer channel's
  category; resolutions are generation- and playlist-checked.
- Withheld Stalker rows are keyed by id, stream_id, movie_id, series_id
  or the row's cmd/name, so id-less rows no longer collapse onto one key
  and stall paging past locked pages.

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

* fix(settings): gate the Electron cached category/content reads while locks are unknown

The warm-route hydration reads the cache directly; it now returns nothing
while the lock store withholds everything, like the live reads.

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

* fix(settings): retire in-flight searches on relock clearing and publish lock revisions after the stamps

- clearSearchResults() advances the search request version, so a search
  issued under the previous lock state cannot republish what a relock
  just cleared.
- A lock write publishes its store revision only once every touched type
  is stamped, so a reload triggered by it cannot read a later type
  through its old stamps.

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

* fix(settings): retire a resolving Stalker live playback when the session relocks

The embedded player defers selecting the channel until its stream
resolves, so the enforcement service's cleared selection could not retire
the request; it now carries the lock version it was issued under and is
dropped when a relock happened meanwhile.

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

* feat(settings): one lock entry point per rail plus a right-click Lock/Unlock

- Stalker's dedicated lock button becomes the same "Manage categories"
  (tune) button the Xtream rail has; it opens the lock-only dialog, so
  every portal type shares one entry point and the rail header keeps
  three actions.
- Right-clicking a category (Xtream, Stalker) or an M3U group offers a
  single-row Lock / Unlock through the shared CategoryLockMenuComponent,
  behind the same PIN gate and lock store as the dialog.
- The settings hint explains where locks are set; group lock strings added
  to all locales (ru/de translated).

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

* fix(settings): serialize lock-store writes and drop a deleted playlist's locks

- Lock-store mutations run through one write queue: each rewrites the
  whole persisted store, so overlapping edits could otherwise snapshot
  the same store and the later write would drop the earlier edit.
- Deleting a playlist removes its locks through the PLAYLIST_DELETE_CLEANUP
  hook; "Remove all playlists" clears the lock store once the deletion
  has succeeded.

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

* fix(settings): apply single-row lock toggles inside the lock store's write queue

The right-click Lock/Unlock (portal categories and M3U groups) built the
new list before entering the queue, so two quick toggles shared one
snapshot and the second dropped the first. Lock writes now accept an edit
of the current list, evaluated inside the queue.

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

* fix(settings): retry a failed settings read before any parental-lock settings write

updateSettings writes the whole settings object, which after a failed
startup read is the defaults; enabling the lock or changing the relock
timeout then replaced the user's persisted preferences. The read is
retried first and the write refused while settings stay unreadable. The
settings writes move to parental-lock-settings-writer.ts.

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

* fix(settings): show the locked-groups row on the M3U rail and roll the relock timeout back to the recovered value

- The M3U groups rail now renders the same "N locked · Enter PIN to show"
  row as the portal category rail, so locked groups no longer vanish
  without an in-context unlock.
- A failed relock-timeout write rolls back to the value read after the
  settings retry, not to the pre-retry default.

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

* fix(settings): retry a failed clear-all of the lock store and keep restored new playlists free of stale locks

A lock-store clear that failed after "Remove all playlists" only logged,
so a later restore reusing a playlist id could inherit the deleted
playlist's locks. The in-memory store now empties at once and the
persisted clear is retried on the next access; a restore that creates a
playlist starts it from empty locks.

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

* fix(settings): count radio playback as lock activity and read the lock store before a restore's stale-id check

- The idle relock no longer interrupts a playing radio station: playing
  <audio> counts as activity, like video.
- A restore retries a failed lock-store read before checking a reused id
  for stale locks, and aborts while the store stays unreadable.

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

* fix(settings): drop withheld Stalker search rows at relock time and keep the M3U unlock row when every group is locked

- A relock during a page-1 Stalker search now filters the rows already on
  screen at once, so old unlocked results are not clickable while the
  replacement page is pending.
- When every M3U group is locked the groups rail still renders, with its
  "N locked · Enter PIN to show" row, instead of the plain empty state.

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

* perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path

Master (#1712) moved the UI component barrel and the Stalker data layer out
of main.js and tightened the initial budget to 2 MB. The parental-lock
prompt imported the PIN dialog through the ui/components barrel and the
enforcement service injected the Stalker store at startup, which pulled
both back in (2.55 MB, over budget). The PIN dialog now loads through a
local lazy file on the first prompt, and the Stalker step loads only while
a Stalker route is open: initial total 1.65 MB.

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

* fix(settings): restore the lock index when an emptying write fails and publish rollbacks after re-stamping

- Removing a playlist's last lock clears the SQLite index first; if the
  clear or the store write then fails, the index is re-stamped from the
  previous locks at once. Title matching and multi-source discovery query
  the worker directly and trust the index, so the stale flag alone did not
  protect them.
- A rollback publishes its store revision only after every type is
  re-stamped, so a reload cannot read a later type through the attempted
  stamps.
- docs: restore the index rules the earlier surfaces rewrite dropped from
  the contract, now in the Lock store lifetime section.

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

* fix(settings): keep the M3U groups view when every group is locked

With every group locked the filtered channel list is empty, so the
container showed its generic empty state and the groups rail's
"N locked · Enter PIN to show" row never appeared.

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

* fix(settings): fail closed when the lazy Stalker enforcement step cannot load

A rejected chunk (e.g. a stale PWA page after a deployment) escaped
applyStalker(), so a locked Stalker selection kept playing after a relock
and the Xtream step was skipped. The step now leaves the Stalker route on
a load failure, which clears the selection and stops playback, and the
Xtream step still runs.

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

* fix(settings): defer a stale Xtream hydration as soon as the catalog is withheld

On Electron a relock that overtook the initial content hydration waited
for the category reload before the content reload set the deferral flag;
the older unlocked hydration could publish its streams in that window.
withholdCatalog() now sets the flag itself, before anything is awaited.

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

* fix(settings): restore backup locks after the Xtream merge and snapshot the Stalker lock dialog's categories

- A backup restore now writes the parental locks last, so a failed Xtream
  merge leaves the playlist's previous locks in place instead of the
  backup's possibly smaller set.
- The Stalker lock dialog snapshots the category list before its lazy
  import and opens only if the route is unchanged, so another portal's
  categories can never be saved under this playlist.

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

* fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store

- On relock the synchronous fail-closed steps (M3U channel, Stalker
  selection, the locked Xtream detail, catalog lists, stored search) run
  immediately instead of queueing behind an earlier apply that may still
  wait on a slow or hung read.
- The post-reload Xtream checks decide by the lock store through the
  unfiltered category rows rather than by absence from the reloaded list,
  which also omits merely hidden categories; unreadable rows fail closed.

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

* fix(settings): require the PIN for lock-bearing backup restores and fail closed during index re-stamps

- A backup carrying lock lists replaces the matching playlists' locks,
  possibly with an emptier set; the import now asks for the PIN (after the
  file was chosen) and aborts when it is refused.
- While a write re-stamps the SQLite index the playlist counts as stale,
  so a relock inside that window reloads fail-closed instead of through
  the old stamps. The internal store write now needs only a readable
  store, so a rollback can still land.

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

* fix(settings): drop parental-lock Xtream reloads once the playlist is switched

A reload issued for playlist A no longer publishes into the shared Xtream
store after the user opened playlist B: the store's reloads guard on the
playlist they read for, and the enforcement apply retires its search
refresh and selection checks on a playlist switch as on a newer lock
version.

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

* fix(settings): re-ask the PIN before a relocked backup merge and keep the PIN cooldown across prompts

A backup merge now asks for the PIN again right before it replaces a
playlist's locks when the app relocked during the import, instead of
relying on the answer given at the start. The wrong-PIN count and the
30-second pause move from the dialog into the lock service, so
dismissing and reopening the prompt no longer resets them.

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

* fix(settings): refuse lock removals that commit after a relock and keep the index stale until the store write lands

Lock edits that take a lock away now commit only while the session is
unlocked, checked inside the write queue at commit time, so an editor
save still in flight (or queued) when the app relocks cannot remove
locks. Adding locks stays allowed. The Xtream category dialog drops its
lock draft after a relock, and a backup restore re-asks the PIN only
when it would remove a lock. Clearing a playlist's last lock keeps its
index stale until the store write has landed.

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

* fix(settings): clear an Xtream detail from a hidden category synchronously on relock

The synchronous relock step now clears a selected Xtream item whose
category the visible category list cannot place (a manually hidden
category opened through search), instead of leaving it usable until the
awaited reloads and lookup finish.

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

* fix(settings): fail closed when the Electron bridge lacks the parental lock worker filter

A new runtime capability requires the lock-state and index-stamping IPC.
When Electron reads Xtream through the SQLite worker without it (a
partial or older preload), the locked session withholds every category
instead of trusting a worker that never learned the lock state.

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

* fix(settings): re-check lock removals at their durable commit points

A lock removal that passed the unlocked check before its write is asked
again right after the store write and, for Xtream, after the index
stamps. A relock in between writes the previous store back or rolls the
stamps back before anything is published. The stale-index bookkeeping,
index stamping and store merge move into helpers to keep the lock store
within the file size limit.

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

* fix(settings): retry a failed revert of a refused lock removal and fail closed meanwhile

When writing the previous store back after a relock-refused removal
fails, the lock store now keeps a pending rewrite, is not readable (the
locked session withholds everything) and rewrites the persisted store
from memory on the next access, so a restart cannot load the removal.
A failed "Remove all playlists" clear shares the same retry.

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

* fix(settings): authorize lock removals when issued and close genre-less Stalker details in fail-closed mode

A lock removal is now authorized right before its first write is issued;
a relock that lands after that is ordered after the write, which
completes. This drops the post-write rollback, whose own failure could
leave the persisted store diverged from memory across a restart. The
Stalker search closes a detail without a genre on relock while every
category is withheld.

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

* fix(settings): restore the previous locks when an Xtream rollback write fails

When an Xtream lock edit's re-stamp fails and the rollback store write
fails too, memory now goes back to the previous locks and a pending
rewrite persists them on the next store access before the index is
re-stamped, so the failed edit cannot take effect through that re-stamp.

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

* test(stalker): cover page-one rows leaving the screen on relock while the reload hangs

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

* fix(settings): offer the portal unlock row in fail-closed mode

When the lock store cannot be read every portal category is withheld
but no locked ids are known, so the rail showed no "Enter PIN to show"
row. It now shows the row without a count in that state.

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

* fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN

Catalog title matching and multi-source discovery query the SQLite
worker directly; they now return nothing while the parental lock
withholds everything (unreadable store or a bridge without the worker
filter). The Stalker lock dialog captures its playlist, provider and
section before the PIN prompt and re-checks them after it.

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

* fix(settings): retire multi-source alternatives on a lock change and keep reconcile off in-flight stamps

The VOD multi-source host keys its discovery session to the parental
lock version: a lock change drops the discovered sources, retires
discoveries and switches in flight, and rediscovers through the
worker's new lock state. Stale-index entries of a write still stamping
are no longer retried by a concurrent reconcile, which could re-stamp
from a store the write had not committed yet.

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

* fix(settings): fail closed on a rejected worker lock sync, tear down Stalker synchronously and capture the Xtream dialog context before the PIN

- A rejected lock-state sync to the SQLite worker makes the locked
  session withhold everything until a later sync succeeds.
- The Stalker enforcement chunk is preloaded when a Stalker route
  opens; a relock runs it synchronously, or leaves the route at once
  while it is not loaded, instead of awaiting the chunk.
- Xtream "Manage categories" captures playlist, provider and section
  before the PIN prompt and re-checks them after it and after the
  dialog import.

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

* fix(settings): keep the relock timeout behind the PIN and bind M3U group lock toggles to their playlist

A locked session can no longer change the relock timeout: the Settings
selector is disabled until the PIN is entered and the service refuses
the change while locked. An M3U right-click lock toggle now captures its
playlist before the PIN prompt and is saved only if that playlist is
still open.

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

* fix(settings): reset a locked M3U channel however it becomes active

The enforcement service now checks the active M3U channel whenever it
changes while locked, so numeric zapping, next/previous and remote
commands, which select from the full channel list, cannot start a
channel of a locked group. Numeric zapping also skips such a channel.

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

* fix(settings): bind the M3U group management result to its playlist

The groups view captures the playlist before the PIN prompt and drops
the management dialog's hidden and locked group lists once another
playlist is open, so they cannot be saved under that playlist.

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

* docs(parental-lock): record the accepted restart case of a failed rollback write

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

* fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

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

* fix(settings): keep the parental lock switch on the saved state and roll back to the recovered value

The Settings switch snaps back to the saved state when clicked and
follows it once the PIN action succeeds, so a cancelled or refused PIN
no longer leaves it showing the opposite state. A failed switch write is
undone to the value read after the settings retry instead of the
hard-coded inverse.

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

* fix(settings): match noncanonical PWA Xtream category ids against their locks

The PWA data source compared raw provider category ids such as "009"
with locks stored as numbers, so such a category stayed visible while
locked. Both sides are now compared in canonical numeric form.

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

* fix(settings): close the M3U group editor on any relock

The group management dialog lists every group name, locked ones
included, even when it opened without lock toggles (unreadable lock
store). It now closes on any relock, and the groups view re-checks the
lock state before opening it.

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 16:38:34 +02:00
f0e51d2806 perf(web): keep lazy-only services and SafePipe out of main.js (#1729)
* perf(web): keep lazy-only services and SafePipe out of main.js

The eager shell imported barrels that re-export Angular injectables and a
pipe it never uses, and their static definitions keep those modules in
main.js: PlaylistFileImportService came with PlaylistContextFacade,
normalizeDateLocale with SafePipe, and the workspace-shell-util barrel with
SettingsContextService, which #1714 grew with match counts. That growth put
master 108 bytes over the renderer.initialBytes baseline #1712 had measured
on a branch without #1714.

Add file-level entries for the three modules and use them from the eager
and settings code: renderer.initialBytes 1,626,127 -> 1,619,993 bytes.

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

* chore(performance): lower the initial-bytes baseline to 1,619,993 bytes

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-27 14:21:40 +02:00
ea51cae3db feat(settings): search settings from the header and the command palette (#1714)
* feat(settings): search settings from the header and the command palette

The header search on the Settings page was shown but disabled. It now
searches a shared index of all 56 settings rows by translated title,
description and English synonyms, replaces the section page with ranked
results, and opens a result by scrolling to, focusing and briefly
highlighting its row. Enter opens the best match, and the section
navigation shows per-section match counts.

The command palette gains a "Settings" group that lists the best six
matches for a non-empty query, so any setting is one Ctrl/Cmd+K away.
Rows hidden by the current form state fall back to the control that
reveals them; rows the runtime cannot render are never returned.

The index ships through a new @iptvnator/workspace/shell/util/settings-search
sub-entrypoint so it stays out of the eager bundle, and a registry spec
keeps it in step with the section templates.

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

* fix(settings): let search reveals win over pending input and gate embedded MPV rows

- A reveal (result click, command palette, Enter) now cancels a search
  keystroke still waiting for its debounce, so its q navigation can no
  longer supersede the reveal and leave the results open.
- Embedded MPV extra options and auto-reconnect require a lazily probed
  embedded MPV capability; frame copy also needs frameCopyAvailable, so
  search never offers a row the settings page cannot render.
- Keyboard users keep a focus-visible ring on the revealed row after the
  highlight fades.

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

* fix(workspace): wait for palette probes without Promise.allSettled

The web tsconfig lib predates Promise.allSettled; use Promise.all over
rejection-safe probes instead.

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-27 07:42:53 +02:00
4grayandClaude Fable 5.1 6f7973a9fb feat(updater): nightly builds and a stable/nightly update channel (#1608)
* feat(updater): nightly builds and a stable/nightly update channel

Every master push publishes its artifacts as a prerelease of
4gray/iptvnator-nightly instead of the rolling test-master draft, with a
version of <next patch>-nightly.<commit date>.<run number> applied in
every build job. Settings → About gains an Update channel switch;
AppUpdateService re-points electron-updater per check (feed repository,
allowPrerelease, channel name, allowDowngrade reset) and reads release
notes from the repository the requested version belongs to. Channel
switches are forward-only: a nightly build stays until a newer stable
release exists.

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

* fix(updater): compute the nightly version once and keep re-runs safe

Review follow-ups: the nightly version is resolved by a leading job and
handed to every build job, and the patch is bumped only when the base
tag already exists so the release-cut window stays below the imminent
release. A re-run never deletes a published nightly; only a draft left
by a failed run is replaced. Typed update-status literals in the
remaining specs carry the new channel fields.

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

* test(packaging): expect the nightly-version prerequisite in the build workflow graph

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 17:49:22 +02:00
4gray e9eca1c386 chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2

* fix(deps): complete Angular migrations after rebasing on master

* fix(ci): use the Node pin for Windows runtime refresh

* docs(deps): synchronize the workspace-shell Node requirements
2026-09-14 19:02:40 +02:00
4gray 9de480826c fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion

* fix(epg): close source reconciliation review races

* fix(epg): serialize cleanup with replacement imports

* fix(epg): report retired worker exits as cancellations

* fix(epg): preserve source metadata through cache cleanup

* refactor(epg): separate worker runtime and import lifecycle

* fix(epg): skip cleanup for unchanged source settings

* fix(epg): cancel retired error rows and pending retries

* fix(epg): redact diagnostics and mirror committed settings after cleanup errors

* fix(epg): preserve metadata writer order independently of timestamps
2026-09-06 07:32:30 +02:00
4gray 0245d73d78 feat(portals): make connection cooldown configurable in desktop settings (#1536) 2026-09-05 11:18:05 +02:00
4grayandClaude Fable 5.1 fa9084fca3 feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized /
fullscreen), Electron only, mirrored into the main-process config by
SETTINGS_UPDATE and applied at the next window creation. `--fullscreen`
forces one fullscreen launch (consumed by the first window). F11 toggles
OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on
Windows/Linux where the title bar is hidden — and is skipped while the
player owns document.fullscreenElement.

attachWindowStateEvents tracks native and HTML fullscreen as two flags,
since Electron leaves only the HTML state when the window was already
natively fullscreen. macOS ignores the constructor `fullscreen` option on a
hidden window, so ready-to-show repeats the request after show(). Toggles
are decided by an observe-only, event-fed tracker
(native-fullscreen-transitions.ts), never against isFullScreen().

Closes #1455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:49:41 +02:00
4gray ae375e0e8f fix(settings): protect unsaved edits on window close, quit, and reload (#1394) 2026-08-09 18:45:48 +02:00
4grayandClaude Fable 5 1a6af75761 feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar

Replace the single scrolling settings page with routed section pages
(/workspace/settings/:section): the context-panel rail links each section,
only the active section renders, and unknown or capability-gated sections
redirect to General. The shared form lives on the parent component, so
staged edits survive section switches; a floating unsaved-changes bar
(Save/Discard) replaces the always-visible footer Save button. Rail links
navigate with replaceUrl so Back still leaves settings in one step.

Along the way:
- delete the unreachable settings dialog mode and the dead
  AppPortalNavigationActionsService with both of its never-injected DI
  tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS)
- delete the scroll-spy directive and pendingScrollTarget plumbing
- revive the EPG panel's "Open EPG settings" empty-state button as a deep
  link to /workspace/settings/epg; the M3U player now reports
  m3u-needs-setup only when the channel has no programmes and no EPG
  source exists in settings or on the playlist itself
- load TMDB cache stats when the Metadata page opens (the section
  component now only exists while its page is open)
- add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales

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

* feat(settings): confirm before leaving with unsaved changes

Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with
a three-action dialog: save and leave, leave without saving, keep editing.
The guard only intercepts leaving the settings AREA — section switches
share the one settings form and pass unconditionally, so the dialog can
never nag while moving between pages. A failed save cancels the navigation
instead of silently dropping the edits it promised to keep; leaving
without saving also reverts the live theme preview. Save-and-leave is
disabled while the form is invalid, with a hint explaining why.

New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales.

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

* fix(settings): stage cover size and EPG view mode; adapt e2e to section pages

Cover size and EPG view mode were the only two controls that persisted
eagerly on click, which made Discard (and leave-without-saving) unable to
revert them: hydrateFromStore() faithfully reloaded the just-persisted
edit. They now stage in the form like every other setting and reach the
store on Save. Review finding by Greptile (P1) and Codex.

E2E suites that walk through settings are updated for one-section-page
rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the
staged cover size (downloads asserts the dataset after Save); the EPG icon
fallback test saves before leaving settings so the new unsaved-changes
dialog does not block its navigation.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 09:34:03 +02:00
4grayandClaude Opus 5 063662028a feat(portals): find the same movie in your other playlists (#1286)
* feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

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

* fix(portals): stop stale source resolutions from committing

Addresses three defects Greptile found in the multi-source review.

**Concurrent switches committed out of order.** Selecting a second source
before the first resolution returned let the slower request overwrite the
newer selection and repoint Undo at itself. `switchTo` now takes a sequence
number and drops its result if a newer switch already committed.

**Stale switches crossed movie sessions.** Navigating to another film while a
resolution was in flight let the continuation activate the old film's source
inside the new controller — and restart it from that session's zero resume
position. The controller is now snapshotted per operation and the movie
session is revalidated after every await. `check()` had the same hazard across
its two awaits and is guarded the same way.

**Short titles skipped discovery entirely.** The trigram tokenizer cannot index
tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH
expression and the query was discarded before SQLite was consulted — the chip
could never appear for those films. Discovery now falls back to a bounded scan
when FTS structurally cannot serve the title; the existing two-tier normalized
confirmation still rejects loose hits like "Upgrade".

Each fix carries a regression test; all three were mutation-checked by removing
the guard and confirming exactly those tests fail. The previous test asserting
that short titles return nothing encoded the bug and has been replaced.

The host spec passed 400 lines, so its fixtures moved to a shared module and
the race suite into its own file.

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

* fix(portals): make the pin decide playback and keep failover going

Second round of Greptile review findings.

**A pin had no behavioural effect.** Loading a stored pin only decorated the
row: Play still started the route's playlist and failover ranking ignored
`isPinned`, so "make this the main source" survived a restart as an icon and
nothing else. The primary action now starts from the pinned source when one is
set, and the pin outranks everything else in failover ranking.

**Failover stopped at the first unresolvable candidate.** An expired account or
a failing `get_vod_info` on the top-ranked source ended the attempt, and since
production calls `failover()` only once — on the original playback failure — a
healthy lower-ranked source was never reached. It now continues through untried
candidates. `switchTo` reports why it stopped so the loop can tell "could not
resolve, try the next one" from "something newer owns the screen"; without that
distinction a superseded switch would have spun forever, because only the
former marks the candidate tried.

**Identity ignored enrichment.** The key was `playlistId:contentId:title`, so
when `get_vod_info` added a TMDB id and release year to an unchanged title the
host saw no change, never reloaded, and kept yearless discovery and title-only
pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every
field that affects matching.

**A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or
501 to HEAD yet serve the media over GET. The probe now retries once with the
ranged GET the main process already supported, instead of caching a working
source as failed and penalising it during failover.

Greptile also flagged a missing token check after the resolve await in
`switchTo`; that guard landed in 4db3a2fd and sits on the line directly below.
Answered on the thread rather than changed.

The host service passed 400 lines again, so the pin, probe, switch-notice and
current-row concerns moved into focused modules beside it.

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

* docs(portals): record the behaviour the review rounds changed

The architecture doc and CLAUDE.md described the feature as first written, not
as it now behaves: pins were documented as a stored preference without saying
they decide playback, failover was described as stopping at the first
unresolvable candidate, the probe as HEAD-only, and discovery as pure FTS with
no mention that short titles cannot be tokenized at all.

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

* fix(portals): invalidate the session while the movie identity is empty

The staleness guard added in 4db3a2fd bumped the session only inside `load()`,
which leaves a window the guard does not cover: route navigation empties the
movie identity first, and `load()` for the replacement runs only once a title
is knowable again. A resolution completing in that interval still carried a
session number that matched, so it passed the check and started the previous
movie's source over the page the user was navigating to.

The binding effect now bumps the session as soon as the identity goes null, so
anything already in flight is invalidated at the moment the old movie stops
being the one on screen rather than when the next one finishes loading.

`lastMovieKey` is deliberately left alone: returning to the same movie should
not re-run discovery, and the controller's state is still correct — only the
in-flight operations needed invalidating.

Regression test added and mutation-checked: removing the bump fails exactly
that test.

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

* fix(portals): stop the source list from losing the real alternatives

Six review findings, all in how multi-source decides what to show and what
it is playing.

Discovery: the current playlist is now excluded in SQL rather than after the
fact, so its own duplicate rows can no longer spend the whole row budget
before a single alternative is read. The short-title scan matches the token
as a word instead of a substring and orders by title length in a wider
window, so "Titanic" and "The Italian Job" cannot push the real "It" out of
it.

Session: metadata enrichment re-runs discovery for the film already on
screen. That is a refresh, not a new session — a second identity key
(playlistId:contentId) now separates the two, so the source the user
switched to keeps playing and stays named, the tried set stays burned, the
position survives and a switch in flight still commits.

Resume: the multi-source controller no longer records the engine's pre-seek
timeupdate at ~0. The playback service's one-shot latch now reports whether
the position can be believed, and until it can, the requested start time
stands in — so a switch during the initial seek does not restart the film.

UI: the in-player sources picker gets the same auto-failover setting and
match kind as the detail page's, instead of always rendering the default and
dropping the toggle. The caption counts distinct playlists, not stream
variants, since the popover groups a portal's copies under that portal.

Session mechanics and the pin toggle move into their own modules to keep the
host service inside the line budget.

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

* fix(portals): keep the playing row when the refined year rejects it

Follow-on from keeping the session across a rediscovery. The rerun can
legitimately drop the row that is playing: enrichment supplies the release
year, and the year gate then rejects a copy the yearless search had admitted
— "Dune" 1984 while the user is watching the 2021 film.

Off the list is right; it is not the same film. Off the screen is not. It is
what is streaming, so it stays as a row and keeps the playing badge, rather
than letting the caption name a playlist that is not sending any bytes.

Also covers the new session key directly in the identity spec.

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

* fix(portals): stop a pin write from landing on the next movie

Two findings from the review of the previous round.

A pin write is an IPC round-trip, and the user can navigate during it. The
continuation then applied one film's answer to another film's controller —
and because unpinning returns "nothing pinned", it would clear the pin the
new movie had just loaded and its Play action would quietly stop starting
from the preferred source. It now commits only while the same film is still
on screen, like every other async path here.

The short-title scan drops its row limit. FTS keeps its window because it
ranks by relevance, so what it keeps is what matters; a scan cannot rank, so
a window there silently decides which valid sources the user is allowed to
see. It also bought nothing: the GLOB cannot use an index, so SQLite reads
every row either way and the limit only truncated the answer. What bounds
the scan is its predicate — reaching it means the whole title is one or two
characters.

The switch-notice type moves to the module that builds it, which also
removes a circular type import between the two.

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

* fix(portals): keep external playback, the pin and the resume point honest

Five findings from the round-5 review.

An external player launched for an alternative carries that playlist's ids,
so the page disowned its own session: the primary button never became Stop,
stopping found nothing to stop, and another click opened a second player.
Multi-source now tells playback which source is actually active, and the
matcher accepts either that or the route's own stream.

Stop also has to beat the pin. The primary action consults the pin first —
that is what makes a pin decide where playback starts — but while a session
is running the same button reads Stop, and consulting the pin there made the
control do the opposite of its label.

A pinned source started from the Resume button resolved at zero, because
nothing reports a live position until the first timeupdate. The controller is
now seeded from the persisted position, one-way: a live value always wins,
since the stored one lags it and applying it would rewind.

A pin whose write failed was still shown as pinned, promising a preference
that reopening the movie would not have.

Portal failures in this path logged raw errors. An Xtream error message
carries the stream URL, and that URL is built out of the username and
password, so they now go through the redacting logger.

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

* fix(portals): make every alias of a pin agree, and stop losing rows

Four findings from the latest review pass.

A pin lookup accepts several aliases of the same movie, but a write only
touched the most-trusted one — so after enrichment the title alias still
pointed at whatever was pinned before, and a reopen that read it (because
TMDB had not landed yet, or its request failed) started the source the user
had just replaced. Writes now go to every alias.

That alias set was also missing one. Enrichment supplies the year as well as
the id, so a pin set before either existed is stored yearless; the candidate
list skipped that form entirely and orphaned the row.

Discovery could lose whole playlists: one playlist listing a film in dozens
of categories produces identically ranked rows that fill the window before
another playlist is read. The collapse now happens in SQL, before the limit,
rather than in TypeScript afterwards where the missing rows are already gone.

And an abandoned source pick finishing late cleared the spinner from the row
the user was actually waiting on.

Removes `isExhausted()` from the host service — no caller outside its own
tests, where the assertion above it already proved the same thing.

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

* fix(portals): probe like playback, and stop the pin answering for a remake

Five findings from the latest review pass.

Writing a pin to every alias — last round's fix for stale aliases — was
wrong in the other direction: `title:{base}:` is shared by every remake, so
a known-year decision stored there answers for a different film. Pin Dune
(2021), open Dune (1984) before its year arrives, and it would start the
2021 source. A write now clears every alias and stores only the canonical
key, which retires the stale ones without making any of them ambiguous.

The probe checked a bare URL while playback sends the playlist's User-Agent,
Referer and Origin. A panel that requires them answers 401/403, so a stream
that plays perfectly was reported dead and penalised in failover ranking.

The switch toast interpolated the raw playlist name. Users routinely name a
playlist after the URL they pasted, so that line could put credentials over
the video; the notice now carries the same safe label the rows use.

External players have no timeupdate, so their polled position IS the live
one. Feeding it through the seed — which stops at the first value — froze
the resume point where playback started, and a switch an hour in rewound to
the beginning.

And auto-failover concluded "nowhere to go" when a stream failed before
discovery answered, stranding the user on the error screen.

Moves `switchTo` into the session module, which is where the rest of the
switch mechanics already live, and splits the route spec along the same
rendering/behaviour seam the other suites use.

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

* fix(portals): re-check the movie after waiting, and follow the alternative

Three findings, two of them regressions from the previous round.

Awaiting a pending discovery before failover let the user navigate during
that wait: the continuation then ran against whatever controller was current
and could answer one film's playback failure by starting another film's
alternative. Both waits — failover and pinned Play — now re-check that the
same movie still owns the screen.

Pinned Play also needed the wait it did not have. Pressing Play while the
pin lookup was still out concluded "nothing is pinned" and started the
route's own source, making a persisted preference depend on worker latency.

And the position bridge still accepted only the route's ids, so an external
player running an alternative had every progress update discarded: the
resume point stayed where playback began and a switch an hour in rewound the
lot. The session matcher and the bridge now share one ownership predicate,
since a page that shows Stop for a session whose progress it throws away is
the bug in two halves.

The test for the external case previously set the position signal directly,
which bypassed the very filter that was broken; it now drives the bridge.

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

* fix(portals): stop a remake matching, and let a pin survive its own playlist

Three findings from the latest review pass.

`normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)"
normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above
every fuzzy one, with the year never consulted because that tier skipped the
gate. Auto-failover could switch the user to the other film entirely. The
year is now read out of brackets too, and a stated disagreement rejects the
row on either tier.

Playback positions are keyed by (playlist, stream), so watching through a
pinned alternative stores progress under ITS ids while the page loads the
route copy's row. Starting the pin therefore resumed from a position
belonging to a different copy — usually zero. It now loads its own.

And a pin can point at another copy of the film inside the playlist being
viewed, which discovery excludes wholesale: the pinned row was absent from
the list, so nothing showed as pinned and Play ignored the preference. The
pin is now read before discovery, which keeps that one row.

Moves the pin-shaped decisions into the pin module, where the persistence
helpers already live.

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

* fix(portals): keep a pinned play, a same-playlist copy and Check honest

Five findings from the latest review pass.

Reading a pinned source's own position is a database round-trip, and the user
can navigate across it — the continuation then handed one film's source id to
whichever movie now owned the screen. Guarded, like every other await here.

Allowing a pinned copy to live in the current playlist made "is this the
route's own source?" a two-part question, and the ownership check still asked
only about the playlist: an external session for that copy was disowned, so
Stop vanished and its progress was dropped.

The yearless title alias is shared by every remake, so clearing every alias
before a write could delete a different film's pin. Writes and unpins now
touch only keys that name one film — plus the ambiguous row this session
actually read, which is the one the user is looking at and the one whose
absence would make an unpin come back.

Restart left the seeded position in the controller, so a failure before the
first timeupdate resolved the next source back at it.

And the alternative rows on the playback-error screen had a Check button
wired to nothing at all.

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

* fix(portals): stop a rediscovery restoring the pin it started with

A same-movie rediscovery read the pin, then held that snapshot across its
source lookup and applied it afterwards. A pin made while the lookup was out
was therefore overwritten by the older value: the row and the primary Play
action named a source the database no longer held.

The snapshot is now applied as soon as it is read, so a later write simply
wins on ordering rather than needing to be detected.

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

* fix(portals): write the pin before retiring it, and keep the badge honest

Three findings from the latest review pass.

Repinning cleared the old rows and then wrote the new one, so a write that
failed after the clear left nothing persisted while the row still showed the
old pin. The order is reversed: the new key is stored first and the stale
ones retired only once it landed. Lookups are most-trusted-first, so a
leftover alias never outranks what was just written.

Starting a source from the picker, or letting a pin decide the primary Play,
never recorded the movie as recently viewed — unlike every other way of
playing it.

And closing an alternative's player and pressing Play started the route
stream while the controller still marked the alternative active, so the
picker and caption named a source that was not running.

Moves the discovery pass into the session module beside the switch and
failover mechanics, and splits the pin spec along the persistence/playback
seam, both to stay inside the file-size rule.

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

* fix(portals): stop claiming playback, a cached answer and a resolution

Three findings, all of them the same rule: never state as fact something the
app has not established.

The "Playing from …" caption appeared as soon as discovery marked a source
active — before Play was pressed, and again after the player was closed. It
now requires a player that is actually running.

Probe answers were cached by URL alone, but the request now carries the
playlist's headers. Two playlists sharing a stream URL could therefore be
told the other's answer, marking a source dead without ever asking it.

And any width below 900 was labelled 480p, published with `api` provenance:
a 640x360 stream stated 480p as a fact, and a 720x576 PAL source likewise.
Widths below HD only resolve with the height — 720 is NTSC 480p or PAL 576p
— so an unrecognised shape now carries no quality tag at all.

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

* fix(portals): match the sub-HD formats, and drop the caption on failure

Two follow-ups to the previous round, both the same rule again.

The 800-wide band still answered from the width alone, so 800x600 and
800x450 were labelled 480p — published with `api` provenance, so read as a
measurement. Sub-HD formats are now matched against known shapes with the
same 5% tolerance the height path uses, and anything unrecognised carries no
tag at all.

And "Playing from ..." survived a playback failure: the inline host stays
mounted while the diagnostic is on screen, so the page named a source for a
stream it had just reported it could not play. The caption now clears on
failure and returns when the engine produces time again.

Splits the route playback spec along the "what it does" / "what it claims"
seam and lifts the repeated active-source stub into one helper.

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

* fix(portals): let the height veto a width match, and hold the failure state

Two follow-ups to the previous round, both in code it introduced.

A width that matched exactly one sub-HD format ignored the height entirely,
so 640x480 came back as 360p — a measurement the numbers contradict. The
height now vetoes, but only in the direction that can be wrong: cropping
removes lines, so a SHORTER frame is a letterboxed master of that format and
the width still names it, while a taller one is a different shape and gets
no tag. That keeps the reason width is preferred in the first place.

And picking a source off the error screen cleared the failure state before
the switch resolved, so an alternative that could not be resolved left the
diagnostic on screen while the caption went back to claiming playback. The
flag now clears only once a switch actually starts something.

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

* fix(portals): release the resume latch when the target cannot be reached

Carrying a position into a shorter cut of the same film — two hours into a
90-minute source — leaves the engine unable to ever report that time, so the
one-shot latch never released: every position save was suppressed for the
rest of the session, and the impossible start time kept being reported to
multi-source for the next switch.

The latch now also opens when a known duration puts the requested point out
of reach, while a reachable one still waits as before.

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

* fix(portals): say "Playing" only while something is playing

`isActive` means "the source a switch or Play would use". Discovery sets it
the moment the page opens and it survives closing the player, so it could not
back the two claims the UI made in the present tense: the "Playing from"
caption and the source row's Playing badge. Both appeared on a page where
nothing had started, and came back after the player was closed.

`playbackLive` is now that statement, and both read it. Inline it needs a
timeupdate — `inlinePlayback()` is only the REQUEST to play, non-null while
the engine is still opening the stream and still non-null after it fails —
and external it needs the session past `launching`. A row that is merely
selected reads "Current" (new key, filled for all 19 locales).

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

* fix(portals): start a never-watched pinned source from the beginning

Positions are keyed by (playlist, stream). When the pin points at a copy the
user has never opened, the lookup returns nothing and the controller was left
holding the ROUTE copy's position — so Play dropped them 42 minutes into an
unstarted film, and the first save wrote that timecode back under the pinned
source's key, making it permanent.

The spec asserted the old behaviour, so it is flipped rather than extended; a
second case covers the host that supplies no lookup at all, where "never
watched" was never established and the position must be left alone.

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

* fix(portals): describe the copy the primary button will actually play

Two gaps found by review.

A pin makes the primary button play a copy the page never loaded a position
for — positions are keyed by (playlist, stream). The label, timecode and
Restart affordance still came from the route copy's row, so the button could
read "Resume 42:18" and start an unwatched copy at zero, or read "Play" and
jump into the middle of one already watched. `createPrimaryActionPosition`
lets the pinned copy's row govern, including when that row is absent: never
watched is an answer, not a fallback to someone else's progress.

A manual source switch also mounts a DIFFERENT stream in the same host while
marking the new source active at once, so the previous stream's timeupdate was
still vouching for it — the caption and the badge claimed the new source while
it was still opening. That path now clears the latch like Play and Restart do.

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

* fix(portals): keep the route's own resume point, and honour a closed pin

Two more from review, both variations on "selected is not playing".

`vodPlaybackPosition` followed whichever copy last reported — so after an
alternative played, Resume and its label described that copy's row while
starting the route's stream, jumping it to a timecode nobody reached in it.
It now splits: `vodPlaybackPosition` stays the last position seen (the
progress bar and the switch handoff want the stream on screen), and
`routePlaybackPosition` holds the route copy's own row for everything that
acts on the route's stream.

`pinnedSourceAwaitingPlay` skipped the pin whenever its row was active, but
`isActive` means selected — the pinned row stays selected after its player is
closed, so the next Play went to the route copy and ignored the stored
preference until the page was reopened. It now takes `playbackLive` too.

The host service crossed the 400-line cap on the way, so the four derived
alternative counts moved into `vod-multi-source-counts.ts`.

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

* fix(portals): keep the primary button honest across navigation and pins

Four follow-ups from review, all consequences of splitting the position
signals.

- Route reuse (the Similar rail) cleared only `vodPlaybackPosition`, so the
  button kept the previous movie's Resume label — and start point — until the
  new lookup landed. Both signals and the playback latch now reset together.
- The primary button's fall-through past an unresolvable pin reached the
  service directly, skipping the bookkeeping a route start needs: the
  controller kept the alternative's timecode and the old stream's timeupdate
  still vouched for the new one. It now goes through the route's own wrappers,
  and Resume seeds the controller with the ROUTE copy's position.
- `alternativePlaylistCount` counted the playlist being watched whenever it
  held a second copy, so "also found in 2 other playlists" could mean one.
- The pinned copy's stored row went stale the moment the user watched it; its
  live position now wins while it is the one playing.

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

* fix(portals): do not spend a source's failover turn on mere selection

`setActiveSource` marked the source tried, but discovery calls it the moment
the page opens and a pin or the picker can call it before anything plays. So
opening a movie burned the route copy's turn: if a pinned alternative then
failed, failover skipped a healthy untouched source — and with only one
alternative, reported the options exhausted.

Selection and attempt are now separate. `setActiveSource` selects;
`markPlaying` also spends the turn, and only the three places that really
start playback call it. `runFailover` additionally retires whatever is on
screen before picking, so the failing source is spent however it got there —
relying on the start paths alone would leave one hole per path, and the cost
of missing it is a ping-pong between two sources.

One existing spec asserted the old behaviour (a route copy burned by a switch
it never played); it now plays first, so it still covers what it meant to —
that the tried set survives a rediscovery.

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

* feat(portals): carry VOD source pins through playlist backup

The new pins table was invisible to backup: exporting a playlist and
re-importing it on a new machine silently dropped every "main source" choice,
with nothing in the archive to say the choice had ever been made.

Pins now ride along under the playlist they point AT — carrying them anywhere
else would restore a preference for a portal the archive never contained.
`matchKey` names the film rather than the portal, so it survives untouched and
only the playlist id is remapped to the imported copy.

`sourcePins` is the one optional collection in the Xtream user state: archives
written before multi-source existed simply do not have it, so its absence is
age rather than damage. Only a wrong type is rejected, and pins without a
usable match key or content id are dropped, since writing one would occupy the
unique key of a film it does not describe.

Adds `DB_LIST_VOD_SOURCE_PINS` through the usual six seams (operation, worker
case, event, preload, bridge contract, service).

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

* test(portals): follow the normalized restore state's new collection

`normalizeXtreamPendingRestoreState` now always emits `sourcePins`, like every
other collection it canonicalizes, so three specs that assert the exact
normalized shape had to follow. Adds coverage for the sanitizing itself: a pin
without a usable match key or content id is dropped, and a non-string
`updatedAt` is discarded rather than carried.

Caught by CI, not locally — the earlier full run served `playlist-shared-ui`
from the Nx cache, so it reported green on a stale result.

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

* refactor(portals): lift the VOD route's orchestration out of the component

The details route had grown to 864 lines — the repository's hard maximum is
400, and while the file predates the rule, a baselined exemption is not a
budget to spend.

Three component-provided services now hold what the component was
accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch,
the caption, the primary button's position, source actions and the failover
toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and
`VodDetailsDownloadsService`. The component keeps its public API, so the
template and the existing specs are untouched. 864 -> 566 lines.

The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal`
read `route.snapshot.params`, which is stale once the router reuses this
component for detail-to-detail navigation (the Similar rail) — so a download
started from a film reached that way fetched the previous one. They now read
the same route-params signal everything else uses, with a regression test.

Also from review: pins are applied on the FRESH-import path too. A new
playlist has no content when the archive is read, so its user state is parked
and replayed after the import — the merge path I wired first never ran there,
and every pin was dropped. A failed pin write now propagates instead of being
ignored: the backup entry is reported failed, and the parked state is kept so
a transient failure can be retried rather than silently losing the preference.

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

* fix(portals): read array-shaped codecs, bound short-title scans, honour alias clears

Three from review.

`info.video`/`info.audio` are declared — and sent by the mock server and many
panels — as string arrays, but the resolver only read the ffprobe object shape.
Every array response therefore lost the provider's codec, so those source rows
showed no codec fact and the "dub may differ" warning could never fire.
`readStreamInfo` now accepts both, and states nothing when the provider stated
nothing.

The FTS-empty fallback scan matched only the FIRST token, which is fine for a
one-word short title but not for "I Am": every catalog row containing the word
"i" came back for TypeScript to throw away — a full scan of a large catalog on
the single database worker, just to open a detail page. Every token must now
appear.

`writePin` reported success when the canonical write landed but retiring the
old alias failed. Lookups read aliases before the canonical key, so reopening
the movie before enrichment would start the source the user just replaced,
with the icon promising otherwise.

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

* fix(portals): write a pin and retire its aliases in one transaction

Split across two calls, a half-failure had no honest outcome. Reporting
success left a surviving alias to win the next lookup and start the source the
user had just replaced; reporting failure — which the previous round changed
it to — left the canonical row durable while the UI showed a pin that was no
longer the stored one. Review was right both times, which is the tell that the
two-step shape was the problem.

`setVodSourcePin` now takes the keys to retire and does both inside one
`db.transaction()`, with the synchronous `.run()` form the better-sqlite3
driver requires there (issue #1137's lesson). `retireKeys` rides through the
worker op, the IPC contract, the preload bridge and the service, so there is
one call and one outcome.

Also corrects the architecture doc: the scan path is reached whenever no token
clears the trigram minimum, not only when the whole title is one or two
characters — the claim the previous commit's code change had already falsified.

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

* fix(portals): tell a superseded pinned play from an unusable pin

Double-clicking Play while a pinned source resolves put both handlers into
`playPinnedSource()`. The second supersedes the first, so the first returned
`false` — which the route read as "no usable pin" and answered by starting the
route source over the playback the second click had just begun.

`playPinnedSource` now reports `played` / `superseded` / `unavailable`, and
only `unavailable` falls through. This is the same distinction `runFailover`
already draws between "keep going" and "stop, something newer owns the screen";
the pinned path simply never had it.

The host crossed the 400-line cap again on the way, so the pinned-play errand
(wait out an in-flight discovery, re-check the session, start the source) moved
into the pin module beside `playPinned`, and the pin-toggle commit went with
it. 388 lines.

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

* fix(portals): restart honours the pin, and a switch replaces the player

Three from review, all in the pinned-playback seam.

A pinned copy watched through resolved to its stored seconds, so the button
read Play — the label uses the in-progress rule — and then started near the
end. Both now go through one `isResumablePosition`, so the label and the start
point cannot disagree.

Restart sat beside a Resume that honours a foreign pin, but called `playVod`
and started the ROUTE copy — silently switching the user's playlist. It now
restarts whatever the primary button acts on, falling back to the route source
only when there is no usable pin.

Switching sources left a running external player alone. With MPV or VLC and
instance reuse off the backend spawns a second detached process, so both
sources kept playing and Stop owned only the newer one.

Also merges master, and puts the five host specs on a shared harness — they
each carried the same 31-line TestBed, which is what pushed two of them over
the file-size cap as cases were added.

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

* fix(portals): find short Unicode titles, and stop two pickers racing

Five from review.

Greptile's P1: a short non-ASCII title was undiscoverable. SQLite's `LOWER()`
and GLOB classes are ASCII-only, so "он" never matched a stored "Он" and the
source simply never appeared. ASCII tokens keep the word-boundary GLOB; a
non-ASCII token falls back to a substring test against both the folded and the
as-typed form, which the normalized confirmation afterwards makes safe.

A probe now retries the ranged GET for 400 and 403, not just 405/501 — those
are what a WAF returns for an unexpected HEAD on a URL it serves happily over
GET, and calling that source dead also ranked it below worse ones.

Three races, all the same shape as ones fixed earlier in this branch:
- a pinned play awaiting its resume lookup did not notice a source picked
  across it, and finished last, replacing the user's choice;
- two overlapping switches both saw the same external session, both awaited
  its close, and both launched — two detached players again;
- the primary button showed the ROUTE copy's Resume while the pinned copy's
  row was still loading, so a click started somewhere else entirely.

Also puts the races spec on the shared host harness, which is what keeps it
inside the file-size rule now that it carries two more cases.

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

* fix(portals): close the player we launched, not the one we now own

Three follow-ups, two of them to last round's own fixes.

The external-session close was defeated in exactly the case it was written
for: `switchToSource` marks the DESTINATION active before handing playback
over, so by the time the service ran, the process still playing no longer
looked like ours and was left running beside its replacement. The service now
remembers the ids it launched with, independently of what is active.

The ASCII/Unicode branch was decided from the NORMALIZED token, which folds
diacritics — "Ça" arrived as "ca", looked like plain ASCII, and took the GLOB
path while the stored title still read "Ça". Decided from the raw token now.

Backup restore upserted archived pins but never removed the playlist's
existing ones, so a present-but-empty collection left stale preferences alive
— unlike the playback positions cleared beside it. An absent collection (an
older archive) still means "no opinion" and is left alone.

Four files crossed the size cap on the way; the split ones now share
`title-sources.spec-data.ts` and `playlist-backup.xtream-fixtures.ts`, and the
external-session ownership moved to its own module.

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

* fix(portals): absent is not empty, and every start claims the generation

Four more from review, three of them defects in last round's fixes.

The restore normalizer materialized `sourcePins: []` for archives that never
had the field, so "absent means no opinion" became "this archive says there
are no pins" and a merge cleared the user's. Absent now stays absent. My test
for that behaviour had passed for the wrong reason — it stubbed an empty pin
list, so the clear was skipped whether or not the guard worked.

`startGeneration` was claimed only by the switch path, so a plain Play, Resume
or Restart could be overtaken by a switch still awaiting its close. Every
start claims it now.

Raw and normalized tokens were paired by position, which breaks when
normalization drops a whole word: "FR: Ça" normalizes to "ca" and got handed
the raw token "FR:", sending it down the ASCII branch it cannot match from.
They are paired by normalized form instead.

And the ambiguous yearless alias (`title:dune:`) is no longer written or
retired beside a precise key — it may hold another remake's pre-enrichment
pin. It stays available when it is the only key there is, since refusing to
pin at all would be worse.

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

* fix(portals): a failed close must not leave the page claiming a dead source

When `closeSession()` rejected, `startResolvedPlayback` rejected with it and
never launched — while `switchToSource` had already marked the destination
active and reported the switch as successful. The page then named a source
that nothing was playing.

The close failure is logged and the replacement starts anyway. A close that
rejects usually means the session was already gone, and a possibly-lingering
process is the lesser of the two evils: the alternative is a UI that lies
about what is on screen.

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

* refactor(portals): split the external-playback handoff out of the service

Both the service and its spec crossed the 400-line cap with the close-failure
handling, so the handoff — deciding which process is ours, closing it, and
surviving a close that rejects — now lives in
`vod-details-external-session.ts` with its own spec file.

Two tests had to start awaiting: replacing a running external player is a
round-trip, and the handoff now yields once even when there is nothing to
close, so the new playback is mounted a microtask later than before.

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

* style(portals): format the extracted external-session module

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

* fix(portals): fold diacritics in the title index

Cross-playlist matching compares normalized titles ("Amélie" -> "amelie")
against an index built from the raw title, and the trigram tokenizer does not
fold diacritics by default. Every accented title was therefore invisible to
the FTS path: two identical `Amélie` entries produced no candidates at all.
That is the broadest of the Unicode gaps review found, and it predates the
short-title work.

The tokenizer is fixed at CREATE time, so existing databases recreate and
rebuild the index once behind a migration marker. `remove_diacritics` needs
SQLite 3.45+, so support is probed on a temp table first: an older runtime
keeps its working index untouched and the migration is not recorded as done,
leaving a later version free to upgrade it.

Case folding for non-ASCII remains impossible in stock SQLite — "ОН" cannot
find "Он" by any available predicate — and is documented as the known limit
rather than patched around again.

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

* fix(portals): clear a playlist's pins by playlist, not by key list

Restoring over a playlist reused the keyed clear, which caps its input at
MAX_KEYS_PER_LOOKUP to bound an IN clause. A playlist with more than eight
pinned movies therefore kept the surplus while the call still reported
success, and the restore then wrote the archive's pins on top — leaving the
union of two states, which is neither the one the user asked for.

Clearing is now a dedicated delete-by-playlist operation with no key list to
truncate, and it refuses a blank playlist id rather than deleting everything.
A failure fails the entry instead of being swallowed: `listForPlaylist`
returns `[]` on error and `clear` returns `false`, so ignoring the result made
a failed read indistinguishable from "there was nothing to clear".

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

* fix(portals): keep a pin readable under every identity of its film

Two defects in the pin/position subsystem, both reported in review.

A pin was stored under the movie's most-trusted key alone and its other
keys retired. But a movie's identity GROWS: the film keyed `tmdb:438631`
today was `title:dune:2021` before enrichment, and reopening it cold asks
for the poorer key first. The preference was therefore ignored until
enrichment landed — and permanently when enrichment is off or never
answers. The decision is now written under every key in `write` (never
the yearless form, which every remake shares), one upsert per key plus
the leftover retirement in the same transaction. `setVodSourcePin` also
reports failure for a pin with no usable key instead of claiming a write
it never made.

The primary button asked whether the pinned copy's position had loaded
by testing presence rather than identity, so re-pinning left it wearing
the previous copy's timecode until the new lookup returned.

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

* docs(portals): record the key-addressing limit a pin write cannot close

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

* fix(portals): fold non-ASCII case in the scan, and read years as tags

I was wrong about SQLite twice over, and both errors cost matches.

GLOB character classes are NOT ASCII-only. `patternCompare` reads them as
UTF-8 code points, so `'Он' GLOB '*[Оо][Нн]*'` is true — only `LOWER()` is
ASCII-only. The scan tier now folds the case in JavaScript, where Unicode
case mapping is real, and hands SQLite one class per character. A short
Cyrillic or Greek title stored in a different case is found instead of
being silently absent from the Sources chip. The builder returns `null`,
leaving the substring tests as the whole answer, for a token holding a
GLOB metacharacter (GLOB has no escape character) or a case mapping that
changes length. The FTS tier is untouched and still cannot fold — that
needs a stored normalized-title column.

The movie's own year came from `extractYear`, which reads a year from
anywhere in the title. That is right where a year is a search hint, wrong
where it is an identity: `2001: A Space Odyssey` was treated as a 2001
film, so every genuine 1968 copy failed the year gate and the movie had
no alternatives at all — and its pin key moved the moment enrichment
supplied the real year. `releaseTagYear` accepts only bracketed and
trailing forms; the repo's own TRAILING_YEAR_PATTERN already documented
this exact hazard.

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

* fix(portals): cover a letter spelled two ways in lower case

Greek Σ lowercases to σ, but a word-final sigma is written ς and is
equally a lowercase of it, so a class built only from the character in
hand knew one spelling of two. Each class now also carries the uppercase
form's own lowercase, which reaches the other one.

One-way on purpose: σ → Σ → σ never arrives at ς. Left so because ς is
only correct at the end of a word, which is exactly where the request's
last character sits — the pair that occurs in real titles is covered, and
closing the other direction needs a fold table.

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

* fix(portals): let an exact title keep a number that is part of its name

Both match tiers weighed the same year, taken from a trailing four-digit
tail or a bracketed tag. On the exact tier that rejects the very copy it
was meant to confirm: reaching it means both titles are the SAME string,
so the trailing digits belong to both, and comparing them against a
release year out of metadata makes "Blade Runner 2049" disagree with its
own stated 2017 — the genuine alternative disappears at the moment
enrichment lands, which is when the user has most reason to expect it.

The exact tier now reads the bracketed form only. Brackets are never part
of a name, so "Dune (1984)" is still rejected against 2021. The base tier
is unchanged: it has just stripped a trailing year, and that year is the
only thing separating "Dune 1984" from "Dune 2021".

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

* fix(database): verify the title index folds, rather than trust the marker

`createTables` declares content_title_fts with the plain trigram
tokenizer, and the diacritics migration declares it again with folding.
Two sources of truth for one tokenizer: if the table ever went missing
after the marker was written, `CREATE TABLE IF NOT EXISTS` would restore
the unfolded form and the migration would skip it on the marker alone.

The upgrade now reads the live table's own DDL from sqlite_master and
rebuilds unless it really folds. A degraded index is invisible from the
outside — discovery just stops finding "Pokémon" for "pokemon" — so the
record has to be checked against the thing it describes.

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

* fix(portals): give the route's own row the facts the page already has

Two provenance defects found in review.

The current-source row is never resolved — nothing needs to fetch a URL
for the stream already playing — so it carried no provider metadata at
all, while every alternative got its facts from the resolve preceding
playback. `audioDiffersFactually` requires a fact on BOTH sides, so the
"dub may differ" warning was structurally unreachable on the commonest
switch there is: route to alternative. It could only ever fire between
two alternatives that had both been resolved. The row now carries what
`get_vod_info` already told the page, via a `providerVodMetadataOf`
mapper shared with the resolver so the two cannot describe one movie
differently.

Quality bucketed every width from 900 to 1199 as 576p, so a 960x540
stream — an ordinary 540p encode — was published as "576p" with `api`
provenance: a measurement its own pixels contradict, from the one field
that is supposed to mean the provider said so. That range holds two
standard formats, so it is matched now rather than bucketed, exactly as
the sub-HD sizes already were. A width matching no known format yields
no tag and a check chip, which is the honest answer.

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

* fix(portals): let the height veto a width-derived quality, and refresh route facts

Both of these are gaps I saw and chose not to close last round; a
reviewer was right that neither survives its own reasoning.

The shape check only ran below 1200, so the HD ranges kept publishing
wrong-but-confident labels: 1440x1080 is anamorphic 1080 and 1600x900 is
900p, and both were "720p" with `api` provenance — the provenance that
means the provider said so. Ranges are fine up there, the standard widths
really are far apart, but only once a known height can veto the answer.
Same rule the matched formats already used: a shorter frame is a
letterboxed master, a taller one is a different shape and gets no tag.

And the route row picked up provider facts only when discovery reran. On
a sparse panel `get_vod_info` can answer with no year and no TMDB id, so
the movie key is unchanged, nothing reruns, and the row keeps stating
nothing — leaving `audioDiffersFactually` one-sided and the dub warning
unreachable on exactly the switch it exists for. It now takes those facts
on without rediscovering, merged onto the existing row so a probe result
already sitting there survives.

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

* fix(portals): a codec is not a dub, and two waits needed a switch guard

Three findings from review.

The "dub may differ" warning compared audio CODECS. AAC and AC3 routinely
carry the same dub, and two AC3 tracks can carry different ones, so it
fired on every identical-language re-encode and stayed silent on the dub
changes it exists for — wrong in both directions, which is worse than
absent, because a warning people learn to ignore is not a warning. Worse,
the previous commit made it reach the common route-to-alternative switch
for the first time, so the false claim was about to get louder.

It now reads a new `audioLanguage`, taken from the track's language tag
and never from the codec. `audio` stays as a display fact. Few panels tag
a language, so the warning is usually silent — the same answer the rest
of this feature gives when it does not know.

`failover()` validated only the session across its wait for a discovery
in flight. The session moves when the FILM does, so a source the user
picked — or the route stream they restarted — during that wait was then
treated as the thing that failed and switched away from. It claims and
rechecks a switch generation, as the pinned path already did.

And the scan's ASCII branch could not find "Ça" from a folded "ca", while
the non-ASCII branch found "Ca" from "Ça" — so whether two playlists
could see each other depended on which one was open. Each ASCII letter
now carries its accented forms, derived by decomposition rather than
tabulated, so it cannot drift from the normalizer.

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

* test(portals): pin what the declared audio shape can and cannot say

The array shape the mock server and many panels send carries a codec and
no language, so the dub warning is silent for every source arriving that
way. Asserted rather than assumed, alongside the ffprobe shapes that do
carry one — otherwise a later reader sees an unused field and wires the
codec back into the warning.

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

* fix(portals): a failed pin read must not export as "no pins"

Three findings, all in code from this session.

Backup called the lenient `listForPlaylist`, which turns a failed read
into `[]`. Since `e0ebbeaf` made restore treat `sourcePins` as
authoritative — clearing the playlist's pins before applying it — an
export whose read failed produced a file that looks complete and wipes
every pin on restore. Losing them is bad; losing them through the one
feature meant to protect them is worse. Backup now uses a strict listing
that throws, so the export fails instead.

The diacritic map stopped at U+024F, which is tidy and leaves Vietnamese
out: `ố` is U+1ED1, the normalizer folds it to `o`, and the scan filtered
those rows out before confirmation. Latin Extended Additional is included
now; the filter decides what belongs, so the range only has to be wide.

And two panels spelling one language differently (`eng` vs `en`, or
`en-US`) raised a dub warning between identical tracks. Tags are
canonicalized before comparison — 639-2 collapses to 639-1, both German
forms meet at `de`, regions drop, and `und` becomes nothing. Anything
that survives longer than three characters is not a language code, so the
comparison is declined rather than guessed.

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

* fix(portals): stop two backup paths from deleting pins they never read

Two data-loss paths, both P1, both mine.

A web export wrote `sourcePins: []`. Pins are Electron-only, so out
there we cannot read them — which is not the same as knowing there are
none, and restore treats the collection as authoritative. A backup made
in the browser was therefore an instruction to delete every pin the
moment it was imported on the desktop. There are three answers here, not
two: pins exist, there are none, and "could not look". The last omits
the field, exactly as an archive written before pins existed does. The
same rule now covers Electron with the bridge method missing.

I had written a test asserting the unreachable-store case resolves to an
empty list "because a backup made there is complete". That reasoning was
wrong: empty was true of what the runtime could see, never of the
playlist.

Restore also cleared the playlist's pins and then wrote the archive's one
by one. A write failing partway left the previous pins already gone and
only a prefix applied — a state belonging to neither, reported as a
failure the user could not undo. `DB_REPLACE_VOD_SOURCE_PINS` does the
clear and every write in one transaction, so the playlist ends up as the
archive describes it or exactly as it was.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 21:53:42 +02:00
4gray bfad82c26c fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look
saved while nothing reached disk.

A second app instance sharing the same userData directory cannot take the
Chromium storage lock, so its renderer reads defaults and every write is
dropped. The app now holds a single-instance lock and focuses the running window
instead of starting a rival copy. The lock is requested after the userData
override so E2E runs with their own data dir keep independent locks, and after
Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local
CDP debugging.

updateSettings() patches in-memory state before persisting and the submit path
had no rejection handler, so a failed write produced an unhandled rejection and
no user-visible feedback. SettingsStore now records which half of the round trip
failed, and the settings page surfaces it through a dismissible error snackbar;
the dialog stays open on failure so the save can be retried.

Two follow-ups from review, both wider than the report:

- a second launch now re-creates the main window when the lock owner has none
  left, so closing the last window on macOS no longer leaves a second launch
  quitting silently with nothing on screen
- App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt
  window, so the downloads broadcaster stops holding a destroyed window. This
  also fixes the same bug on the pre-existing dock `activate` path.

Closes #1156
Closes #102
2026-07-27 23:14:30 +02:00
4gray f9ea3070ee refactor(settings): split the settings page into per-section facades (#1274)
settings.component.ts had grown to 819 lines — past the CLAUDE.md target (<300)
and hard maximum, passing lint only because it sat in the max-lines baseline.

The behaviour moves into facades the template binds to directly, following the
precedent already in this folder: new app-update (218), form (197), epg (123),
embedded-mpv (74) and remote-control (37) facades, with playlist-reset extended
to 143 and settings-options to 200. The component is now a 259-line coordinator
holding capability flags, section nav, players() and the cross-facade flows.
settings.component.ts is removed from the max-lines baseline.

No behaviour change. One ordering detail: applyChangedSettings now applies
language/theme before kicking off the EPG re-fetch; changeTheme only touches DOM
theme sync and translate.use does not touch the form, so the two are
independent.
2026-07-26 22:57:16 +02:00
4grayandClaude Fable 5 ec09778f36 feat(about): show build commit next to the app version (#1208)
* feat(about): show build commit next to the app version

Settings > About now renders "<version> (<short-sha>)" with the full
SHA in the tooltip, so bug reports from test and nightly builds
identify the exact commit. The commit is injected at CI build time into
apps/web/src/environments/build-commit.ts (same placeholder pattern as
the TMDB key inject); PR builds use the real head SHA instead of the
ephemeral merge commit. Local/dev builds keep the plain version.

The semver version itself deliberately stays untouched: a "-sha"
suffix would flip electron-updater into prerelease mode and leak into
installer/artifact version fields.

Requested by WolfganP in #1202.

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

* style(settings): keep relative import after monorepo alias imports

Addresses Greptile feedback on #1208.

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

* feat(docker): inject build commit into published PWA images

The Docker/PWA build path bypassed the Electron workflow's inject step,
so published images showed the plain version in About. Pass the commit
as a build arg and run the inject script before the PWA build; the
script no-ops when BUILD_COMMIT is empty, leaving local docker builds
unchanged.

Addresses Codex feedback on #1208.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 09:44:08 +02:00
4grayandClaude Fable 5 26271fc076 feat(embedded-mpv): frame-copy rendering engine (experimental, macOS Apple Silicon) (#1169)
* spike(embedded-mpv): frame-copy pipeline prototype (helper + shm ring + Electron viewer)

Standalone macOS spike for the frame-copy unification direction from the
2026-07-10 analysis: a helper process renders mpv offscreen into a GL FBO,
reads frames back through an async PBO ring, and publishes BGRA frames into
a 3-slot POSIX shm seqlock ring; a minimal Electron viewer copies the newest
frame via a plain-C N-API addon and uploads it to a WebGL canvas per rAF.

First numbers on M1 Pro (see spike README): 4K60 HEVC hwdec sustained at
60 fps end to end, ~1.2 ms shm copy + ~3.5 ms texture upload, ~10 ms
produce-to-upload age, zero torn frames. Remaining gates: weak hardware,
long-run pacing, HDR, latency flash test.

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

* spike(embedded-mpv): add RESULTS.md measurement log with M1 Pro baseline

Structured per-machine table with repro commands so the pending Intel Mac
and Windows iGPU runs can be appended and compared one-to-one.

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

* spike(embedded-mpv): pacing/judder instrumentation + 50/25 fps and HDR gate results

Viewer now measures inter-frame intervals on both clocks (present side and
producer side): stddev/p99/max, late-frame counters vs the producer's median
interval, and a cumulative LONGRUN summary every 30 s. The addon exposes the
producer timestamp (produceMs) for this.

Measured on M1 Pro: 50 fps and 25 fps cadences are clean (late frames only
at startup; residual jitter is 120 Hz rAF grid quantization, bounded by one
display tick), and 4K25 HDR10 PQ/BT.2020 is tonemapped to SDR by mpv before
readback at full rate with unchanged copy costs. RESULTS.md carries the
tables and HDR-clip repro commands.

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

* spike(embedded-mpv): record 10-minute 4K60 HEVC long-run results

Zero dropped frames and zero torn reads after the first-minute warmup over
~8.5 minutes; steady-state late frames (~0.4%) track the 12 s test clip's
--loop restarts, not the copy pipeline.

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

* spike(embedded-mpv): viewport-scaling measurement + integration design draft

Confirms the render-at-viewport-size claim (4K source in a 720p FBO costs
720p: 0.17 ms readback / 0.16 ms copy / 0.17 ms upload at 60 fps) and adds
DESIGN.md — the draft integration architecture: per-session helper process
linking bundled libmpv on all platforms (finally full-featured + Wayland-
agnostic Linux), JSON-over-stdio control evolving the Linux wid protocol,
unchanged EmbeddedMpvSession renderer contract, shm generations for resize,
packaging via the existing vendored-runtime tooling, rollout behind its own
flag with the docked path as default.

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

* spike(embedded-mpv): auto-detect Homebrew prefix and Node headers for Intel Macs

BREW_PREFIX was hardcoded to /opt/homebrew (Apple Silicon) and NODE_INC to
one nvm version; both now resolve via brew --prefix and the PATH node's
execPath, so the pending Intel Mac run needs no Makefile edits. README gets
a fresh-machine checklist.

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

* spike(embedded-mpv): self-contained measurement bundle for machines without Node/pnpm

make-bundle.sh assembles a tarball with the spike sources, vendored N-API
headers (Makefile prefers them when present, so no Node install is needed),
pre-generated 4K HEVC/HDR10 test clips, and an official Electron dist
download for the target arch. collect-results.sh builds and runs the full
RESULTS.md scenario suite automatically (plus an optional --long 10-minute
run) and writes one results-<host>-<date>.txt to send back. Target-machine
prerequisites shrink to Xcode CLT + brew mpv — built for the pending Intel
Mac baseline run.

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

* spike(embedded-mpv): support MacPorts libmpv and legacy-macOS bundles

Makefile and collect-results.sh now detect libmpv in the Homebrew prefix or
MacPorts /opt/local (Homebrew is unsupported on legacy macOS like High
Sierra; 'sudo port install mpv +libmpv' provides libmpv there). make-bundle
takes ELECTRON_VERSION/BUNDLE_SUFFIX overrides — Electron 27+ needs macOS
10.15, so High Sierra bundles ship Electron 26.6.10 (LSMinimumSystemVersion
10.13).

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

* spike(embedded-mpv): scope macOS frame-copy engine to Apple Silicon only

Owner decision 2026-07-10: skip Intel Mac measurements and gate the future
frame-copy engine on arm64. Intel Macs able to run the app at all are a
shrinking 2015-2020 cohort and keep the docked/external/web player paths;
the macOS hardware gate closes with the M1 Pro numbers, and remaining
hardware risk moves to the Windows/Linux ports.

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

* feat(embedded-mpv): frame-copy helper process and shm frame reader (native layer)

iptvnator_mpv_helper: one-process-per-session libmpv host that renders
offscreen at viewport size (headless CGL + async PBO ring, validated in
spikes/mpv-frame-copy), publishes BGRA frames into a seqlock shm ring with
resize generations, plays audio directly, and speaks a stdio protocol —
tab-separated commands in, JSON events out. The snapshot event mirrors
NativeEmbeddedMpvSessionSnapshot; status semantics (END_FILE reasons,
eof-reached with keep-open, pause gated on loaded path, fatal-only status
flips) are ported from embedded_mpv.mm.

embedded_mpv_frame_reader.node: plain-C N-API reader the preload script
uses to memcpy the newest complete frame into a V8 ArrayBuffer (Electron's
memory cage forbids zero-copy). Stub exports off macOS.

Both build as extra binding.gyp targets through build-embedded-mpv.js; the
helper gets the same libmpv dependency-path rewrite + ad-hoc re-sign as the
addon and is validated by the forbidden-link check.

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

* feat(embedded-mpv): frame-copy engine wiring in main process and preload

EmbeddedMpvFrameCopyAdapter implements the NativeEmbeddedMpvAddon surface
over a per-session helper process (spawn, stdio protocol, snapshot cache,
graceful quit->SIGTERM->SIGKILL teardown), so EmbeddedMpvNativeService
reuses its polling/diff/power-blocker/recording logic unchanged. The
IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY flag (darwin/arm64 only) routes
getAddon() to the adapter and reports engine: 'frame-copy' in support.

The preload frame pump loads the shm reader addon, copies the newest frame
once per rAF into a reused buffer, and uploads it to WebGL2 on the
renderer's canvas — no frame data crosses the contextBridge; the bridge
only gains attachEmbeddedMpvFrameView/detachEmbeddedMpvFrameView. The
experiment flag relaxes the window sandbox for that native require;
contextIsolation and nodeIntegration:false stay on.

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

* feat(embedded-mpv): frame-copy canvas mode in the player component + docs

EmbeddedMpvPlayerComponent renders <canvas data-embedded-mpv-frame> when
support reports engine 'frame-copy' and the session controller starts/stops
the preload frame pump around the session lifecycle. The bounds provider
skips HIDDEN_BOUNDS and the popover cutout for this engine — the canvas is
ordinary DOM, dialogs and popovers stack above it natively; bounds sync
still drives the helper's render size. Adapter unit tests cover spawn args,
snapshot caching, shm generations, protocol encoding, unexpected-exit
mapping, and dispose escalation. Architecture doc and CLAUDE.md describe
the engine, its flag, and the sandbox trade-off.

Verified end to end in the built app (M1 Pro): engine detection, helper
spawn, lavfi playback onto the canvas via CDP-injected smoke — including an
orientation fix (helper FLIP_Y already yields texture-order rows; the pump
shader must not flip uv again).

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

* fix(embedded-mpv): close helper stdin on dispose + lifecycle logging

Live testing surfaced a stray idle helper that survived a session switch;
until the root cause is pinned down, dispose now also closes the child's
stdin (the helper exits on EOF) as a second kill path besides quit ->
SIGTERM -> SIGKILL, and spawn/dispose/exit are logged with the session id
so leaks are attributable.

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

* fix(embedded-mpv): reap sessions when the renderer reloads or crashes

Root cause of the stray idle helper found during live testing: session
teardown lives in the renderer's Angular lifecycle, which never runs on a
renderer crash or hard reload — the main process kept the session (and its
frame-copy helper process / native mpv handle) alive until app shutdown.
EmbeddedMpvNativeService now watches the main window's webContents for
render-process-gone and did-navigate (full reloads only; in-app Angular
routing emits did-navigate-in-page) and disposes every session. Applies to
both engines. Verified live: location.reload() during frame-copy playback
logs 'Disposing 1 session(s): renderer reloaded' and the helper exits
cleanly. Regression test drives both events against the service.

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

* feat(embedded-mpv): Settings toggle for the frame-copy engine

New 'Embedded MPV: frame-copy engine' checkbox in Settings > Playback,
shown only when the machine can run it (macOS arm64 with the helper binary
present — support now reports frameCopyAvailable). The choice persists to
the main-process config store because the engine relaxes the window sandbox
for the preload frame pump, which is fixed at window creation: main.ts
reads the store before creating the window and sets the engine env var; an
explicitly set env var (including '0') always wins, and the UI shows a
restart hint while the saved choice differs from the active engine.
Localized in all 18 locales.

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

* feat(embedded-mpv): aspect-fit rendering in the frame-copy helper

The helper now observes dwidth/dheight and renders its FBO at the
aspect-fit size of the video inside the requested viewport, bumping a shm
generation on change — letterbox bars are never baked into frames (the VOD
watch shell's ~2:1 box no longer shows black side bars; the canvas
background is transparent so the sides show the app surface, while
fullscreen keeps its black backdrop). Frames also get smaller than the
viewport when aspects differ, trimming copy cost. Aspect override changes
refit automatically. Snapshots now carry videoWidth/videoHeight, and the
adapter forwards IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY to mpv's audio-delay
for lip-sync tuning until proper calibration lands.

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

* feat(embedded-mpv): require frame-copy artifacts in macOS package validation + docs

macOS packages that ship embedded_mpv.node must also ship the
iptvnator_mpv_helper binary and the embedded_mpv_frame_reader.node addon —
they come out of the same binding.gyp run, and a package missing them would
silently lose the frame-copy engine. Covered in the package-identity test.
Architecture doc and CLAUDE.md document the Settings toggle, aspect-fit
rendering, audio-delay passthrough, and the renderer-reload session reaping.

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

* fix(electron): inline TS helpers so the sandboxed preload keeps working

The frame pump's async/await (target es2015 + importHelpers) made webpack
externalize tslib in main.preload.js. Sandboxed preloads can only require
Electron's built-in module whitelist, so the entire preload script failed
to load and window.electron disappeared for every run without the
frame-copy flag. importHelpers:false for electron-backend keeps the preload
bundle self-contained — and future async code in preload can no longer
silently reintroduce the breakage. Verified live: sandboxed run now has the
bridge, reports engine 'native' and frameCopyAvailable true.

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

* docs(embedded-mpv): commit the frame-copy analysis handoff + source inventory

The 2026-07-10 analysis that led to this branch now lives next to the spike
(spikes/mpv-frame-copy/ANALYSIS.md), and the architecture doc's What To
Commit section lists the frame-copy engine sources.

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

* fix(embedded-mpv): address review findings on the frame-copy engine

- Stale pump attach can no longer win over a newer session: attach/detach
  bump a shared epoch and async attach waits re-check it after every await,
  so an attach for a replaced session aborts instead of installing itself
  (greptile P1).
- A failed frame-view attach (no canvas, no WebGL2, reader missing) now
  disposes the session and surfaces the error UI instead of leaving audio
  playing behind a black canvas (codex P2).
- A stale frame-copy opt-in without the helper binary falls back to the
  native engine instead of reporting embedded MPV unsupported, and the
  Settings checkbox stays visible while a saved opt-in exists so it can
  always be cleared (codex P2). Regression test covers the fallback.

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

* fix(packaging): make the darwin frame-copy packaging test host-agnostic

On non-macOS CI hosts validatePackagedEmbeddedMpv also reports that macOS
link validation needs a macOS host, so the success-path assertion now
checks only the frame-copy artifact requirement instead of expecting an
empty error list.

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

* docs(embedded-mpv): Windows/Linux porting handoff for the frame-copy engine

Self-contained entry point for porting sessions on other machines: current
state and coordination constraints, per-OS task lists (Linux EGL first,
then Windows WGL + named shm — the decisive iGPU perf gate), the
hard-won gotchas from the macOS integration (preload/tslib sandbox
breakage, V8 memory cage, frame orientation, stale-attach epoch, dispose
escalation, node-gyp naming, snapshot protocol semantics), testing
recipes, and the suggested milestone order.

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

* docs(embedded-mpv): branching and merge strategy in the porting handoff

Port work goes to stacked branches off the frame-copy branch (PR base =
frame-copy branch, sequential merges, stack depth one), never into the
frozen PR #1169 branch itself.

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

* docs(embedded-mpv): drop stale uncommitted note from porting handoff

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

* fix(embedded-mpv): harden frame-copy helper startup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 19:48:07 +02:00
4grayandClaude Fable 5 dc05a2566e feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals (#1123)
* feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals

Adds an opt-in TMDB integration (Settings > Metadata) that enriches
detail views with a field-level merge — the provider stays authoritative
for stream data, TMDB fills editorial fields when the match is confident.

Enrichment:
- Movie/series details: plot, cast (avatar chips), director, genres,
  rating, poster/backdrop, official YouTube trailers
- Confidence-gated matching: provider tmdb_id trusted; otherwise
  normalized-title search with year gate (±1; series accept earlier
  premieres), season-suffix stripping, Cyrillic search-language override,
  and language-prefix fallback variants
- Lazy season/episode enrichment: real episode names, overviews, stills
- "Similar" rail (Xtream): TMDB recommendations matched to the catalog
- Actor pages per portal with full filmography, availability filter and
  an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES
  worker op over the trigram FTS index

Infrastructure:
- SQLite cache table tmdb_metadata (details, search verdicts, seasons,
  persons; per-language, TTL-guarded), in-memory fallback for the PWA
- Settings: enable toggle, own-API-key override with a live "check key"
  button; TMDB attribution in Settings and About
- Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via
  tools/tmdb/inject-tmdb-key.mjs when the secret is configured
- normalizeTitle shared between renderer and DB worker
- CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked)

Fixes and refactors along the way:
- fix(stalker): Advanced Search sent bare get_ordered_list requests and
  skipped the auth handshake when isFullStalkerPortal was missing on the
  active-playlist meta — full portals answered "Authorization failed."
  and search looked empty; now mirrors the catalog request shape and
  routes through makeAuthenticatedRequest with URL-based detection
- fix(stalker): TMDB fields survive info re-normalization; detail views
  prefer the store copy patched by async enrichment over stale snapshots
- refactor(xtream): split oversized vod/serial detail components into
  component-scoped playback services; detail routes re-initialize on
  route param changes (router reuses them for detail-to-detail nav)
- i18n: all new keys translated across the 18 locales

Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates.

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

* fix(tmdb): provide route params observable to inline collection details, linearize regexes

The global-collection inline detail host builds a fake ActivatedRoute for
VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params.
Since the detail components now read route.params via toSignal() (detail->
detail re-init), the missing observable crashed component construction and
the content hero never rendered — broke dashboard-activation, favorites and
recent Electron E2E on all platforms. Provide the params observable
alongside the snapshot and assert it in the component spec.

Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in
normalizeTitle now excludes opening delimiters inside the classes, and
youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead
of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u
escapes (greptile note).

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

* fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP

Review follow-ups on PR #1123: the Xtream VOD detail badge renders
rating_imdb, but the merge wrote the TMDB score only into `rating`, so a
TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the
provider left it empty, mirroring the Stalker merge. All trailer iframes
are normalized to youtube-nocookie.com, so the extra youtube.com frame-src
allowance was dead surface (greptile) — removed.

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

* fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema

Fixes the confirmed findings from the PR #1123 code review:

- Stalker search: setSelectedContentType now runs BEFORE setSelectedItem,
  so the TMDB enrichment gate in the selection hook no longer sees the
  content type of the previously open tab (wrong/no enrichment after
  ITV -> search -> movie).
- Title normalization is now two-tier (normalizeTitleKeys): the exact
  normalized form keeps a trailing year, the base form strips it and
  remembers the tag. Year stripping is anchored to the end of the title
  ("2001: A Space Odyssey" keeps its year) and language-prefix stripping
  is UPPERCASE-only ("It: Chapter Two" is no longer amputated).
- All catalog matching (similar rail, actor pages, DB worker
  DB_MATCH_TITLES) compares exact forms first and only accepts
  year-stripped matches when the stripped tag is year-compatible (+-1)
  with the TMDB year — "Blade Runner" (1982) can no longer claim a
  catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped
  trailingYear so the renderer can apply the guard to worker matches.
- mergedBackdrops tolerates a plain-string backdrop_path; enrichment
  merge+patch blocks are wrapped in try/catch so a malformed provider
  payload can no longer become an unhandled rejection.
- loadGlobalMatches (both actor routes) guards against actor->actor
  navigation races — a slow match for the previous person no longer
  overwrites the current one's results.
- tmdb_metadata media_type CHECK widened to ('movie','tv','person') and
  person rows now use the honest 'person' type (TmdbCacheMediaType).
  Pre-release dev DBs with the narrow CHECK are rebuilt in place — the
  table is a pure cache, so the migration is a self-healing
  drop-and-recreate keyed off sqlite_master.

Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression
coverage: title-normalization.util.spec.ts, two-tier cases in
tmdb-similar.util.spec.ts and title-match.operations.spec.ts.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:07:46 +02:00
4grayandClaude Opus 4.8 0b526b119a feat(epg): add vertical list view for the live EPG panel (#1115)
* feat(epg): add vertical list view for the live EPG panel

Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).

- New EpgListViewComponent (app-epg-list-view) mirrors
  EpgTimelineComponent's input/output contract 1:1, so all four live
  hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
  with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
  hasProgramsForDateKey, epg-archive.util catch-up gating,
  epg-summary.util collapsed-summary maths, epg-date helpers,
  EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
  duplicated logic.
- Rows show time range, title, optional description, live progress on
  the on-air row, catch-up "Watch" on past rows when archive playback
  is available, and a details dialog; keyboard activation guards
  nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
  collapse/expand remounts, and shows a sticky in-flow "On now" strip
  (never overlaying rows) when the current programme is scrolled away;
  all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
  modifier (--epg-inline-height clamp); timeline and collapsed heights
  are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
  SettingsStore/StorageMap → segmented control in the EPG section);
  Electron-only UI, PWA stays on the timeline default. i18n keys added
  to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
  persistence spec, swap tests in all four host specs, and Electron
  E2E for the settings round-trip and the rendered list view. Docs
  updated (m3u-playlist-module.md).

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

* fix(epg): address list-view review findings from Codex and Greptile

- Reset the list view to today when a new channel's programme set
  arrives while the user is parked on another day (timeline parity):
  the scroll controller now keys by the full programme-set identity
  (programsFocusKey) and commits today before focusing, instead of
  silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
  on SettingsStore; the four live hosts consume the derived signal
  instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
  registerEpgListViewEffects(), bringing the component back under the
  300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
  coverage: return-to-today on channel switch, day navigation left
  alone, no-takeover when today has no data, empty-set no-op.

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

* fix(epg): drop malformed programmes from the list-view day filter

Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 10:05:08 +02:00
4gray c0e9cd5de3 fix(settings): show current release notes (#1101)
* fix(settings): show current release notes

* fix(updater): use app version for local release notes

* fix(settings): constrain release note images

* fix(settings): prefer current notes without updates
2026-06-29 11:03:15 +02:00
4gray b1119189e2 feat(updater): add GitHub releases desktop updater
Adds the GitHub Releases desktop updater with release notes, startup notification, packaging metadata, tests, and CI fixes for Electron E2E.
2026-06-28 22:21:20 +02:00
4gray 1444047a1d refactor: split dashboard rails and settings logic
Split dashboard rails and settings logic into focused helpers/facades while preserving behavior.
2026-06-14 13:34:23 +02:00
4gray adf37e7504 feat(dashboard): add configurable dashboard rails
* feat(dashboard): add configurable dashboard rails

* fix(dashboard): address rail review feedback
2026-06-14 11:52:44 +02:00
4grayand4gray e8aa7c3a34 [codex] Add scoped EPG security trust controls (#1054)
* Add scoped EPG security trust controls

* fix: address scoped trust review feedback

---------

Co-authored-by: 4gray <fourgray@proton.me>
2026-06-12 20:46:24 +02:00
4gray 697eab6e73 refactor(epg): route renderer calls through runtime bridge
Add typed EPG runtime bridge, split EPG runtime capabilities, migrate renderer EPG callers away from direct window.electron access, and address Greptile review feedback.
2026-05-24 13:45:51 +03:00
4gray 0fb4c7d13e fix(settings): gate external player choices by launch support 2026-05-22 14:39:20 +03:00
4gray 959f6061f0 fix(runtime): address greptile capability feedback 2026-05-22 14:37:06 +03:00
4gray ed8680116c fix(runtime): address consolidated review feedback 2026-05-22 14:02:56 +03:00
4gray 582422c512 refactor(runtime): gate settings backup file save by capability 2026-05-22 13:41:24 +03:00
4gray c9a0d5210e refactor(runtime): gate settings sections by capability 2026-05-22 13:41:24 +03:00
4gray 09e2e08fc1 refactor(runtime): gate settings external players by capability 2026-05-22 13:40:14 +03:00
4gray 78a5a7af74 refactor(settings): use runtime capabilities for platform state 2026-05-22 13:33:31 +03:00
4gray 5d355b6f10 fix(web): address strict mode review feedback 2026-05-22 03:11:14 +03:00
4gray 4ab8915483 chore(web): enable strict TypeScript mode 2026-05-22 02:58:12 +03:00
4gray d24c77a143 chore(nx): enforce scoped workspace boundaries (#942) 2026-05-15 09:59:06 +02:00
4gray a40d5447e7 feat(settings): add external player arguments (#932)
* feat(settings): add external player arguments

* fix(settings): address external player argument review

* fix(settings): require external player argument settings

* feat(settings): add external player argument placeholders

Closes https://github.com/4gray/iptvnator/issues/835
2026-05-14 11:04:01 +02:00
4gray 724e4b1ab3 feat(playback): add embedded mpv (macos) stream recording (#916)
* feat(playback): add embedded mpv stream recording
Entire-Checkpoint: f957cd9849e0

* fix(playback): address embedded mpv recording review
Entire-Checkpoint: f957cd9849e0

* fix(playback): track mpv recording auto-stop replies
Entire-Checkpoint: f957cd9849e0
2026-05-10 12:27:09 +02:00
4gray 9261794b4a Merge pull request #902 from weeco/feat/xtream-xmltv-epg-fallback
feat(xtream): fall back to uploaded XMLTV when provider has no EPG
2026-05-09 13:06:51 +02:00
Tedd Johnson 626948f2f5 Add double-click stream open setting 2026-05-07 20:03:33 -07:00
weeco e751e82c07 feat(xtream): fall back to uploaded XMLTV when provider has no EPG
Live TV channels in Xtream playlists go blank whenever the provider's
get_short_epg returns nothing, even when the user has working XMLTV
URLs in Settings. The XMLTV pipeline already populates epg_programs
but only the M3U module was reading it.

Wire those uploads up as a fallback for Xtream too: when the provider
returns nothing for a channel with an epg_channel_id, look it up in
the local table. A settings toggle flips the priority for users whose
curated XMLTV is better than the provider's auto guide.

EpgQueueService.enqueue() became async to batch the XMLTV lookup once
per viewport change. To keep the queue consistent under fast scroll,
all shared-state mutations now happen behind a generation counter so
only the latest call commits. Per-method bridge gating in the new
XtreamXmltvFallbackService keeps each path working when the preload
exposes only one of the two endpoints.
2026-05-07 08:52:59 -07:00
4grayandClaude Opus 4.7 e192cba776 feat(player): add VLC reuse-instance setting (#893)
VLC was unconditionally spawned per click — VLC's own single-instance
preference fails because the per-launch RC args defeat its D-Bus
forwarder. Mirror the existing MPV reuse pattern so users can opt in to
driving one tracked VLC via its RC interface (clear + add) instead of
opening a new window every stream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f957cd9849e0
2026-05-05 00:55:04 +02:00
4gray 4a21579aff fix: clear external player paths on save
Entire-Checkpoint: f957cd9849e0
2026-05-04 23:26:56 +02:00
4gray ec2f6a702a chore: resolve master conflicts for embedded mpv branch
Entire-Checkpoint: 5b514fe72836
2026-05-02 02:19:54 +02:00
4gray c51d8161ea refactor(web): split settings component into sections
Entire-Checkpoint: c2cea9c530e6
2026-05-01 20:16:32 +02:00
4gray 4b2076fa53 feat(settings): add cover size settings and implement responsive cover sizing
Entire-Checkpoint: c2cea9c530e6
2026-04-30 18:56:06 +02:00
4gray 9f873e6bed feat(player): add embedded-mpv player for macOS as experimental feature
- Introduced tooling for building and staging the macOS `libmpv` runtime for IPTVnator's embedded MPV player.
- Added `build-macos-runtime.mjs` for building an LGPL-compatible runtime from source.
- Created `stage-macos-runtime.mjs` for staging the built runtime artifacts.
- Implemented validation for the packaged embedded MPV runtime in `electron-after-pack.cjs` and `embedded-mpv-macos.cjs`.
- Updated packaging scripts to ensure the embedded MPV runtime is correctly integrated and validated during the build process.
- Added README files to document the expected layout and usage for the embedded MPV runtime artifacts.

Entire-Checkpoint: c6e522b4276c
2026-04-27 00:32:14 +02:00
4gray 0311a4dde8 refactor(settings): clean up unused imports and commented code
Entire-Checkpoint: c6e522b4276c
2026-04-25 23:54:30 +02:00
4gray 58ee06bb2b refactor(settings): add backup and reset functionality with UI updates
Entire-Checkpoint: c6e522b4276c
2026-04-25 23:03:50 +02:00
4gray fc3db71e85 refactor: delete all playlists feature
- Updated confirmation dialog messages to provide detailed consequences of deleting playlists.
- Added backup hints and progress messages for better user experience during playlist deletion.
- Ensured consistency in language across all supported translations (de, el, en, es, fr, it, ja, ko, nl, pl, pt, ru, tr, zh, zhtw).

Entire-Checkpoint: c6e522b4276c
2026-04-21 20:09:05 +02:00
4gray c8c93798f9 feat: enhance playlist backup export functionality and UI feedback
Entire-Checkpoint: c6e522b4276c
2026-04-21 08:54:03 +02:00
4gray 7b46509d21 feat: implement EPG source status component and enhance EPG data management in settings
Entire-Checkpoint: c6e522b4276c
2026-04-20 01:09:33 +02:00
4gray 5fdd999c7e style: enhance settings UI with responsive action bar and snackbar adjustments 2026-04-06 17:56:50 +02:00