Commit Graph
310 Commits
Author SHA1 Message Date
d677fbaf8c perf(electron): look up the login shell PATH without blocking the main thread (#1784)
* perf(electron): look up the login shell PATH without blocking the main thread

fix-path ran $SHELL -ilc env synchronously right after the first load.
With a typical zsh profile that held the main thread for 1-2 s, while the
database worker's ready message and the renderer's first IPC calls waited,
so the launch journey's first card came that much later. Use shell-path's
async shellPath() with fix-path's fallback, so the resulting PATH is the
same and the main thread stays free.

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

* docs(perf): name the PR that made the login shell PATH lookup async

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

* fix(electron): let bare-name player spawns wait for the login shell PATH

With the lookup now asynchronous, an external player launched (or the
Linux embedded MPV support check, which runs and caches a bare
`mpv --version`) within the first seconds could see the inherited PATH.
The OPEN_MPV_PLAYER / OPEN_VLC_PLAYER handlers and every embedded MPV
handler now await waitForLoginShellPath() (settled lookup, at most 10 s,
immediate on Windows), restoring the guarantee the blocking lookup gave.

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

* fix(electron): wait for the login shell PATH only for bare-name spawns

External players wait only when they resolve to a bare name (no
configured path, no well-known install found); a path to an executable
starts at once. Embedded MPV waits only on Linux and only for support and
prepare, which run the cached bare-name `mpv --version` check; sessions
and controls never wait.

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

* fix(electron): wait for the login shell PATH only before the mpv probe

Embedded MPV support and prepare waited on every Linux call, although
getSupport() returns before the bare-name `mpv --version` probe for the
frame-copy engine, native Wayland, a disabled feature or a cached result.
willProbeLinuxMpvExecutable() now gates the wait on the probe actually
running.

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

* fix(electron): stop waiting for a login shell that already timed out once

After the first wait for a hung `$SHELL -ilc env` runs out, later
bare-name player launches and Linux mpv probes proceed at once instead
of each waiting the full limit again.

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

* fix(electron): bound login shell PATH waits by the lookup's own budget

Replace the latch on the first expired wait with a deadline set when the
lookup starts (10 s). Every wait ends when the lookup settles or the
deadline passes: a launch retried while the shell is still within its
budget waits for the PATH again, and once the budget is spent no launch
waits, so a hung shell still delays at most the first seconds.

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

* fix(electron): re-probe a missing mpv once a late login shell answers

When the PATH lookup runs out of budget, the Linux embedded MPV support
check probes `mpv --version` with the inherited PATH and caches a
missing result for the rest of the session. waitForLoginShellPath() now
reports whether the lookup settled; after a timed-out wait the handler
forgets a cached "missing" once the lookup finishes, so the next support
check probes again with the login shell PATH.

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

* fix(electron): share one deadline among waits before the PATH lookup starts

A bare-name launch that waited before the lookup was scheduled started
its own 10 s limit, so while startup was stuck every retry paid the full
delay again. The first early wait now sets the shared deadline; the
lookup still replaces it with its own budget when it starts.

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

* fix(electron): re-probe mpv after a late login shell PATH either way

A Linux mpv probe that ran on the inherited PATH can be wrong in both
directions: the login shell PATH may add mpv or drop the directory the
inherited one found it in. forgetLinuxMpvExecutableProbe() now clears a
found result as well as a missing one.

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

* fix(electron): skip the login shell PATH wait for Flatpak host launches

In Flatpak, players start through `flatpak-spawn --host`, which resolves
the name with the host's PATH; the sandbox's login shell lookup cannot
change it. The launch handlers now decide from the same launch context
the player uses: no wait in flatpak-host mode, otherwise only for a bare
player name.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 11:48:06 +02:00
4gray 9d3e71a952 perf(electron): compile the main process and preload for ES2022 (#1757) 2026-09-30 18:48:35 +02:00
78a6648c8d perf(electron): let hidden and minimized windows report themselves hidden (#1724)
* perf(electron): let hidden and minimized windows report themselves hidden

The main window was created with backgroundThrottling: false (since #1123,
without a stated reason). Electron then keeps document.visibilityState at
"visible" for a hidden, minimized or fully covered window and never lets
Chromium throttle it, so every renderer timer, rAF and CSS transition ran at
full rate in the background, and the playback keep-awake gate, which
releases the display for a minimized window, could never see one.

Use Chromium's default. Audible media and picture-in-picture are exempt
from background throttling in Chromium, and a local check confirmed HLS
playback continues unchanged through more than six minutes minimized,
audible and muted.

Playwright's focus emulation pins every page it attaches to as visible, so
the new window-visibility E2E launches the app without Playwright and
drives it over raw CDP (electron-unautomated-launch.ts).

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

* test(e2e): harden the unautomated Electron launch

Review follow-ups for the window-visibility E2E:

- Resolve `electron` in the main process through a require created from
  the `node:module` builtin instead of `process.mainModule`, which only
  exists when the app entry is CommonJS.
- Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM,
  then SIGKILL, 5 s each, then fail instead of waiting forever.
- Surface CDP protocol errors from Runtime.evaluate instead of returning
  undefined.
- Wait until the window is actually shown before hiding it. The app shows
  its window on ready-to-show, and a hide() that lands earlier is undone
  by that show(); this was the first-attempt failure on the macOS shard.

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

* test(e2e): check the playback display lock is released while hidden

Review follow-ups for #1724:

- Add an E2E that plays the webm fixture in the unautomated launch,
  records the main process's prevent-display-sleep blockers, and asserts
  the keep-awake lock is taken while visible, released when the window is
  hidden, and taken again when it is shown. The visibility tests alone
  would still pass if the renderer gate or the bridge stopped updating
  powerSaveBlocker.
- Validate CDP replies before dispatch (CodeQL
  js/unvalidated-dynamic-method-call): only a numeric id with a pending
  settle function is called.

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

* test(e2e): skip killing an Electron that already exited

stopElectron now checks the recorded exit state before each signal and
tolerates a kill that races the exit: on Windows taskkill throws for a PID
that no longer exists. Startup cleanup can no longer replace the startup
error that explains the failure; a cleanup failure there is logged.

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

* fix(stalker): keep the watchdog cadence while the window is hidden

With background throttling on, Chromium wakes a hidden, silent page's
timers at most once per minute after five minutes, so a portal that asks
for get_events every 30 s would see pings at half its cadence while the
window is minimized. Tick the watchdog from a dedicated worker
(createBackgroundInterval, an inline blob worker allowed by the renderer
CSP), whose timers are not subject to page throttling. It falls back to a
page setInterval where no worker is available or the worker fails to load.

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

* fix(stalker): keep a stopped watchdog interval stopped

A worker error that arrived after stop() started the page fallback
interval, which nothing cleared, so pings continued for an inactive
playlist. stop() now marks the interval stopped, detaches the worker
handlers, and the fallback refuses to start afterwards.

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-30 07:17:30 +02:00
cd0a50dbec fix(playback): stop random black frames on software GL in frame-copy (#1748)
* test(e2e): explain black canvases in the packaged frame-copy smoke

When the smoke's canvas stays black, print and attach the session
snapshot (with mpv's drop counter), every slot of the helper's
shared-memory frame rings read from /dev/shm, and the session's verbose
mpv log (log-file). Together they separate these cases: the helper never
published a frame, mpv rendered black, or the preload pump did not draw
a real frame.

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

* fix(playback): stop random black frames on software GL in frame-copy

mpv's LUT scalers (the default lanczos/spline family) build their weight
texture from an uninitialized talloc_array: mp_compute_lut fills only
kernel->size of every stride floats, so each row's padding is heap
garbage (reinit_scaler in video/out/gpu/video.c, mpv 0.41 and master).
The LUT is an rgba16f texture sampled with linear filtering. When the
padding holds NaN, or a value large enough to become Inf as half-float,
Mesa's llvmpipe carries it through the zero-weight neighbour texel, the
weights become NaN and the frame clamps to pure black. Whether it happens
depends on heap contents, so the packaged smoke's paused frame was black
in most attempts on the CI runner.

Evidence from CI (8 repeats each, no retries): baseline 2/8 passed;
LUT-free bilinear scalers 8/8; gpu-dumb-mode 8/8; LP_NUM_THREADS=1 6/8.
A synchronous glReadPixels right after mpv_render_context_render()
showed the black frame already in the helper's FBO. The readback, the
shared-memory ring and the preload pump were correct.

On a CPU rasterizer the helper now selects mpv's LUT-free bilinear
scalers and disables sigmoid upscaling. Software GL cannot afford the
LUT scalers anyway. A session option for the same key still wins.

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

* fix(playback): report software scaler results and scope smoke ring diagnostics

The software-renderer fallback now reports which scaler options mpv
accepted and which it rejected (with mpv's error), instead of always
printing success. The smoke's black-canvas diagnostics read only the
failed session's rings (<sessionId>-g<N>), not every impv-fc ring in
/dev/shm.

Addresses Greptile review feedback.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:37:21 +02:00
f0765012fa fix(playback): stop external player position polling that starts after exit (#1720)
MPV and VLC wait 2 s and 1.5 s before their first position poll, but the
delay timer was never stored, so stopping the poll during that wait could
not cancel it. A player that exited, errored or was replaced early left a
5 s (MPV) or 2 s (VLC) interval polling a dead socket or RC port until the
next launch or app quit. Keep the delay handle and clear it with the
interval.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:40:34 +02:00
1d9a563d1a test(performance): count startup phases and SQL statements for the J1 launch journey (#1715)
* test(performance): count startup phases and SQL statements for the J1 launch journey

Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process
keeps named counters and registers a main-only performance:read-counters
IPC handler; without the flag nothing is counted and the handler does not
exist.

- debug-trace.ts owns the registry; traceStartupPhase replaces the
  trace('startup', ...) sites and counts main.startupPhases.
- The database worker counts executed statements through better-sqlite3's
  Statement prototype (the verbose callback expands every statement and
  made bulk inserts 2-4x slower) and posts the count over its message
  port, flushed before every other worker message. The main-thread shared
  connection is counted through a new connection observer in the shared
  database library.
- The first main window freezes main.modulesRegisteredBeforeWindow at
  creation and main.sqlStatementsBeforeReadyToShow at ready-to-show.
- The journey gate drops the ready-to-show that Electron emits for the
  about:blank detour, so the app sees the real document's first paint,
  and taps the counters handler; the J1 record reads both counters after
  the renderer probe completes.

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

* test(database): require one SQL statement per exec during initialization

The performance capture counts one exec call as one statement, because
SQL cannot be split reliably in the counter (trigger bodies contain
semicolons). The historical-upgrade driver now wraps exec on every
connection initDatabase opens and fails on a batch, so that counting
assumption holds for the fresh profile and all historical schemas.
Documents the definition in the counter and the architecture docs.

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

* test(performance): count SQL statements only for the launch journey

Codex review: the M3U import, refresh-cancellation and Xtream benchmarks
also run with IPTVNATOR_PERF_CAPTURE=1, so the statement hook wrapped
every row of their bulk inserts and changed what they measure.

SQL counting now also needs IPTVNATOR_PERF_COUNT_SQL=1, which only the
launch journey sets; a harness test fails if another source sets it.
Startup phases, the window snapshot and the read handler stay on the
capture flag. Without SQL counting no ready-to-show listener is attached,
so a zero is never reported for statements nobody counted.

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 21:01:26 +02:00
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
4grayandClaude Fable 5.1 8ebb7e3424 perf(ci): run Tier A coverage concurrently with isolatedModules ts-jest (#1701)
Tier A coverage runs projects a few at a time (largest first, bounded Jest workers, buffered output, fail-fast kept) and ts-jest transpiles with isolatedModules instead of type-checking per process; five type re-exports become export type, two decorated inputs use import type. Unit Tests and Typechecks job: 26 min -> 9 min (Tier A step 23 min -> 6.5 min).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:05:26 +02:00
4grayandClaude Fable 5.1 e39c854a41 perf(electron): load the main-process startup wiring after the window starts loading (#1702)
Registering IPC handlers costs 0.4 ms; evaluating the modules behind them (axios, drizzle-orm, better-sqlite3, electron-updater, fix-path) before the window could load was the real cost. main.ts now keeps only the pre-paint wiring and loads the rest as the deferred-events.js chunk inside the main window's did-start-loading listener, where the import and its registrations complete before any renderer invoke can arrive.

Interleaved A/B on the performance build: app.whenReady 323 -> 265 ms, did-finish-load 499 -> 447 ms; J1 journey spawnToDidFinishLoad ~405 -> ~365 ms with identical counters. Packaging ships the chunk explicitly, verify:package-layout requires it, and the benchmark build identity hashes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 21:51:19 +02:00
4grayandClaude Fable 5.1 587e19541f perf(electron): compile the main-process bundle from a V8 code cache (#1696)
main.js is now a small entry that enables Node's on-disk V8 compile cache under userData/v8-compile-cache and then requires the application bundle main.app.js. Warm launches reach app.whenReady about 13 ms sooner at the median; IPTVNATOR_DISABLE_COMPILE_CACHE=1 turns it off and IPTVNATOR_COMPILE_CACHE_DIR relocates it.

Packaging now ships main.app.js explicitly and verify:package-layout requires the main-process entry files in app.asar, because nx-electron copies the backend through an allowlist. The Xtream benchmark build identity hashes both the launcher and the bundle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 19:19:30 +02:00
4grayandClaude Opus 5 eb61d37dc3 docs(tmdb): replace catalog titles in matching examples with stand-ins (#1658)
* docs(tmdb): replace catalog titles in matching examples with stand-ins

The Cyrillic and Arabic examples that document title folding were taken
straight from a user's own portal catalog while the folding bugs were being
diagnosed, and they spread from there into comments, fixtures, assertions and
the architecture doc. A public repository is not the place for someone's
viewing inventory, and the TMDB ids pinned alongside them identify the exact
shows.

Swap in stand-ins that reproduce the property under test rather than the
title: a word whose "й" folds away, one whose "ё" does, a two-word title
carrying a season marker, an Arabic phrase whose initial hamza decomposes into
a separate word. Every replacement was run through the real `normalizeTitle`
and `cleanTitleForSearch` before being written, so the folded keys, the wire
queries and the cache lookup keys are the same shape as before — "Лейка" and
"Леика" still meet on one key while staying two different searches, and
"AR| أمثلة تجريبية" still keys as a hamza split into a space. Real TMDB ids
become synthetic ones. One channel fixture that read as a film title becomes a
plain channel name.

Deliberately left alone: the leading-tag rule's "Akira | 1988" / "Момо | Momo"
examples in `title-normalization.util.ts`. Those are not an inventory — they
are the evidence for a regex decision measured over 1.27M catalog titles, and
inventing replacements would document a measurement that never happened.

No release note: comments, fixtures and docs only, with no behaviour change.

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

* docs(tmdb): label the folding stand-ins as illustrative, not as history

Review caught that the substitution left synthetic titles inside sentences
that assert observed fact: the architecture doc claimed a particular folded
query "returns zero results while `Лейка` returns the show" and named the
stand-ins as the titles that were searched, missed and negatively cached, and
several comments read the same way. The titles never existed, so the reading
is wrong in the one direction that matters — a future reader debugging a
regression would take them for production evidence.

Keep the claim that is actually established, which is a class-level one:
under the old single-form design every Cyrillic title carrying "й"/"ё" and
every Arabic title carrying a hamza form was searched folded, missed, and
cached as missing for the negative TTL. Present the strings themselves as
illustrative stand-ins chosen to fold the same way. The verified invariant —
what NFD does to those letters, and what the two tiers therefore produce — is
unchanged and still assertable, because it is a property of the text rather
than of any title.

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

* docs(tmdb): finish the sweep — a missed cache key and a last observed-fact claim

Two spots the first pass left behind:

- `tmdb-search-cache-cleanup.spec.ts` kept a catalog-derived lookup key
  verbatim. Only the TMDB id beside it had been swapped, because the sweep
  matched the title in its display casing and the key stores it lowercased —
  so one item of the inventory stayed in the repository, twice.
- `SearchTitleVariant`'s doc comment still asserted that one specific folded
  string finds nothing on TMDB. State the mechanism instead: folding rewrites
  the letters the search matches on, so the folded form finds nothing there.

`content-search.util.sqlite.spec.ts` gets a channel fixture that no longer
reads as a film title.

`search-text-fold.util.spec.ts` is deliberately left alone. Its "Ёлки" is a
common noun sitting in a Unicode corpus beside "Amélie", "İnşaat" and "Ά",
not an inventory entry — and its rows are precomposed/decomposed PAIRS
(U+0401 against U+0415 U+0308). A textual substitution rewrites only the
composed half and silently turns the pair into two different words; doing it
failed that spec, which is what a fixture encoding an invisible property is
for.

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

* docs(search): finish the fold fixtures by rewriting both halves of the pair

The last two occurrences lived in the Unicode fold corpus, which an earlier
attempt left alone after a textual substitution failed the spec. The reason it
failed is the point: one row is a precomposed/decomposed PAIR — U+0401 against
U+0415 U+0308 — asserting that both spellings fold to one string. Replacing
the visible text rewrites only the composed half and silently turns the pair
into two different words, which is not a thing a reader or a regex can see.

Rewrite both halves by code point instead, keeping the decomposed half
decomposed: U+0415 U+0308 + the new stem. Verified by decoding every quoted
string in the file afterwards, and the spec passes (534/534).

A repository-wide sweep now finds no occurrence of the replaced titles in
either normalization form.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:24:14 +02:00
b30c783e85 fix(search): locale-invariant Turkish case folding in every search path (#1640)
Turkish upper and lower case queries now return the same results everywhere a
title can be searched. Lower-casing the dotted capital "İ" (U+0130) leaves a
combining dot behind, so "İnş" and "inş" reached different search arms and
different results.

- Case folding is locale-invariant: `toLowerCase()`, never `toLocaleLowerCase()`,
  which under a Turkish or Azeri OS locale maps ASCII "I" to the dotless "ı".
- The Electron content search composes to NFC and drops the leftover combining
  marks before tokenizing, and its LIKE/GLOB pattern builders additionally spell
  the `'tr'`-locale İ forms, since SQLite LIKE folds only ASCII.
- A shared `foldSearchText` covers every in-memory filter: channel lists, the
  Xtream and Stalker catalogs, category filters, collections, the EPG guide, the
  command palette, sources, the playlist switcher and the download lists.
- Composing before the strip keeps canonically equivalent spellings equal while
  the fold stays accent-sensitive; the Turkish I/ı pair is deliberately left
  alone, as the FTS index does not fold it either.

Covered by a SQLite-backed spec over the real trigram index plus regression
cases in the affected renderer specs.

Closes #609.

Co-Authored-By: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:14:02 +02:00
4grayandClaude Fable 5.1 01c423ac43 fix(portals): stop treating a slow panel as a dead host; IPv4 fallback budget in Electron (#1621)
* fix(portals): apply the IPv6->IPv4 fallback budget in the Electron process

The 2500 ms happy-eyeballs attempt timeout from #1404 only ever ran in the
web backend. The Electron main process and its playlist-refresh and EPG
workers kept Node's 250 ms default, so a dual-stack panel hostname behind a
VPN or a slow link failed every connection attempt in a row and tripped the
host connectivity guard. The module now lives in `@iptvnator/shared/host-health`
and every Node isolate that opens connections applies it.

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

* fix(portals): stop treating a slow panel as a dead one in the host guard

axios raises the same ECONNABORTED whether the SYN went unanswered or the
panel accepted the connection and then thought for longer than the request
budget. Two such timeouts opened the breaker and every request to the panel
was refused for 30 s with "portal is not responding" — the shape behind the
"connection keeps dropping" reports on 0.23 and nightly.

Both transports now report whether the TCP connection was established
(`onConnect`: Electron through a per-request observed agent instead of the
shared keep-alive globalAgent, the web backend through the transport that owns
the ClientRequest), and `classifyHostRequestFailure(error, { connected })`
downgrades a host-level code observed after the handshake to inconclusive.
Redirect attribution keeps precedence. A host that never accepts the
connection trips the guard exactly as before.

The Xtream mock gains a `silent:silent` scenario whose detail actions accept
and never answer, plus a real-socket regression spec for the guard.

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

* fix(portals): let an accepted connection clear the host-failure streak

Review finding: an unanswered SYN, then an accepted-but-slow timeout, then
another unanswered SYN still reached the two-failure threshold, because the
middle request was merely not counted. An accepted TCP connection is the
reachability the guard measures, so it now reads as `responded` and clears
the streak like an HTTP response would. Regression coverage for the mixed
sequence on one flapping loopback origin (Electron) and through the proxy
route (web backend).

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

* fix(portals): credit an accepted connection when it happens, not when the request settles

Review findings. A request that connected and then hung for 30 s cleared,
on its eventual timeout, the failures later requests had recorded while it
waited — reopening a host that had just died on evidence older than theirs.
The connect hook now reports the connection the moment it fires through a
new `HostConnectivityGuard.reportConnected`, which clears the failure streak
but closes no open or half-open breaker (the trial keeps its slot until it
settles), and the settled timeout is inconclusive.

Electron also skips the socket observer while an environment proxy
(`http_proxy` / `https_proxy` / `all_proxy`) applies to the request: through
a proxy the socket connects to the proxy, whose handshake proves nothing
about the portal, so those requests keep the pre-observer behaviour.

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

* fix(portals): decide the proxy exemption with axios' own resolution

Review findings. The hand-rolled environment check ignored `no_proxy`, so a
LAN portal exempted from the proxy lost its connect observer and slow
requests to it still tripped the breaker; it also read the variables with
`??`, letting an empty lowercase one mask a populated uppercase one that
axios would honour. The decision now calls `proxy-from-env`'s
`getProxyForUrl`, the same pinned package axios' http adapter uses,
declared as a direct dependency so the packaged app carries it.

The validated-axios spec clears and restores every proxy variable around each
case, so a runner that exports a proxy cannot change what the cases prove.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 14:50:50 +02:00
4grayandClaude Fable 5.1 7522c7689f fix(settings): honest update-channel check and fresh release notes (#1631)
* fix(settings): make the update-channel check honest and keep release notes fresh

Settings → About mixed two commit models: the channel select applied on
Save while "Check again" ran immediately against the still-saved channel,
so picking Nightly and checking reported "latest version" for Stable under
a select reading Nightly. The status now carries a badge naming the channel
the verdict describes; while the select shows an unsaved other channel the
verdict is dimmed, a hint names both channels, and the check button becomes
"Save and check for <channel> updates", which submits the form — the main
process already re-checks when the saved channel changes. A download in
flight or finished belongs to the previous channel and keeps the plain
check.

"What's new" for a nightly published after the app started failed with a
raw IPC error: each release catalog is a process-lifetime snapshot and a
fully paged list never re-read GitHub. findIndex now reloads the catalog
once when a version is missing, and a newly found update drops every
catalog. The dialog recognises the not-found rejection through the shared
marker text, explains it with the version, and links to the channel's
release list; other failures keep their reason under a localized headline.

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

* fix(updater): serialize readers of one release catalog

findIndex may rebuild the shared release array while another reader of
the same catalog still holds an index into the old one and dereferences
it after paging further. Every reader now runs through the catalog's
runExclusive queue (getReleaseNotes and the manual-update check), so a
reload can no longer pull the list out from under a navigation.

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

* fix(settings): attribute a kept download to the channel it was found on

setChannel keeps a download that is running or finished when the saved
channel changes, but status.channel already names the new channel, so the
About badge attributed a Stable download to Nightly and the pending hint
vanished. Every check now stamps status.verdictChannel with the channel it
ran on and setChannel leaves it alone; the badge names that channel, and
while it differs from the saved one a hint says the shown update came from
the other channel and the saved one has not been checked yet.

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

* refactor(updater): move release-notes reads out of AppUpdateService

AppUpdateReleaseCatalogs (app-update-release-notes.ts) now owns the
per-channel catalogs and both reads the updater performs on them: release
notes with previous/next paging and the newest release for the
manual-install fallback. The service only delegates, shrinking from 610
to 508 lines instead of growing.

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

* docs(updater): name verdictChannel as the badge's source

The About paragraph still said the badge reads status.channel while the
paragraph below it and the code use status.verdictChannel.

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

* fix(settings): show the release list, not the earlier release, after a paging failure

A failed Previous/Next keeps the earlier notes for navigation while the
body shows the error, so the dialog's action offered the earlier release
instead of the channel release list. The error is now checked first.

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

* fix(updater): do not reload the catalog before falling back to latest

A read that falls back to the newest release on a miss (the installed
version's notes, e.g. an unpublished local build) paged the whole list
twice: findIndex reloaded on the miss before index 0 was selected. The
reload is now opt-in per call and off on that path.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 13:09:24 +02:00
4grayandClaude Fable 5.1 634a66b1e4 build(deps): declare node-gyp for the embedded MPV native build (#1625)
`apps/electron-backend/build-embedded-mpv.js` resolves the compiler with
`require.resolve('node-gyp/bin/node-gyp.js')` and its comment claimed the
package was "a declared devDependency" — it never was. node-gyp reached the
tree only as a transitive of `@electron/rebuild`, in pnpm's hidden hoist
(`node_modules/.pnpm/node_modules`). pnpm's `.bin` shims export that
directory on NODE_PATH, which is why `pnpm nx …`, `pnpm run build:backend`
and CI kept building the addon, while a plain
`node apps/electron-backend/build-embedded-mpv.js` on a clean install failed
with "Unable to resolve node-gyp".

Declare node-gyp 12.4.0 (the version already in the lockfile store) as a root
devDependency so the resolution no longer depends on a shim implementation
detail, correct the stale comment, and record the contract in the
embedded-MPV architecture doc plus the Agent Bootstrap notes.

No packaged-build change: the no-runtime skip and its
`embedded-mpv-unavailable.txt` marker are untouched.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:30:50 +02:00
4grayandClaude Fable 5.1 c016c73f86 feat(shell): zoom shortcuts on Windows and Linux (#1109) (#1623)
* feat(shell): zoom shortcuts on Windows and Linux (#1109)

Cmd/Ctrl and +/−/0 (numpad included) now zoom the app on every platform.
Windows/Linux run without a menu (`setMenu(null)`), so the shortcuts are a
renderer key binding in `WorkspaceKeyboardShortcutsService` calling a new
synchronous, preload-local bridge method `adjustZoomLevel`, which steps the
frame-bound temporary level through `webFrame.setZoomLevel` — never a
main-process `webContents.setZoomLevel`, whose per-URL entry the app's
`file://` path routing resets. Step and limits live in
`libs/shared/interfaces` (`stepZoomLevel`: 0.5 per press like Electron's
zoomIn/zoomOut roles, clamped to levels −4…6). On macOS the renderer sees the
key before the application menu, and `preventDefault()` keeps the menu role
from stepping a second time (Electron only performs the menu key equivalent
in its unhandled-keyboard-event hook).

Persistence is unchanged: the main process still reads the live level back
on close, quit and reload. The zoom E2E now drives the real shortcuts (in,
out, numpad, reset). Help dialog entries added and translated for all
locales; contract updated in docs/architecture/workspace-shell.md.

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

* fix(shell): never step a stored out-of-range zoom level against the request

A level persisted before the shortcuts existed (the macOS menu roles never
clamped, and the store restores any finite level) was clamped BEFORE the
step, so the first zoom-in from level 7 rendered smaller. Step from the raw
level instead: a press further out leaves an out-of-range level where it is,
a press back in lands on the limit.

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

* docs(shell): state the zoom bridge's return contract precisely

`adjustZoomLevel` steps by `stepZoomLevel`'s rules; a stored out-of-range
level is never moved against the request, so the returned level is not
itself guaranteed to be within `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`.

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

* docs(release): add a release note for the zoom shortcuts

The Release note gate requires an added `.changes/*.md` for runtime changes;
the shortcuts are a user-visible feature of their own, so they get their own
note and the persistence note stays about persistence.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:47:32 +02:00
4grayandClaude Fable 5.1 6f987d79d8 fix(shell): reload the packaged renderer back onto its in-app route (#1622)
The packaged renderer is index.html over file:// with path routing, so
after in-app navigation the document URL names a path with no file behind
it. A main-process reload (macOS View > Reload, DevTools) failed with
ERR_FILE_NOT_FOUND and stranded the window on Chromium's error page; a
renderer-initiated reload (the settings unsaved-changes guard's confirmed
location.reload()) was cancelled by the will-navigate trust check and
silently did nothing.

Both legs now re-load the packaged index with the route in a restoreRoute
query parameter, which main.ts restores with history.replaceState before
Angular bootstraps. The did-fail-load recovery is deferred to the error
page's dom-ready: a load issued from inside the failure event yields a
document that never receives animation frames and never paints.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 08:43:43 +02:00
ab239b5043 fix(shell): persist the app zoom level as frame-bound temporary zoom (#1109) (#1617)
* fix(shell): persist and restore the app zoom level (#1109)

The app-wide zoom (Cmd/Ctrl and +/-) was never saved, so it reset to the
default on every restart. Save the webContents zoom level next to the window
bounds on window close and before quit, and reapply it once the renderer
finishes loading. webContents zoom is per-host, so the restored level then
holds across in-app section navigation (SPA route changes never reload).

* fix(shell): restore the zoom level as frame-bound temporary zoom (#1109)

Under file:// Chromium keys zoom by the full URL, and the packaged
renderer routes with pushState, so a level applied through
webContents.setZoomLevel belongs to index.html only: the first resize
after a section change snapped the renderer back to the default, and
the close handler read the current route's entry (usually 0) over the
user's choice. The preload now applies the persisted level with
webFrame.setZoomLevel, a temporary zoom bound to the frame that survives
in-page navigation and resizes; the main process hands it over through
the synchronous WINDOW:GET_ZOOM_LEVEL IPC and writes the live level back
on close, before-quit and before every cross-document navigation, since
a reload drops the temporary level.

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

* fix(shell): apply the restored zoom level at DOMContentLoaded (#1109)

A webFrame.setZoomLevel at preload start left a hidden window without a
first frame on Linux and Windows: ready-to-show never fired, the window
never showed, and the renderer got no animation frames, so the startup
splash removed in a requestAnimationFrame stayed. macOS was unaffected
and CDP-driven tests force frames, which is why only the packaged
legacy-migration E2E asserting the splash is gone caught it. Applying
once the document is parsed is harmless and still lands before the
first Angular paint; ownership of the level now follows the preload's
WINDOW:ZOOM_LEVEL_APPLIED acknowledgement instead of the request. The
release note no longer advertises Ctrl shortcuts Windows and Linux do
not have.

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

---------

Co-authored-by: Justin Willhite <5132924+thejdubb02@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 15:29:35 +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
d438f5c655 feat(epg): accept local XMLTV files as EPG sources (#1600)
* feat(epg): accept local XMLTV files as EPG sources

Settings → EPG and the playlist dialog accepted `file://` in their form
pattern, but the main process rejected everything except http(s), so a local
XMLTV entry saved fine and then failed on import. Both surfaces now take a
remote link, a `file:` URL, an absolute POSIX path or a Windows drive/UNC
path (`classifyEpgSourceReference` in shared/interfaces), and the settings
section spells out the accepted formats with examples.

The EPG worker opens every source through `openEpgSourceStream`: remote
links keep the validated-redirect client and trust policy, local files are
read from disk behind the signature-sniffing optional gunzip stage, so
.xml, .xml.gz and extension-less gzip all parse. Only hand-typed sources
may be local: `extractM3uEpgUrls` harvests http(s) links only from M3U
headers, since the local branch bypasses `validateRemoteUrl`.

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

* feat(epg): pick local XMLTV files with a native file dialog

A folder button beside each EPG source row (Settings → EPG and the playlist
dialog) opens the native open-file dialog and writes the chosen absolute
path into the row. New `EPG_OPEN_FILE_DIALOG` IPC behind
`ElectronBridgeApi.openEpgFileDialog`, gated in the renderer by
`RuntimeCapabilitiesService.supportsEpgFilePicker`.

The row's refresh/remove buttons carry `data-test-id`s now, and the EPG
e2e suites address them by id instead of index, since the folder button
became the first button in a row.

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

* fix(epg): authorize local XMLTV files in the main process

Review follow-up (Greptile P1, Codex P1). The renderer hands source strings
to FETCH_EPG/EPG_FORCE_FETCH unchanged, so the form validator alone could
not enforce the provenance rule: a compromised renderer, or a legacy
`file://` entry an older version stored from an M3U header, could name any
file on disk.

`EpgWorkerService.startFetch` now asks a main-process
`EpgLocalSourceAuthorizer` before a local path reaches the worker: a path
the native picker returned is trusted at once, a hand-typed path is
confirmed once in a native message box the renderer cannot fake, and a
refusal is reported in the progress panel. Allowed paths persist under
TRUSTED_LOCAL_EPG_SOURCES in the main-process config. The worker opens its
local branch only when main set `allowLocalFile`; the service defaults to
deny-all until epg.events installs the persisted authorizer.
`resolvePlaylistEpgSourceState` and `filterPlaylistEpgUrlsForFetch` drop a
stored non-remote entry unless it is also in `manualEpgUrls`.

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

* fix(epg): fail a refused local EPG fetch instead of resolving it

Review follow-up (Codex P2). A denied native confirmation now rejects the
fetch after reporting the error row, so handleFetchEpg and the renderer's
fetch result cannot claim the file was read. Also restores the unrelated
CLAUDE.md paragraph an earlier formatter pass had reflowed into a list.

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

* fix(epg): cancel a local source retired during its authorization prompt

Review follow-up (Codex P2). startFetch keeps the request generation
captured before awaiting the native confirmation and rechecks it
afterwards: a source retired meanwhile ends as cancelled instead of
starting an import that a pending clear would then have to await.

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

* test(epg): leave the local XMLTV e2e with a pristine settings form

The local-file test ended with the EPG source field still dirty, which
arms the main-process close guard: the app then waited for the unsaved
changes dialog instead of closing, the close timeout killed it, and on
Windows the killed process kept iptvnator.db busy (EBUSY on the data-dir
cleanup) and hung the Playwright worker teardown. Discarding the form
before the app closes takes the test from 15 s to 4 s locally.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 21:04:41 +02:00
4gray 62655a8b5d feat(playlist): show desktop health indicators for network sources (#1592) 2026-09-12 23:05:17 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray 0700ba966e fix(epg): decode gzip files wrapped in HTTP gzip (#1589) 2026-09-12 14:23:59 +02:00
Lars Emig e76447975b feat(playback): stream-info popover in the player overlay (#1578) 2026-09-10 21:33:41 +02:00
4gray bad8a0991e feat(downloads): download completed Xtream catch-up programmes as TS (#1572)
* feat(epg): copy catch-up programme URLs without changing playback

* feat(downloads): save completed Xtream archive programmes as TS

* fix(epg): let newer archive copy requests supersede pending work

* fix(downloads): protect archive partials and independent submissions

* fix(downloads): verify archive identity through finalization

* fix(downloads): bound archive storage and capture cleanup entries

* fix(downloads): preserve archive ownership across failure paths

* fix(downloads): recover explicitly verified archive completions

* fix(downloads): journal archive promotion before publishing files

* fix(downloads): reset archive proof before an explicit restart

* fix(downloads): preserve archive recovery ownership and interruption

* fix(downloads): verify durable archive identity at resume open

* fix(downloads): fence archive commands during completion commit

* fix(downloads): persist archive ownership throughout its lifecycle

* fix(downloads): protect archive removal and missing-file recovery

* fix(downloads): journal private cleanup captures for recovery

* fix(downloads): journal active archive cleanup before removal

* fix(downloads): clean settled archives before deleting stale rows

* fix(downloads): preserve archive ownership on removal and resubmission

* fix(downloads): recover proven archive completions before retry

* fix(downloads): recover local archives before remote transfer checks

* test(downloads): resolve archive fixture from workspace root

* fix(downloads): distinguish reused archive inodes by creation time

* fix(downloads): bind fresh archive reservations to owned files

* fix(downloads): clean reservations when ownership writes fail

* fix(downloads): commit archive reservation and ownership atomically

* fix(downloads): retain captures until replacement restoration succeeds

* fix(downloads): require durable ownership before cleanup relocation

* fix(downloads): preserve 64-bit archive file identities on Windows

* refactor(release): keep capture fixture constants in their shared module

* fix(downloads): preserve the last link of captured foreign files

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
2026-09-08 20:33:05 +02:00
4grayandClaude Fable 5.1 97b0264dee fix(xtream): render catch-up start times in the panel timezone (#1563)
* fix(xtream): render catch-up start times in the panel timezone

The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).

- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
  ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
  from the `time_now` / `timestamp_now` clock pair, so spellings such as
  `UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
  when unchanged) and project it back from the payload in
  `DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
  read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
  +03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
  Global favorites, and the clock-pair derivation at a UTC-3 viewer.

Closes #1562

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

* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates

Review follow-ups (Greptile):

- A source switch while `get_account_info` is in flight no longer hands
  playlist A's status or clock to playlist B: the store is patched only
  while the asking playlist is still selected, the timezone is persisted
  under the asking playlist's id regardless, and a late failure cannot mark
  the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
  strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
  over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
  by every account-info check and only ever used for non-standard servers.

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

* fix(xtream): drop a panel clock that no longer belongs to the source

Review follow-ups (Codex + Greptile):

- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
  server drops the persisted `serverTimezone` (payload-only) until the next
  account-info check, so Favorites / Recent cannot keep rendering the OLD
  panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
  at the panel it came from — an edit that moved the source during the
  request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
  timezone into the store playlist, so a later response without a usable
  clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.

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

* fix(xtream): drop the stale panel clock inside the UPDATE statement

Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).

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

* refactor(xtream): split the server-clock primitives out of the timezone util

Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.

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

* fix(xtream): offer the learned panel clock to storage on every check

Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.

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

* fix(xtream): apply an account-info answer only to the panel it came from

Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.

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

* fix(xtream): never report another panel's status for the selected playlist

Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.

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

* fix(xtream): persist the panel clock with one conditional UPDATE

Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.

Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:

- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
  that `json_set`s the payload only while the row still points at the
  request's connection and does not already carry the value; a malformed
  payload is never rewritten (CASE, not AND, so json_extract cannot run
  before json_valid). Wired through the worker types, main handler,
  preload, bridge interface, both IPC contract tables and
  `DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
  readwrite cursor transaction, plus the localStorage copy.

The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.

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

* fix(xtream): keep the stored panel clock across clockless full upserts

Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.

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

* refactor(release): split capture-navigation under the max-lines cap

`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:

- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
  navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
  sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
  alternative sources (the two identical live-category flows share one
  helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
  the re-exported API the seeding driver and the capture script import

Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.

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

* refactor(electron): move the panel-clock SQL into its own operations module

Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 19:40:47 +02:00
4grayandClaude Fable 5.1 7d1503fd31 feat(epg): rebuild the programme guide for M3U playlists (#1560)
* docs(epg): add programme guide redesign spec for the M3U host

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

* docs(epg): add programme guide implementation plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat(epg): add guide keyboard navigation controller

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* i18n(epg): translate the programme guide

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

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

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

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

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

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

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

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

* test(e2e): cover the programme guide flow

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 18:47:25 +02:00
4gray 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 9bcdbc0efb fix(migration): preserve and recover legacy desktop sources (#1550)
* fix(migration): recover legacy desktop sources without replacing current data

* test(migration): cover legacy recovery IPC contracts

* test(migration): use static legacy Electron bootstrap
2026-09-06 00:45:35 +02:00
4gray e40f31db97 fix(host-health): retain trial ownership until requests settle (#1547) 2026-09-06 00:43:54 +02:00
4gray eb3cf89f62 fix(stalker): let media transports control seek byte ranges (#1544) 2026-09-05 20:51:32 +02:00
4gray 0ba5107561 fix(m3u): use custom User-Agent for URL import and refresh (#1535) 2026-09-05 14:49:23 +02:00
4gray 07ccf45f32 fix(playback): hide VLC RC console on Windows (#1533)
* fix(playback): hide VLC RC console on Windows

* fix(playback): preserve VLC retry arguments containing quiet flag
2026-09-05 11:33:05 +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 2f1ffe59f9 fix(embedded-mpv): disable the youtube-dl hook in the frame-copy helper (#1526)
The native-view addons and the external MPV launch path already run with
`ytdl=no`, but the frame-copy helper left mpv's default, so a refused HTTP
open fell through to ytdl_hook and yt-dlp before failing. That delayed the
`error` transition the auto-reconnect policy waits for and could leave the
session error reading "youtube-dl failed: unexpected error occurred"
instead of the load error.

Set `ytdl=no` in the helper's built-in block, before the stdin
`mpv-options` loop, so a user `ytdl=yes` session option still overrides
it. Document the helper's built-in block next to the other session-option
transports and add a release note.

Verified by rebuilding the helper against Homebrew libmpv and driving the
binary by hand against a connection-refused URL: the built-in default logs
only the ffmpeg/stream errors, while a `ytdl=yes` stdin line spawns yt-dlp.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:00:43 +02:00
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

The contract lives in `libs/shared/interfaces/src/lib/epg-display-offset.util.ts` with two equivalent forms: `epgDisplayTimeMs` shifts a programme for display, `epgProviderClockMs` shifts "now" into the provider's clock for every "currently airing" decision — the batched `GET_CURRENT_PROGRAMS_BATCH` lookup takes an explicit `nowMs`, and the channel lists, the Xtream/Stalker previews, the M3U player's current-programme mirror, the unified collection resolver, the dashboard live cards and the recording overlap all pick the same programme the guide renders as "now". Portal short-EPG windows start at the provider's own "now", so under a non-zero offset the Xtream preview surfaces cut their window from the full guide at the provider clock, Stalker short-EPG requests are widened for negative offsets, and every per-stream memory of the previous offset is retired together when the setting changes.

Co-authored-by: Mark Jardine <markjardine27@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:46:10 +02:00
4grayandClaude Fable 5.1 b2ca85172c fix(playback): seek Embedded MPV steps relative to mpv's own position (#1518)
* fix(playback): seek Embedded MPV steps relative to mpv's own position

Arrow keys and the ±10 s buttons in the Embedded MPV player advanced only
about a second per press when pressed repeatedly or held. The shortcuts
already asked for 5 s steps, but `EmbeddedMpvCommandRunner.seekBy` turned
each step into an absolute `seek` computed from `session.positionSeconds`,
which is floored to whole seconds, polled every 500 ms (helper snapshots at
most every 250 ms) and not refreshed by the seek reply. Every press inside
that window therefore landed on the same target.

Steps now go through a new `EMBEDDED_MPV_SEEK_BY` IPC / `seekEmbeddedMpvBy`
bridge method that every backend forwards as mpv `seek <delta>
relative+exact`: `seekBy` exports in the macOS addon and the Windows/Linux
`wid` addon (Linux over its JSON IPC socket), and a `seek-by` stdin command
in the frame-copy helper. mpv resolves the delta against its own position
and merges queued relative seeks, so presses accumulate as in mpv itself.
The absolute form survives only as a fallback for a preload without the
method or an addon binary without `seekBy`; the timeline scrub still
commits an absolute target.

Validated with a real mpv 0.39 IPC probe: three relative seeks in a burst
advance +15 s, three absolute seeks from one stale base advance +5 s.

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

* fix(playback): drop speculative position update from relative Embedded MPV seeks

Review follow-up for the relative seek path.

The macOS and Windows/Linux `seekBy` exports advanced `snapshot.positionSeconds`
by the delta after dispatching the mpv command. That is not idempotent the way
the absolute seek's optimistic write is: the observer (mpv event thread, or
the Linux IPC poll) can already have stored the post-seek `time-pos` under the
same mutex, so adding the delta on top counted the step twice, and while paused
nothing corrected it. On Linux it also advertised a position that a failed
socket delivery never reached. Relative steps now leave the snapshot alone;
only the observed `time-pos` updates the position.

The packaged Linux frame-copy smoke now drives `seekEmbeddedMpvBy` through the
built app: a burst of three +2 s steps issued without waiting for snapshots has
to land on 6 s, and a -60 s step has to clamp at 0. The generated Y4M fixture
grows from 2 s to 12 s (about 415 KB) so the burst and the playing section that
follows stay inside the clip. Replayed against a local mpv 0.39 with the same
fixture and media server: burst -> 6.0, -60 -> 0.

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

* docs(agents): mirror the Embedded MPV relative-seek contract into AGENTS.md

Review follow-up: the Shared Player Controls section documents the frame-copy
commands and shortcuts, so the relative seekEmbeddedMpvBy invariant lives
there too, next to the CLAUDE.md note.

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

* fix(playback): reject a Linux relative seek the mpv IPC socket did not accept

Review follow-up: the Linux branch of SeekBy discarded the socket transaction
result and returned normally, so a step that never reached mpv looked like a
seek still awaiting observation. It now throws like a failed mpv_command_async
on the in-process engines; the renderer swallows the rejection and resyncs
from the next snapshot, and the main process logs it.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:36:54 +02:00
ZoultandClaude Fable 5.1 5a11b82eaf feat(embedded-mpv): configurable extra libmpv options and network auto-reconnect (#1515)
Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'.

Started by Bpl5966 in #1515 and finished by the maintainers in the same PR.

Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:36:14 +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
4grayandClaude Fable 5.1 82295356ed perf(database): commit catalog writes in row-budgeted transactions (#1511)
* perf(database): commit catalog writes in row-budgeted transactions

Refreshing or deleting a large Xtream playlist spent most of its time in
"Removing cached content": every 100 rows were deleted in their own
transaction, so a 300k-row catalog cost ~3,000 commits, each flushing an
FTS5 segment, re-appending dirty index pages to the WAL and, about every
4 MB, running an fsync-ing auto-checkpoint. Measured on a 900k-row copy of
a real database the delete took 13 s where a single set-based statement
takes 3 s, with 2 GB of WAL traffic instead of 140 MB. The re-import wrote
its rows the same way.

Deletes now read content row counts per category from the covering
indexes, pack categories into groups of about 5,000 rows and issue one
set-based DELETE per group, then drop the categories (and, for playlist
removal, the user-data tables) with one scoped statement each. Inserts keep
100-row statements but commit fifty of them at a time. Cancellation still
lands between commits and progress still reports after each one; the
worker additionally throttles progress events to one per 100 ms with
summed increments, so a large operation no longer floods the renderer.

Same subset, same machine: 13.0 s -> 5.6 s for the delete, 16.7 s -> 8.6 s
for the insert; the fsync-bound share is larger on Windows and spinning
disks.

Closes #1292

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

* test(database): pin the row budget and the worker-side progress flush

Review follow-up: the default 5,000-row budget and the 50-statement insert
commit were only exercised with explicit overrides or sub-budget inputs, and
nothing covered the worker controller flushing a coalesced progress report
before its terminal event. Both are now pinned, and the docs no longer claim
the insert path reports SQLite `changes` or binds 1,600 parameters per
statement (it binds the eleven columns a value supplies).

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

* test(e2e): keep the stress suites on 100-row commits via a test-only budget knob

The Xtream responsiveness and playlist-switcher suites slow the database
worker down with IPTVNATOR_DB_WORKER_BATCH_DELAY_MS so they can observe an
import mid-flight; the stress catalog is 1,920 rows per type, which at the
production budget of 5,000 rows per commit is a single commit per type and
too few progress events for their assertions. The new companion knob
IPTVNATOR_DB_WORKER_ROWS_PER_TRANSACTION restores 100-row commits for those
runs only; unset or invalid it leaves the default untouched.

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

* fix(database): scope refresh and cache-clear deletes to the captured category ids

The row-budgeted rewrite deleted a refreshed playlist's categories with a
playlist-wide predicate. The worker serves other requests between commits,
so a newer import of the same playlist could create categories in that
window and lose them to the older refresh, after which its content inserts
fail their foreign keys. Count and delete now use the ids the collection
step read, as the chunked code did; playlist removal keeps its playlist
scope because its final playlist-row delete cascades the same set.
deleteCategoriesWhere runs every filter through requireScopedFilter.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:31:31 +02:00
4gray 069b8b3cc9 feat(playback): advanced subtitle support in shared player controls (#1471) 2026-08-23 13:57:46 +02:00
5b2eb515d1 feat(downloads): track live-TV recordings in the download manager (#1452)
* feat(downloads): track live-TV recordings in the download manager

Embedded MPV recordings were written to disk and forgotten: no list, no
reveal/play, no missing-file handling, and the channel/EPG context was lost
the moment the recording stopped. Recordings now live beside downloads:

- New `recordings` table (no unique index, no playlist FK — recordings
  survive source deletion; playlist name stored via playlistDisplayLabel).
- EmbeddedMpvRecordingTracker persists the lifecycle: start/stop hooks plus
  a session-snapshot observer for implicit stops (stream-replacement
  auto-stop, frame-copy helper crash, session error/close); startup repair
  turns rows a hard kill left behind into playable `interrupted` partials.
- Channel/EPG metadata is captured at recording START in all four live
  hosts (M3U, Xtream, Stalker ITV, unified live tab); a clean stop triggers
  renderer-side enrichment with every program overlapping the recorded
  window, keyed by target path — covering recordings that span a program
  boundary. Provider EPG never reaches SQLite, so post-hoc lookup is
  impossible by design.
- Own RECORDINGS_* IPC surface + RECORDINGS_UPDATE_EVENT ping and a
  separate supportsRecordings capability gate (the supportsDownloads
  allowlist is all-or-nothing and stays untouched). Reveal/play shell IPCs
  are gated on the recordings table, so the renderer-supplied recording
  directory stays a write-location preference, not a shell-access grant.
- Manager UI: `recording` filter chip, "Recording now" queue section (REC
  pulse, elapsed, live file size — no percentage, the length is unknown),
  16:9 channel-logo Recordings library, Needs attention with Remove only,
  focused detail at /workspace/downloads/recording/:recordingId.

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

* fix(downloads): close the stop-enrichment race and repair player stubs

Greptile spotted a real ordering bug: the stop IPC returns as soon as mpv
acknowledges, while the recording row's terminal-state update is still queued
in the tracker. The renderer answers that snapshot with stop enrichment, whose
handler only accepts a terminal row — so the covered-program metadata could be
silently dropped with "Recording not found".

- EmbeddedMpvRecordingTracker.whenSettled() exposes the serialized write
  chain; RECORDINGS_UPDATE_PROGRAMS awaits it before the terminal-row lookup.
  Regression covered from both sides: the handler must not touch the database
  until the barrier resolves, and the barrier must imply a committed row.

CI also caught spec stubs that had not learned the new player inputs (my local
run-many had been an Nx cache hit, so the failures only surfaced in CI):

- Teach the `app-web-player-view` and `app-embedded-mpv-player` stubs the
  `recordingMetadata` input and `recordingStopped` output across the m3u,
  Xtream, Stalker, unified-live-tab and web-player-view specs.
- The races spec now asserts the metadata argument explicitly instead of
  matching a two-argument call.
- Extract the Stalker and unified-live-tab spec stubs into sibling
  `*.spec-stubs.ts` files (the pattern ui/playback already uses) so both specs
  stay under the 1200-line test limit without shaving assertions.

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

* test(downloads): make the recordings events spec a module

The spec deliberately has no static imports — every dependency is swapped
through jest.doMock before the harness's dynamic import — which also made it a
TS script rather than a module, so its top-level `registeredHandlers` landed in
the global scope and collided with the same-named const in stream-probe.spec.ts
(TS2451). Local per-project runs compile the specs separately and stayed green;
only the Tier A coverage suite builds them into one program, so CI caught it.

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

* fix(downloads): address Codex review on recording lifecycle

Four findings from the Codex review, all real:

- P1: `addon.stopRecording()` only dispatches — native-view uses
  `mpv_set_property_async`, frame-copy writes a helper command — so
  finalizing inside the stop hook could stat a file mpv had not flushed and
  even unlink bytes still being written. The tracker now treats the hook as a
  request and finalizes on the acknowledged inactive snapshot, with a 10 s
  bound so a lost acknowledgement cannot strand the row. Only a recording
  that never went active has its empty reservation removed. Stop enrichment
  follows through `whenFinalized(targetPath)` (bounded) instead of merely
  draining the write queue.
- Live file size: `file_size_bytes` is written at finalization only, so the
  manager's 15 s refresh reported nothing while recording. Active rows are
  now decorated with a current `fs.stat` size.
- Manager-initiated Stop bypassed both player stop paths, so recordings
  spanning program boundaries kept only the start-time program.
  `EmbeddedMpvPlayerComponent` now owns the active→inactive edge and emits
  `recordingStopped` for every trigger; the adapter and legacy toggle no
  longer emit it themselves.
- Startup recovery could terminate a row another live instance was still
  writing under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES. Rows carry `owner_pid`
  and recovery skips those whose owner process is alive.

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

* fix(downloads): derive the enrichment wait from the stop fallback

Greptile caught the seam my previous fix left: the enrichment barrier waited
5 s while the tracker's acknowledgement fallback only finalizes at 10 s, so a
stop mpv never confirms let the terminal-row lookup expire early and drop the
covered programs with no retry — precisely the case the fallback exists for.
The wait is now derived from the acknowledgement bound (fallback + 1 s), with
a regression test that fails if the two ever drift apart again.

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

* fix(downloads): address the second Codex pass on recordings

Four more findings, all real:

- P1 (macOS native-view): `StopRecording` clears `recordingActive` *before*
  dispatching the async property set and restores it if the request is
  rejected, so the first inactive snapshot is optimistic, not an
  acknowledgement — the tracker could finalize (and stat) a file mpv was
  still writing, and a rejected stop would leave the row `completed` while
  recording continued. An inactive snapshot now has to survive a 1.5 s settle
  window (three poll cycles); a revived recording cancels the pending
  finalization.
- Removing a failed row unlinked its path unconditionally, which takes the
  file of a newer recording that reused the freed name within the same
  timestamp second. The cleanup now runs only while no other row claims it.
- The All chip and the header's active badge ignored recordings, so a manager
  holding only recordings read "All 0" and an active recording never showed
  up in the badge.
- Switching channels auto-stops the recording, but by the time the host
  handled the stop its `activeChannel`/EPG already described the NEW channel,
  so the old recording was enriched with the wrong schedule (and an unrelated
  program could be promoted to its title). The stop event now carries the EPG
  key captured while the recording was active and every host compares it
  before enriching.

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

* fix(downloads): close the persistence race and two recording UX gaps

- Greptile P1: the enrichment deadline (fallback + 1 s) still raced the
  terminal write — if the tracker queue or the UPDATE took longer than the
  remaining margin, `whenFinalized` returned while the row was still
  `recording` and the one-shot enrichment was dropped. The deadline now
  bounds only the wait for mpv; `finalize()` removes the entry synchronously,
  so once it has started the wait follows the write itself.
- Codex: `RECORDINGS_STOP` ignored `owner_pid`. Session ids restart per
  process, so under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES stopping another
  instance's row could stop an unrelated local recording. Foreign rows are
  now refused.
- Codex: the In progress chip counted active recordings while its filter
  deliberately hid them, so clicking it showed "no matches". Active
  recordings now belong to that filter — a chip whose count disagrees with
  its page is a lie.

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

* refactor(downloads): drop the enrichment barrier instead of tuning it

Three review rounds circled the same class: synchronizing mpv's asynchronous
stop acknowledgement with a one-shot program enrichment. Each fix moved the
deadline (5 s → fallback+1 s → wait-on-the-write) without removing the reason
a deadline existed at all — the handler insisted on a *terminal* row.

It never needed one. `openSync('wx')` makes the reserved path exclusive while
a recording owns it, so the newest row for that path IS the recording that was
stopped, and `finalize()` writes only status/end time/size and never
`programs_json`. Enrichment and finalization are therefore order-independent:

- `RECORDINGS_UPDATE_PROGRAMS` matches the newest row for the path in any
  status and awaits only the tracker's write queue, which exists solely to
  guarantee the INSERT committed (a recording stopped milliseconds after it
  started).
- `whenFinalized`, its deadline constant, and the per-entry finalized promise
  are gone; the tracker keeps only the settle window and fallback that make
  *finalization* itself correct.

No behavior is lost and the whole timing class disappears with the code.

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

* fix(downloads): bind recording finalization to its entry and shield live rows from startup repair

Two races from the Codex review:

- Tracker timers finalized by reusable session id, so a stop followed by an
  immediate restart on the same session let the old settle timer finalize
  the NEW row (marked completed while mpv kept writing) and strand the old
  row in 'recording'. Finalization is now bound to the exact open entry,
  and replacing a session's entry arms the old entry's settle timer so an
  unobserved stop still finalizes it.

- reconcileStaleRecordings() runs after the renderer is interactive; a
  recording started during bootstrap has ownerPid === process.pid and was
  repaired to interrupted/failed mid-write. Recovery now skips rows the
  tracker reports as actively tracked (activeRowIds()).

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

* fix(downloads): harden recording startup repair against recycled pids and stale renderer lists

Second Codex pass on the recovery path:

- A live ownerPid alone no longer shields a row: after a crash the OS can
  recycle the pid for an unrelated process, which would park the row in
  'recording' with no instance able to finalize it. Recovery now also
  checks (best-effort, ps/tasklist) that the process looks like an
  IPTVnator/Electron instance; an unreadable name stays conservative and
  keeps the skip.

- The renderer loads before the repair pass runs and may already hold the
  pre-repair list with a stale Stop affordance; recovery now broadcasts
  one RECORDINGS_UPDATE_EVENT after changing any rows.

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

* fix(downloads): defer teardown finalization behind the flush window and bound the live-size stat

Third Codex pass:

- A synthetic error/closed snapshot from disposeSession() arrives while
  the frame-copy helper may still be flushing (0.5 s quit grace + 2 s
  SIGTERM grace before SIGKILL). Finalizing there statted a file mid-write
  — short captures became terminal 'failed', longer rows persisted a
  truncated size, and startup recovery could repair neither. The tracker
  now defers that finalization behind a 2.5 s flush window; the row stays
  'recording' (repairable) meanwhile, and an already-acknowledged stop's
  settle timer keeps its 'completed' verdict instead of being relabelled
  'interrupted'.

- The active row's live file size used a bare await stat(): one stat
  hanging on a dead network filesystem wedged every RECORDINGS_GET_LIST.
  The probe now mirrors the availability probe's contract — in-flight
  coalescing plus a 1 s deadline degrading to no size.

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

* fix(downloads): unmask recycled recording owners, guard the PWA recording route, and unblock file probes

Fourth Codex pass:

- Recycled-pid discrimination no longer stops at the process-name family
  check (any Electron app could shield the row): a live holder must also
  not provably have started after the recording did (ps -o etime= /
  PowerShell StartTime). A pid frees only when its previous owner dies, so
  a recycled pid's holder is always younger than the recording; unreadable
  evidence stays conservative.

- /workspace/downloads/recording/:recordingId gets a supportsRecordings
  capability guard redirecting the PWA to the manager — RecordingsService
  never becomes authoritative there, so the detail rendered a permanently
  blank workspace.

- Finalization and startup repair stat through a bounded async probe (3 s
  deadline, ENOENT/ENOTDIR as the only proof of absence) instead of
  main-thread statSync: a dead network mount no longer freezes the main
  thread or the tracker queue, repair leaves unjudgeable rows recoverable,
  and finalization keeps the requested status with an unknown size rather
  than branding a likely-good file failed. The 0-byte reservation unlink
  is fire-and-forget for the same reason.

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

* fix(downloads): keep inconclusive recording probes out of Needs attention and bound repair batches

Fifth Codex pass:

- Recording list decoration now uses the bounded availability variant that
  preserves 'unknown': a timed-out or permission-errored probe is not
  proof of absence, so a good recording on a slow mount no longer lands in
  Needs attention with its Play/Reveal hidden.
  ElectronRecordingItem.fileAvailability widens accordingly; consumers
  already gate on === 'missing'.

- Startup repair probes its whole batch concurrently, so main.ts awaits
  roughly one 3 s deadline instead of one per stale row.

Cross-process ping propagation under IPTVNATOR_ALLOW_MULTIPLE_INSTANCES
stays out of scope (debug-only flag, same single-window design as
DOWNLOADS_UPDATE_EVENT) — rationale left on the review thread.

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

* fix(downloads): fix duration rounding at hour boundaries and bound owner-process probes

Sixth Codex pass:

- The recording duration formatter rounded minutes after flooring hours,
  so 59:45 read '60 min' and 1:59:45 read '1 h 60 min'. One shared
  recordingDurationLabel() now rounds the total minutes before splitting
  (both the detail page and the library card used a duplicated copy).

- Startup repair's synchronous ps/tasklist/PowerShell ownership probes get
  a 2 s spawn timeout and are memoized per unique pid, so a batch of rows
  from one crashed instance costs at most one name query and one
  start-time query, and a hung process query degrades to the conservative
  fallback instead of blocking the main thread.

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

* fix(downloads): return to the manager through history from the recording detail

Seventh Codex pass (single finding): with a validated returnUrl the manager
is already the previous history entry, so Back now uses Location.back()
instead of pushing a third entry that made the browser Back button reopen
the detail; router navigation remains the fallback for direct links —
matching the offline-detail navigation.

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

* fix(downloads): bound removal cleanup and shell gates, date interrupted rows by file mtime

Eighth Codex pass:

- RECORDINGS_REMOVE no longer awaits an unbounded unlink of a failed
  row's leftover reservation: cleanup is raced against the 1 s deadline,
  so a hung network unlink cannot keep the Remove action busy — the row
  deletion is what matters.

- Reveal/Play swap the synchronous lstat gate for the bounded async
  availability probe: a dead mount no longer blocks the main process, and
  only PROVEN absence refuses the action — an inconclusive probe lets the
  shell try and answer honestly.

- Startup repair dates an interrupted row's endedAt from the captured
  file's mtime (mpv's last write) instead of the repair time, so an
  overnight shutdown no longer inflates a five-minute capture into an
  hours-long recording; the repair-time fallback remains when mtime is
  unreadable.

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

* fix(downloads): keep recording-start program metadata fresh across EPG boundaries

Ninth Codex pass (single finding): the unified live tab's
recordingMetadata computed cached its Date.now() verdict — starting a
recording after an EPG boundary snapshotted the previous show. It now
tracks the existing 30 s progress tick. The Stalker live layout's
currentProgram had the same memoization (feeding recording metadata, the
EPG panel summary, and external-player metadata); it gains a 30 s clock
tick with interval cleanup in ngOnDestroy.

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

* fix(downloads): re-select the Xtream current program against the 30 s tick at recording start

Tenth Codex pass (single finding): the Xtream live layout's recording
snapshot read withEpg().currentEpgItem, a computed whose Date.now()
verdict stays cached until epgItems changes — a recording started after
an EPG boundary snapshotted the previous show. The selection logic is
extracted as the pure findCurrentEpgItem(items, nowMs), the store
computed delegates to it unchanged, and recordingMetadata re-selects
with the layout's existing 30 s currentTimeMs tick.

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

* fix(downloads): scope stop enrichment to the exact recorded list item

Eleventh Codex pass (single finding): the stop-enrichment guard compared
only the EPG key, which is not unique for M3U items — two list entries
sharing a tvgId (or the display-name fallback) could hand the first
item's recording the second item's schedule after a switch-triggered
auto-stop. RecordingStartMetadata/RecordingStoppedEvent gain an opaque
sourceItemKey (unified tab: item.uid; M3U player: channel.id), captured
while the recording is active exactly like the EPG key, carried through
the player's stop edge, and compared by the hosts before enriching.
Xtream/Stalker keys are already playlist+id-scoped and need no extra key.

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

* fix(downloads): derive the M3U start-snapshot program from the active channel's schedule

Twelfth Codex pass (single finding): the M3U recording snapshot read the
NgRx currentEpgProgram, which retains its last value across a channel
switch and through EPG gaps (the mirror effect only dispatches when a
program exists) — a recording started on a channel with no airing
program could persist the previous channel's title, which stop
enrichment deliberately never overwrites. The snapshot now derives the
program from the active channel's own schedule against the existing 30 s
clock, and an EPG gap snapshots no program.

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

* fix(downloads): keep finalizing rows in the recovery ledger and guard the repair update

Thirteenth Codex pass (single finding): finalize() removes an entry from
the open map before its queued terminal update commits, so
activeRowIds() briefly omitted a row still persisted as 'recording' —
startup recovery overlapping a clean stop could relabel it interrupted,
after which the tracker's status-guarded update could not restore
'completed'. Finalizing entries now stay in a dedicated ledger until the
update settles, and the repair UPDATE itself is guarded on
status='recording' as a second belt against a finalization that commits
between recovery's SELECT and its write.

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

* fix(downloads): register update listeners before the initial list load

Fourteenth Codex pass (single finding): RecordingsService awaited its
initial RECORDINGS_GET_LIST before subscribing to the update ping — a
recording transition during that request pinged into the void while the
response still reflected the pre-transition state, and recording pings
are rare enough that nothing self-healed until the 15 s poll (armed only
once an active row is visible). The listener now registers first so the
load-state coalescing queues the trailing refresh. DownloadsService had
the same latent window and gets the same reorder.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 4gray <fourgray@proton.me>
2026-08-23 08:05:25 +02:00
4grayandClaude Fable 5 00aa623b83 fix(downloads): reconnect interrupted transfers and resume without validators (#1446)
* fix(downloads): reconnect interrupted transfers and resume without validators

Xtream panels commonly kill each VOD connection after a byte/time burst
(~130-260 MB) and send no ETag/Last-Modified. The validator-only resume
path then deleted the partial and surfaced a raw "aborted" failure, so
every Retry restarted from zero and large files could never finish.

- Resume without a validator through overlap verification: the Range
  request rewinds by 256 KiB and the replayed window must match the
  partial's tail byte-for-byte before anything is appended; a mismatch
  truncates the partial and restarts from scratch (download-overlap.ts).
- Reconnect automatically on recoverable interruptions and clean short
  responses (download-reconnect.ts): progress >=64 KiB past the best
  attempt resets a 3-stall budget; request-phase failures during
  reconnects are converted into retained interruptions so automation can
  never delete a partial.
- Extract pure response-header helpers into
  download-resume-validation.ts to keep download-transfer.ts within the
  file-size guideline.

Verified against a real throttling portal: a 1.6 GB and a 3 GB movie
completed through 15 and 23 connection resets respectively.

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

* fix(downloads): address review findings on reconnect baseline and small partials

- Judge reconnect progress against the previous attempt instead of a
  high-water mark, so a transfer that legitimately restarted from byte
  zero mid-loop (overlap mismatch, ignored Range) is measured by its
  rebuilt file; at most two such regressions are tolerated per transfer
  to keep the loop structurally bounded (Greptile P1).
- Floor reported progress at the partial's retained size while
  appending, so a response that ends inside the overlap window can never
  move persisted progress backwards (Greptile P1).
- Verify partials smaller than the overlap window in full from byte zero
  and append, instead of rewriting the .part in place — an early-dying
  reconnect can now only grow the file (Codex P2).

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

* fix(downloads): keep tolerated regressions off the reconnect stall budget

A tolerated restart regression consumed a regression credit AND counted
as a stalled attempt (its negative delta is below the progress
threshold), so a legitimately rebuilt file that grows in sub-64 KiB
steps was failed one reconnect early. Regressions are now charged to
their own bounded budget only; the stall budget stays reserved for
attempts that genuinely fail to grow the file.

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

* fix(downloads): gate success and validator promotion on complete overlap verification

Round-3 review findings:

- Success is rejected while the overlap verifier has not consumed its
  entire window: a complete 206 that ends inside the window proves the
  remote entity shrank, so the transfer truncates and restarts from
  scratch instead of finalizing the old suffix as a completed file; an
  early-dying stream stays an ordinary retained interruption (Codex P1).
- A verify-append attempt promotes the response's ETag/Last-Modified
  only after the complete overlap matched — an unverified partial is
  never blessed with a validator the next resume would If-Range-append
  onto (Codex P1).
- A tolerated restart regression resets stalls accumulated against the
  discarded representation, so the rebuilt file starts with the full
  stall budget (Greptile P1).

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

* fix(downloads): carry the known total through total-less reconnect responses

A resumed response without a usable total (chunked, or an unsatisfiable
Content-Range) erased task.totalBytes, so a reset over that response
could no longer classify as a retained interruption and generic cleanup
deleted the verified partial. The previously learned total is now
carried forward for appending attempts; fresh and restarted transfers
still drop it, since it described a discarded file.

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

* fix(downloads): never fabricate a total for an unverified retained partial

A retained failure with an unknown total persisted
totalBytes = bytesDownloaded, so after stalled reconnects over a
chunked, validator-less response that kept ending inside the overlap
window, Retry's completed-partial shortcut saw the .part size equal the
fabricated total and finalized the unverified partial without a request.
The fallback is now explicit per call site: only a finalization failure
after a complete transfer records its byte count as the total; retained
interruptions keep an unknown total unknown, forcing Retry to re-verify.

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

* fix(downloads): treat an unsatisfiable resume range as a representation change

A range-capable server whose entity shrank below the rewound overlap
offset answers 416 before any response body exists, which rejected the
request into the generic partial-deleting failure path. The 416 is now
recognized as a representation change: the partial is truncated and the
transfer restarts against the current entity from byte zero.

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

* fix(downloads): treat an indeterminate Content-Range total as unknown

`Content-Range: bytes 200-299/*` fell through to the Content-Length
fallback, deriving a "total" equal to the end of the selected range —
a resumed response ending there was declared complete and the truncated
partial finalized. An indeterminate total now yields null, letting the
previously known total carry forward and classify the short response as
a retained truncation instead.

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

* fix(downloads): signal restarts explicitly and keep carried totals informational

Round-8 review findings, both fixed at the design level instead of
another byte-comparison patch:

- Restart epochs (Greptile P1): the transfer layer now reports every
  rewrite-from-zero via task.transferRestarts (overlap mismatch, shrunk
  entity, 416, ignored Range), and the reconnect loop opens a fresh
  progress epoch on that signal — clean stall budget, no baseline. Byte
  inference could not recognize a rebuild landing near the previous
  attempt's count; the explicit signal can. Two restarts are tolerated
  per transfer; an unsignalled regression is an ordinary stall.
- Authoritative vs informational totals (Codex P1): completion and
  truncation decisions now use only the response's own total or its
  advertised indeterminate range end (`bytes X-Y/*` -> Y+1); a carried
  total is informational, is dropped once the bytes on disk falsify it,
  and can flag a short transfer but never authorize finalization. A
  mid-reset 206 retains the partial even with a falsified total — the
  response proved range capability — persisting the total as unknown.

Also raises the resume spec's jest timeout and tightens its polling:
the previous 5 s default flaked on starved CI runners.

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

* fix(downloads): keep unproven state fully uncommitted across the resume model

Round-9 review findings, closing the remaining commit-before-proof gaps:

- The response's total now stays uncommitted (task and row) until the
  complete overlap matched, exactly like the validator: a persisted
  total equal to the unverified partial's size let the completed-partial
  shortcut finalize unproven bytes after a pause, crash, or retained
  failure (Codex P1).
- Retained-interruption persistence syncs the live task with the row:
  a stale falsified total left in memory made the next reconnect's
  resume-offset guard reject the retained partial into generic cleanup
  (Codex P1).
- An observed 206 is remembered as task.serverAcceptsRanges, so a
  request-phase failure (no response at all) can retain an
  unknown-length partial on that evidence instead of deleting it
  (Codex P2).

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

* fix(downloads): treat a reset after the final ranged byte as completion

A 206 that delivers every advertised byte but ends in ECONNRESET instead
of a clean close was classified as an interruption with a falsified
total; the reconnect then resumed at EOF, collected a 416, and truncated
the complete file — an endpoint that always resets after its last byte
could never finish. A retainable failure whose on-disk bytes exactly
equal the response's own completion evidence (and the proven total, with
the overlap verified) now completes the transfer. Strict equality keeps
oversized partials on the generic-failure path.

Also splits the transfer error classes and retention classification
into download-transfer-errors.ts to stay under the max-lines rule.

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

* fix(downloads): never let an indeterminate range end authorize completion

Reaching Y of `Content-Range: bytes X-Y/*` proves the selected range
was delivered, not that the entity ends there — a range-capping server
resetting at its cap would have finalized a truncated movie as
complete. The reset-after-final-byte completion now requires the
response's authoritative total; indeterminate range ends keep flagging
short delivery but such resets stay retained interruptions.

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

* fix(downloads): retain any nonempty partial and keep indeterminate ranges incomplete

Round-12 findings, resolved by removing the last evidence requirements:

- Retention no longer needs a total, validator, or range proof: since
  overlap verification owns resume correctness, the next attempt can
  safely prove, resume, or restart over ANY retained partial — deleting
  bytes is the only unrecoverable outcome. This closes the whole family
  (refused reconnects, chunked responses, falsified totals, and
  reconstructed retry tasks losing the in-memory range flag) and removes
  task.serverAcceptsRanges entirely (Codex P2).
- A clean EOF at the advertised end of an indeterminate range
  (`bytes X-Y/*`) stays incomplete, matching the reset path: reaching Y
  proves the range was delivered, not that the entity ends there, so a
  range-capping server can no longer finalize a truncated movie; the
  shrunk-entity restart likewise requires an authoritative total
  (Codex P1). Responses with no range and no total keep the clean-EOF
  completion contract of unknown-length HTTP.

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

* fix(downloads): honor 416-confirmed completion and keep proven validators

Round-13 findings plus a CI limit:

- A 416 whose `Content-Range: bytes */N` equals the partial's size
  confirms the file IS the complete entity (under If-Range a validator
  mismatch yields 200, so the 416 also confirms identity): finalize it
  instead of truncating and redownloading forever (Codex P1).
- A validator proven by a complete overlap match is now promoted on the
  error path too, and retained-failure/pause persistence write
  resume_validator from the task — later attempts resume via If-Range
  instead of replaying the 256 KiB window, which stalled out servers
  whose per-connection cap barely exceeds the window (Codex P1).
- download-resume.spec.ts crossed the 1200-line test limit: the shared
  harness moves to download-resume.test-harness.ts and the
  overlap-family cases into download-overlap-resume.spec.ts; the runtime
  spec gets the same CI-load timeout headroom as the resume specs.

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

* fix(downloads): settle falsified totals on clean exits and probe EOF after zero-growth replays

Round-14 review findings plus the CI build break:

- download-resume.test-harness.ts was outside the tsconfig test-helpers
  exclude glob and broke every app typecheck/build; renamed to
  download-resume.test-helpers.ts (the excluded pattern).
- A clean indeterminate delivery that outgrows a stale carried total now
  settles that total to unknown on the row AND the live task before
  persisting or throwing — the reconnect's resume-offset guard would
  otherwise reject the retained partial into generic cleanup (Codex P1).
- A verified overlap replay of an indeterminate range that appends
  nothing arms a one-shot EOF probe: the next attempt requests the byte
  after the partial so a compliant 416 (bytes */N) can confirm the file
  is the complete entity, instead of repeating the rewind until the
  stall budget fails a finished download; a probe answered with more
  data is retired unappended and rewound verification resumes
  (Codex P1).

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

* fix(downloads): retain the partial on an inconclusive EOF-probe 416

A probe at the partial's exact end always collects a 416 when the
entity ends there, and the confirming Content-Range length is optional
— so a length-less 416 is equally consistent with a complete file, and
the unconditional restart redownloaded a likely finished movie every
cycle. The probe's 416 now restarts only when a stated total BELOW the
partial proves the entity shrank; otherwise the partial is retained.

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

* fix(downloads): treat any EOF-offset 416 as inconclusive, not just the probe

A validator-backed resume at the partial's exact end IS an EOF request:
when its 416 arrived without the optional Content-Range length, the
probingEof gate saw false and restartFromScratch truncated the complete
partial. The inconclusive-416 retention now keys on the request having
started at the partial's end (covering the probe and every If-Range
resume alike); a restart still requires a stated total below the
partial.

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

* fix(downloads): contradiction-proof carried totals and identity-gated 416 completion

Round-17 review findings:

- A carried total now falls the moment the response's advertised range
  end contradicts it, before any byte lands — waiting for the bytes to
  reach it left a pause/exit window where an N/N row let the
  completed-partial shortcut finalize a truncated file (Codex P1).
- The 416 completion shortcut now requires identity proof: an exact-EOF
  request backed by If-Range, or the EOF probe that follows a fully
  verified overlap replay. A bare length match on a rewound request
  proves nothing about whose bytes are on disk; a contradictory 416
  (stated total says the rewound range was satisfiable) retains instead
  of restarting (Codex P2).

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

* refactor(downloads): extract 416 classification to stay under max-lines

The previous commit pushed download-transfer.ts to 404 effective lines;
the 416 decision moves into classifyRangeNotSatisfiable() in
download-transfer-errors.ts with identical semantics, and the unused
re-export-only imports are dropped.

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

* fix(downloads): promote the proven total on the error path and fix the 416 contract doc

- The error-path promotion after a complete overlap match now updates
  task.totalBytes alongside the validator: a pause landing while the
  partial sits at a stale carried total otherwise persisted an N/N row
  that Resume's completed-partial shortcut would finalize (Codex P1).
- The download-manager contract doc's rewound-416 paragraph now matches
  classifyRangeNotSatisfiable(): completion requires identity proof at
  exact EOF, restart requires a proven shrink, everything ambiguous
  retains (Codex P1 on the doc).

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

* fix(downloads): correct both 416 classification boundaries

Round-19 review findings, one in each direction:

- A rewound 416 WITHOUT a stated length now retains: unsatisfiability
  alone never proves the entity shrank relative to the retained bytes,
  and the canonical contract reserves restart for stated proof
  (Codex P1).
- A stated total EQUAL to a rewound request's first byte now restarts:
  the entity ending exactly at the rewound offset makes the 416 valid
  and proves the partial extends past the entity — equality was being
  misread as a contradiction, stranding the download in retain forever
  (Codex P1).

classifyRangeNotSatisfiable() gains an exhaustive pure table spec
covering every branch.

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

* fix(downloads): restart on reset-ended responses that completed a shorter entity in-window

The clean-EOF path already restarted when a response delivered its
complete authoritative total inside the verification window, but a
reset arriving right after that final byte took the retention path and
stranded the oversized partial in a stall loop. The catch path now
mirrors the shrink detection: authoritative total reached inside an
unproven overlap restarts from scratch.

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

* fix(downloads): request identity encoding for byte-exact transfers

Axios's Node adapter transparently decodes gzip/brotli responses, which
would put decoded bytes on disk while Content-Length, Content-Range,
and every Range offset speak the encoded representation — desyncing
resume offsets and overlap verification on origins that compress.
Downloads now always send Accept-Encoding: identity.

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

* fix(downloads): drop carried totals an indeterminate range can exactly reach

`bytes 200-249/*` can deliver the partial exactly TO a carried total of
250; the <= guard kept that total, and a pause or exit anywhere in that
window persisted a 250/250 row the completed-partial shortcut would
finalize without EOF proof. The guard is now strict: a carried total
survives only when the advertised indeterminate range cannot reach it,
which also closes the mid-stream pause window.

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

* fix(downloads): arm the EOF probe after a reset-ended verified zero-growth replay

A verified overlap replay that reset right at the partial's end (zero
growth, indeterminate range, no validator) retained without arming the
EOF probe, so every retry replayed the same tail until the stall budget
expired — the clean-EOF path's probe arming now has its reset-path
mirror.

Also splits to stay under max-lines: the classifyRangeNotSatisfiable
table spec moves to download-transfer-errors.spec.ts and the DB persist
helpers to download-transfer-persistence.ts.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 07:07:25 +02:00
4grayandClaude Fable 5 7fc9380bff feat(portals): mark a full season as watched in one click (#1447)
* feat(portals): mark a full season as watched in one click

Series detail pages on both Xtream and Stalker portals get a season-level
watched toggle next to "Download season": marking writes full-progress
rows for the unwatched episodes only (real durations survive), a fully
watched season flips the action to unwatch-all.

Persistence goes through new batch IPC channels
(DB_SAVE/CLEAR_PLAYBACK_POSITIONS_BATCH, one SQLite transaction with
onConflictDoUpdate().run(); the PWA data source rewrites its
localStorage blob once). Stalker deliberately bypasses the batch IPC
and loops the existing position-mutation queue so legacy-row
reconciliation still runs and the queue coalesces to a single reload;
partial failures surface a dedicated snackbar.

Also removes the dead toggleEpisodeWatched store method, splits
season-container/serial-details-playback under the max-lines cap
(season-watch-toggle.util.ts, SerialDetailsSeasonWatchService), and
classifies *.spec-data.ts fixtures under the test max-lines ceiling
(baseline shrinks by main.preload.spec-data.ts).

Closes #1442

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

* fix(portals): guard stale season batches and split partial-unwatch feedback

Review follow-up (Codex on #1447):
- A season batch completing after the user navigated to another series
  or playlist no longer writes the old series' rows into the freshly
  reset position state (episode ids can collide across playlists); the
  Xtream host captures the playlist/series identity before awaiting and
  skips the rendered-state mutation when it changed. The DB write is
  unaffected — it carries its own playlistId.
- A partially failed "mark season as unwatched" on Stalker now reports
  a dedicated SEASON_MARKED_UNWATCHED_PARTIAL message instead of the
  watch-direction "marked" text; translated into all 18 locales.

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

* fix(portals): exclude the playing episode from season marking and count partial saves

Second review round (Codex on #1447):
- The episode currently playing (inline or in an external session, or
  with a launch in flight) is excluded from a season's mark-watched
  batch: the player persists its live position every ~15 s and would
  immediately overwrite the just-written full-progress row. The button
  count reflects the exclusion and the action disables when nothing is
  markable. Unmarking still clears such an episode — the recreated
  in-progress row reflects live playback truthfully.
- A Stalker StalkerSeriesPositionPartialSaveError (scoped watched row
  saved and published, only legacy cleanup failed) now counts as a
  watched success instead of feeding false total-failure feedback.

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

* fix(portals): gate stale season-batch snackbars on the originating page

Third review round (Codex on #1447): a batch resolving after the user
navigated away no longer shows its contextless success/error snackbar
on the newly opened detail page — the same ownership check that guards
the state mutation now guards the feedback too.

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

* fix(portals): sync catalog progress badges after toggles and gate Stalker feedback

Fourth review round (Codex on #1447):
- Any Xtream watched toggle (single episode or season batch) now
  refreshes XtreamStore.loadAllPositions after persisting — the catalog
  reads series-progress badges from the store, which otherwise loads
  positions once per playlist, so returning from the detail kept stale
  badges. Skipped when the playlist changed mid-flight (the store then
  belongs to the other playlist; its own init reloads positions).
- Stalker's season snackbars are gated on the captured playlist/series
  identity, matching the Xtream ownership guard — a batch draining after
  navigation no longer reports on the newly opened page.
- Stalker season-toggle specs moved to stalker-series-view.season-watch
  .spec.ts with their own harness; both prior spec files sat at the
  1200-line test ceiling.

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

* docs: describe the season watched toggle in CLAUDE.md

Fifth review round (Codex on #1447): the canonical Seasons entry in the
VOD/Series detail section now covers the bulk toggle, its playing-episode
exclusion, both persistence paths, catalog badge sync, and the
stale-completion contract.

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

* fix(portals): let only the latest positions load patch the Xtream store

Sixth review round (Codex on #1447): loadAllPositions is now
latest-load-wins — a fetch superseded while in flight (playlist switch
before getAllPlaybackPositions resolves) no longer patches the singleton
store with the previous playlist's position maps, which could leave the
new catalog showing the old playlist's progress badges.

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

* docs: reflect the spec-data max-lines classification in CLAUDE.md and AGENTS.md

Seventh review round (Codex on #1447): both canonical max-lines
descriptions now list **/*.spec-data.ts among the test-ceiling globs so
future agents neither treat these fixtures as production files nor
remove the exemption unknowingly.

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

* fix(portals): parse "N min" durations when marking episodes watched

Eighth review round (Codex on #1447): Stalker VOD episodes report
durations like "45 min", which parseDuration could not read — bulk (and
single) mark-watched then persisted 1/1-second rows. The minute format
now parses to seconds, matching what the removed legacy store method
already handled.

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

* fix(portals): parse compound hour durations and cover the toggle end-to-end

Ninth review round (Codex on #1447):
- parseDuration now reads the compound "1h 30min" form the Xtream
  fixtures emit (hour group optional, so "45 min" keeps working) —
  bulk-marked episodes no longer persist a minutes-only duration.
- New Playwright coverage exercises the season toggle through the real
  UI on both portals: Xtream (category → series detail → mark →
  reload-persistence → unmark) and Stalker (embedded-series flow,
  mark → unmark with the item's actual episode count).

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

* fix(portals): refresh Stalker catalog progress badges after watched toggles

Tenth review round (Codex on #1447): the Stalker mirror of the Xtream
catalog sync — StalkerCatalogFacadeService loads its position maps once
per playlist and the runtime bridge only pushes external-player updates,
so renderer-initiated toggles left grid badges stale. The series view
now calls the facade's new ownership-checked refreshPositions after the
season batch (including partial successes) and after single toggles;
the reload is latest-load-wins like the Xtream store fix. Optional
injection keeps collection-detail mounts outside the catalog working.

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

* test(portals): cover the season toggle batch IPC end-to-end in Electron

Eleventh review round (Codex on #1447): the new Electron E2E marks a
season through the real UI, asserts the eight SQLite rows written by
DB_SAVE_PLAYBACK_POSITIONS_BATCH directly through the preload bridge,
proves persistence with a full app relaunch (renderer and main process
die, so state can only come from the database file), and clears again
through DB_CLEAR_PLAYBACK_POSITIONS_BATCH back to zero rows.

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

* fix(dashboard): keep watched rows out of the series resume target

Twelfth review round (Codex on #1447): a watched position row — a
natural finish or a manual/bulk "mark watched" marker — is a completion
record, not resumable progress. Continue Watching no longer auto-plays
such an episode at its end; the handoff stays detail-only and the series
page's quick-start picks the first unwatched episode instead. Card
progress bars and SxxEyy badges keep their current source.

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

* fix(portals): fail closed on refresh reads and gate batch APIs by capability

Thirteenth review round (Codex on #1447):
- Position-cache refreshes now use a failure-propagating read
  (getAllPlaybackPositionsOrThrow through the Electron data source): a
  transient IPC failure rejects instead of masquerading as an empty
  list, so a populated store/facade cache stays stale-but-populated
  rather than being wiped. All load/refresh call sites handle the new
  rejection (init loads may retry on the next activation; post-toggle
  refreshes log and keep the snackbar flow).
- The season-batch bridge methods joined playbackPositionStorageMethods,
  so a bridge lacking them degrades to the in-memory path wholesale
  instead of throwing mid-action.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:03:14 +02:00
4grayandClaude Opus 5 e94cc029eb fix(portals): time out and fast-fail PWA proxy requests to dead hosts (#1424)
* refactor(portals): hoist the connectivity guard into libs/shared/host-health

The breaker was written dependency-free so both processes that talk to
portals could share it. Move the part that has no Electron in it — the
state machine, the failure classification, the redirect-attribution
helpers and the fast-fail error — into `@iptvnator/shared/host-health`
(`scope:shared` / `domain:shared-runtime` / `type:util`).

What stays in `apps/electron-backend` is the genuinely main-process part:
one guard for the whole process, so both portal IPC handlers see each
other's evidence, and the console warning that announces it. Every call
site is unchanged; the wrapper re-exports the two types they import.

The spec splits the same way — the state machine moves with the class,
the singleton and its redirect attribution stay with the wrapper.

Register the new project in the coverage policy, which every project with
a test target must declare a tier for.

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

* fix(portals): time out and fast-fail PWA proxy requests to dead hosts

The web backend's proxy routes were bare `axios.get()` calls with no
`timeout`, so a provider that accepted a connection and then went silent
held the request until the OS gave up on the TCP connection — minutes,
rather than the 15/30 s budget the Electron handlers use. Add the same
per-route timeouts (Xtream 30 s, Stalker 15 s / 30 s for `create_link`,
playlist and XMLTV 30 s).

Those numbers are safe for large downloads: on axios' default transport
`timeout` bounds the time to response headers and then continues as the
socket's inactivity timeout, so a multi-megabyte XMLTV file that keeps
delivering bytes is never cut off — only a stalled one is.

With requests bounded, run `/xtream` and `/stalker` through the shared
breaker, injected via `WebBackendAppOptions.hostGuard` so specs drive it
with a clock they own. Playlist and XMLTV downloads keep the timeout but
no breaker, matching Electron: a download is one request rather than a
catalog fan-out, it is usually the direct result of the user asking for
it, and it can outlive the half-open trial window.

The breaker is checked before the Xtream URL revalidation, which resolves
the hostname — a dead host is where DNS is slow too, and a request
admitted and then abandoned by the URL policy hands its token back rather
than holding the trial slot.

`resetHostConnectivityGuard()` no longer no-ops in the PWA: the breaker
lives in the backend process, so it travels to a new
`POST /connectivity-guard/reset`, which reads only the origin and never
logs the credential-bearing URL. `skipConnectionGuard` now survives the
PWA transport too, so Stalker endpoint discovery keeps the exemption it
has on the desktop instead of tripping the breaker with its own probes.

A fast-fail keeps each route's HTTP 200 `{message, status}` envelope. The
Stalker path needs one extra step: `forwardStalkerRequest` turns that
envelope into `HTTP Error <code>: …` with a numeric `status`, and the
renderer reads both as "the endpoint answered" — which would make
discovery walk every candidate and fire lazy repair at a host just
declared dead. A prior branch keyed on the shared
`isHostConnectivityFastFailMessage` rethrows it bare instead.

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

* fix(portals): report exempt Stalker probe responses to the PWA breaker

Flagged by the author of #1421 as one of the twelve fixes that landed
there after this branch cherry-picked the pre-review commit: an exempt
discovery probe must still REPORT, it just must not COUNT.

The web backend was skipping the report entirely for a probe, which loses
the case that matters. A failure carrying an HTTP response proves the
endpoint answered, and this route sets no `validateStatus`, so axios
rejects every non-2xx with `error.response` attached — a probe answered
with 404 or 500 was therefore dropped instead of clearing the record.
Two counted failures either side of it then read as consecutive and
opened the breaker in the middle of discovery, which is exactly what the
exemption exists to prevent.

`reportProviderRequestFailure` now takes `countFailures`, matching the
Electron reporter, and both routes always report.

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

* fix(portals): read the redirect hop from the transport that followed it

`failedAfterRedirect` decided whether a failure belonged to a redirect
destination by reading `error.config.url`. That is right for the Electron
transport, which sets `maxRedirects: 0` and reissues every hop as its own
request, so the hop IS the config URL. It is blind on the web backend,
which uses axios' default transport: follow-redirects walks the chain
inside one request and `config` is built once, so `config.url` stays the
URL we asked for.

Verified against the installed axios 1.19.0 with a live server that 302s
to a dead port:

    asked for          : http://127.0.0.1:63953/player_api.php
    config.url         : http://127.0.0.1:63953/player_api.php
    request._currentUrl: http://127.0.0.1:1/dead

So the comparison was original-vs-original, found no redirect, and
charged two dead destinations to the provider that had answered both
times with a 302 — then fast-failed it. Read `request._currentUrl` first
and fall back to `config.url`, which covers both transports; Electron's
native per-hop requests expose no `_currentUrl` and are unaffected.

Also check redirect attribution BEFORE suppressing failure counting for
an exempt probe. A 3xx from the guarded endpoint is an answer, so a probe
that observed one must clear the record; otherwise a timeout, a probe
redirected to a dead destination, and another timeout still read as two
consecutive failures.

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

* fix(portals): stop axios query params reading as a redirect

Codex found that the Xtream breaker never opened at all, and it was
right. The web backend passes credentials and the action through axios'
`params`, so axios sends `…/player_api.php?username=…&action=…` while the
baseline handed to `failedAfterRedirect` is the query-less URL the route
built. Verified against axios 1.19.0 with a plain ECONNREFUSED and no
redirect anywhere in sight:

    baseline           : http://127.0.0.1:1/player_api.php
    request._currentUrl: http://127.0.0.1:1/player_api.php?username=demo&…

The two normalized URLs differ, so every ordinary failure looked like a
post-redirect failure, credited the endpoint, and the breaker could never
trip.

Compare origin and path, not the whole URL. That keeps what the check is
for — an endpoint that answered and sent us elsewhere, including the
same-origin `/player_api.php` → `/slow/player_api.php` case — and gives
up only a redirect that changes nothing but the query, which is then
counted as an ordinary failure. Erring towards counting is the safe
direction here.

The reason 57 tests passed over a dead feature is the real lesson:
`StubHttpClient` threw bare `Error`s, so the guard's redirect check saw
neither `config.url` nor `request._currentUrl` and quietly did nothing.
The stub now shapes its rejections like axios does, including the query
axios appends. With that alone, four existing tests fail against the old
comparison.

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

* fix(portals): count a hostname that stops resolving, and fix a stale docblock

Two from review, one behavioural and one documentation.

A name that will not resolve is the host failing to answer — the same
evidence as the ENOTFOUND the transport would have raised a moment later.
But the SSRF validation turns a lookup failure into a 400 "host could not
be resolved", and the release path added earlier handed the token back as
inconclusive, so the breaker could never open for a host whose DNS died
and every request kept paying for the same dead lookup.

`ProviderUrlError` now carries the underlying lookup error internally.
A refusal that has one is counted; a genuine policy refusal — private
address, bad scheme, credentials in the URL — still only releases the
half-open slot, because that says nothing about reachability. The field
is internal: `providerUrlErrorBody()` strips it at both call sites, so
the client sees exactly the body it saw before, which the test asserts.

The docblock on `resetHostConnectivityGuard` still said the PWA channel
is unknown and the call no-ops. That stopped being true when this branch
implemented `CONNECTIVITY_GUARD_RESET` over HTTP, and a stale contract
there is how the next caller silently skips the PWA path. (The edit was
in an earlier commit and was lost when the branch was rebuilt on master.)

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

* docs(portals): record the transport-specific redirect contract

The redirect section still described one transport: hop-by-hop requests,
`error.config.url`, whole-URL comparison. Two of those three are now
wrong for the web backend, and this document is the canonical contract —
leaving it stale is how the attribution bugs fixed in the last two
commits get reintroduced.

Says what is actually true: which field holds the failed hop on each
transport and why the helper reads both, and that the comparison is
origin + path because the web backend's credentials ride in axios'
`params` and a whole-URL comparison therefore reported a redirect for
every ordinary failure.

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

* docs(portals): scope the guard summary to both processes

The opening line still defined the breaker as an Electron main-process
concern, which contradicted the ownership section below it and is the
part a reader skims to decide whether the document applies to them.

Names both processes, and records that the web backend had the worse
version of the problem first — no request timeout at all — since that is
why the timeouts and the breaker had to land there together.

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

* fix(portals): declare the shared-interfaces dependency of host-health

The new library's manifest listed only `tslib`, but its emitted JavaScript
does `require('@iptvnator/shared/interfaces')` — the guard builds its
fast-fail message with `buildHostConnectivityFastFailMessage`. Anything
resolving the built artifact from its own manifest would have failed with
MODULE_NOT_FOUND.

The manifest was copied from `shared/logging`, which imports nothing
across libraries and therefore needs nothing beyond `tslib`.
`shared/m3u-utils` is the right precedent: it imports the same library
and declares `"@iptvnator/shared/interfaces": "0.0.1"`.

Verified against the build output rather than by inspection — the emitted
`host-connectivity-guard.js` requires the module, and the generated
`dist/libs/shared/host-health/package.json` now declares it.

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

* fix(portals): bound the PWA connectivity-guard reset

The reset was a bare `fetch` with no timeout, which is the exact failure
this change exists to remove, reintroduced one layer up. Every caller
awaits the reset BEFORE issuing the request it is clearing the way for —
`retryContentInitialization` awaits it first by design — so a backend or
reverse proxy that accepts the POST and then goes quiet would leave
Retry doing nothing at all, for as long as the socket stayed open.

Bound it with an AbortController and a 5 s timer. The abort rejects,
`resetHostConnectivityGuard` swallows it as it already does for any
other failure, and the caller proceeds to its real request — which is
what "best effort" was supposed to mean. The timer is cleared in a
`finally`, and it covers the body read as well as the headers.

Five seconds because this talks to the user's own backend rather than a
provider: it should answer immediately, and a slow one must not hold up
the retry that asked for it.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:27:05 +02:00
4gray 61fca6f016 fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)
An Xtream activity row is built from its `content` row, and the catalog
endpoints that create those rows carry only a title and a poster. So the
dashboard hero and the recommendations rail rebuilt their TMDB query from the
display title alone, while the detail view had searched with the original
title, the release date and often a TMDB id. Without a year
`pickConfidentMatch` requires a globally unique exact title, which common
titles never satisfy — "Inside Out" matches several films and resolves to
nothing, every time.

Three `content` columns close that gap next to the existing `backdrop_url`:
`tmdb_id`, `release_year`, `original_title`. The detail views back-fill them
from what is on screen, the activity SELECTs project them, and
`buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same
facts through its stored entry, and rows with neither keep the title-only
fallback.

Measured against a real profile before building: of 58 distinct Xtream
movie/series activity rows, 16 (28%) produce a year-less key — the cohort
where a miss is guaranteed rather than likely.

Contracts worth preserving:

- Per-column, never overwrite. Enrichment supplies the pieces at different
  times, so a row-level guard would let the first arrival block every later
  one forever.
- `release_year` is the year the PROVIDER stated. The TMDB merge fills the
  date field when the provider left it empty, so it marks its own
  substitution with `tmdb_supplied_release_date` and the extractor skips
  those — making contamination structurally impossible rather than avoided.
- The id is stored unvetted: every consumer re-gates it through
  `assessProviderId`, which re-decides per lookup where a write-time verdict
  would be permanent.
- No media-type column — for Xtream the catalog files movies and series
  apart, so `content.type` already is the media type.

Worker requests now await `getDatabase()` before dispatching. The renderer
loads before `initDatabase()` and the worker opens the database file without
running migrations, so a query issued during startup on an upgraded install
could otherwise hit a schema whose new columns do not exist yet.

Not covered: the PWA, whose catalog cache is rebuilt from the API on every
load, so a stored id would never outlive the detail view that resolved it.
2026-08-13 18:51:26 +02:00
4gray 0cba49f3e2 fix(dashboard): keep every catalog row per title key when matching (#1425) 2026-08-13 07:31:59 +02:00
4gray e3f72f7dce perf(portals): fast-fail requests to portal hosts that stopped answering (#1421) 2026-08-13 07:31:23 +02:00
4grayandClaude Fable 5 5f4d219d3a refactor(portals): compact credential-free logging for failed Stalker requests (#1418)
* refactor(portals): log failed Stalker requests as compact credential-free summaries

Failed STALKER_REQUEST handlers dumped the entire axios error object
(config, request internals, agent state, stack) into the main-process
console — ~100 lines per dead-portal request. Extract the existing
compact Xtream error formatter into a shared portal-request-error util
(action, host, pathname, code, status, message — query string never
included) and use it from both handlers. The redundant pre-throw
"[StalkerEvents] HTTP Error" line is dropped; HTTP >=400 already
reaches the catch block and is now logged once, compactly.

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

* fix(portals): never retain an unparseable request URL in error logs

The URL guard in stalker.events.ts rejects credentialed portal URLs
before the request URL is built, so a malformed row value such as
"http://user:secret@" reaches the error formatter as-is. Its fallback
copied that raw string into `pathname`, and redactSensitiveData only
sanitizes userinfo of URLs it can PARSE — an unparseable one passes
through verbatim, password included.

Withhold the URL entirely in that branch. Verified: the added
regression test fails on the old fallback and passes with the marker.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:54:23 +02:00