Commit Graph
227 Commits
Author SHA1 Message Date
4grayandClaude Fable 5.1 4bd4bf2fc2 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>
2026-09-26 11:10:48 +02:00
4grayandClaude Fable 5.1 bfdfe18a9c 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>
2026-09-26 07:36:23 +02:00
4grayandClaude Fable 5.1 02f5b09201 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>
2026-09-26 06:21:18 +02:00
4grayandClaude Fable 5.1 559ec5508c 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>
2026-09-26 05:40:21 +02:00
4grayandClaude Fable 5.1 c114cd04d3 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>
2026-09-26 02:59:54 +02:00
4grayandClaude Fable 5.1 7d12d14259 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>
2026-09-25 22:31:39 +02:00
4grayandClaude Fable 5.1 ae5d3f49de Merge origin/master into claude/parental-control-feature-31dde2
Resolves the settings-store defaults split, the electron-conf key list and
the guidance reorganization (CLAUDE.md now imports AGENTS.md; the parental
lock contract is linked from the agent context map instead).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 22:12:58 +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
4grayandClaude Opus 5 b02d79805b fix(tmdb): a new series no longer matches its older, better-known namesake (#1648)
Metadata is looked up in the app's own language, so an unrelated older foreign
series can come back under exactly the same localized name as a recent
local-language one. `pickConfidentMatch` admitted the older row through the
series "premiered earlier" tolerance — portals report the running season's
year while TMDB reports the premiere — and then let `pickMostPopular` decide
across every admitted candidate, discarding the year evidence that had just
admitted them. The better-known show won on votes, and the newer series
rendered its poster, cast, genres and rating.

Rank admitted candidates by year evidence first (`yearEvidenceTier`: the
provider's exact year, then a year off by one, then the series tolerance) and
let popularity break ties only inside the strongest tier any candidate
reached. The tolerance stays — three of eight real lookups from one install
depend on it — but it is a last resort, not an equal. Measured over 400
Cyrillic series titles sampled from a real catalog, 20 normalized keys had a
same-titled older series and 16 of those were the more popular row.

The mirror case is accepted knowingly: a long-running show whose stated
season year happens to BE another same-titled show's premiere year now
resolves to the newer show. Only the older show's season air dates could
separate the two and a search response does not carry them, while that shape
needs three coincidences at once against one that needs none.

Search cache keys move to `|v4` with a matching startup cleanup, because a
positive row naming the wrong show stays fresh for 30 days.

Merged with `Build on windows x64` red: the checked-in Windows Embedded MPV
runtime pin points at an upstream release whose retention expired, so that
job fails repository-wide on a cold cache. Unrelated to this change; tracked
separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:18:56 +02:00
4grayandClaude Opus 5 4e575a810e feat(dashboard): say where you left off instead of which provider it came from (#1646)
Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.

The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.

Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.

Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.

Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:19:47 +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 7790e68147 feat(portal): show each season's own poster beside the season tabs (#1628)
Series detail pages now render the selected season's poster as a season
cover next to the season tabs and description, and the fullscreen episode
panel shows the same poster as a season strip above its tabs.

Resolution is TMDB-first, like the show artwork merge: the lazy season
enrichment stores `/tv/{id}/season/{n}` `poster_path` as a w342 URL in
`tmdb_season_posters` (Xtream) or `StalkerSeriesTmdbSeasonsService.posters()`
(Stalker), under the same write-only-if-changed convergence guard as the
season overview. Xtream falls back to the provider's `seasons[].cover_big`/
`cover` when it is an http(s) URL other than the show poster, because panels
repeat the show poster on every season. Stalker is TMDB-only.

The cover column is not rendered for one-season items, seasons without a
poster, or a failed image, so every fallback is today's markup. It is sized
by a new `--season-cover-width` token (96/120/144px per Settings.coverSize).
The hero poster never follows the season.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 11:45:54 +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 9d3266deb7 fix(tmdb): search TMDB with the provider spelling, not the folded key (#1626)
The title search sent the folded comparison key (NFD + strip marks + lowercase) as the TMDB query, so every Russian title with й/ё ("Фейк (10 серий)" → "феик") and every Arabic title with hamza missed and was cached as missing for 7 days. Search candidates now carry a provider-spelled wire query beside the folded comparison key; variants are deduplicated and cached per attempted variant by the lowercased query; the search lookup key moves to |v3 and startup deletes the retired |v2 rows under their own app_state marker. Verified on 1.99M live catalog titles (folded key byte-identical). Real-SQLite cleanup coverage runs inside Electron across skipped, previous, pre-person, fresh and repeated startups.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:44:41 +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
4gray 9a3aa63490 ci(nightly): set the electron-builder publish channel for nightly builds (#1611) 2026-09-16 07:50:50 +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
4grayandClaude Fable 5.1 ef3f98d026 feat(portals): posters-only cover wall for movie and series grids (#1604)
* feat(portals): posters-only cover wall for movie and series grids

Add `Settings.showCoverTitles` (Settings > General, default on). Turning it
off drops the title row under VOD/series covers in catalog, favorites and
recent grids and reveals the title as a bottom-gradient overlay on hover and
keyboard focus, pinned open for items whose cover is missing or failed.

`CoverTitlesService` is the single resolver: the opt-out AND a hover-capable
pointer, so touch-only devices keep their titles. Live channel grids, search
results, "recently added" rails and dashboard rails always keep labels.

Catalog and collection cards become keyboard buttons (role, tabindex,
aria-label, Enter/Space, focus ring) and poster alt text is the title. The
default-on boolean coercion moves into `settings-opt-out.util.ts` because
the settings store reached the max-lines limit.

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

* fix(portals): keep nested Remove key presses from activating the card

Enter/Space on the content card's nested Remove button bubbled into the
card's own key handlers: Enter opened the item before removing it and
Space opened it while cancelling the removal. Only keys pressed on the
card element itself now activate it. Regression spec added.

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

* fix(portals): keep cover titles while an in-section search filters the grid

The posters-only wall exempts search results because they are identified
by the name the user typed; the category grid's own in-section filter is
the same case, so `app-grid-list` now keeps the title row while its
`searchTerm` is non-blank. Contract docs updated, regression spec added.

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

* fix(portals): keep cover titles while the collection tab search is active

The unified favorites/recent tab filters by its own search term, so its
matches are identified by name like every other search result. The tab
now opts its cards out of the posters-only wall while the term is
non-blank. Contract docs updated, regression spec added.

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

* fix(portals): move the card Remove control out of the button surface

An interactive control nested inside a role="button" is an invalid
accessibility structure. The content card's activation surface is now its
own inner element and the Remove button a sibling positioned over the
poster corner, labelled by its tooltip text. Spec asserts the control is
never a descendant of the button.

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

* fix(portals): draw the collection card focus ring where it is not clipped

The card's overflow: hidden clipped an outline drawn on the inner
activation surface on every edge, so keyboard users saw no focus
indication. The ring now sits on the outer card via
:has(> .content-card__activation:focus-visible).

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

* fix(portals): detect any hover-capable pointer for the posters-only wall

`hover` describes only the primary pointer, so a touch-first tablet with
a mouse or hover-capable stylus attached lost the wall. The resolver now
reads `(any-hover: hover)`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 18:34:21 +02:00
4grayandClaude Fable 5.1 83e70a52c5 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>
2026-09-13 22:48:27 +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 a417826b01 fix(m3u): determine VOD playback independently of TMDB (#1594) 2026-09-12 22:47:54 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray fadcbb4673 test(database): guard upgrades across skipped releases (#1582)
* docs(database): require upgrades across skipped releases

* test(database): cover direct upgrades from 0.19 through 0.23

* test(database): verify upgraded schemas against current contract
2026-09-11 07:08:45 +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
4gray 93e759e1da fix(m3u): accept standard Base64 ClearKey values (#1575)
* fix(m3u): accept standard Base64 ClearKey values

* refactor(release): move M3U fixture generation out of capture driver
2026-09-08 08:59:00 +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 436825bdec fix(xtream): try advertised TS after initial web HLS HTTP failure (#1558)
* fix(xtream): try advertised TS after initial web HLS HTTP failure

* refactor(playback): extract fullscreen channel panel state

* test(xtream): keep synthetic media within the mock project
2026-09-06 11:00:09 +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 d9d6f49757 feat(playback): slide-in channel list for fullscreen playback (#1519) 2026-09-05 17:00:46 +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 1c56b3ea02 fix(portals): count concurrent connection failures once (#1537)
* fix(portals): count concurrent connection failures once

* docs(portals): clarify failure counting after guard eviction
2026-09-05 12:21:49 +02:00
4gray 0245d73d78 feat(portals): make connection cooldown configurable in desktop settings (#1536) 2026-09-05 11:18:05 +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 6cfdaf5900 feat(website): add the Stalker portal setup guide with mock-backed screenshots
Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the
portal URL shapes discovery accepts, MAC normalization, the optional
serial/device-ID/signature fields and the pinning rules behind the
"generate device IDs" toggle, what endpoint discovery does on Add, the
sections a portal source gets, Account info, and a troubleshooting list
built from the app's own refusal messages, plus a seven-question FAQ.
The guide is cross-linked from the download pages and llms.txt.

Guide screenshots come from the capture script. Shots that walk into a
Stalker portal start the stalker-mock-server and seed its marketing-demo
portal for that run only, so release shots never gain a third source
card. The frame guard allowlists exactly that scenario's MAC and keeps
rejecting every other MAC-shaped string.

To keep the live-TV frame free of third-party images, the fictional live
channel list and the channel-logo SVG renderer move into
@iptvnator/shared/marketing-fixtures; both mocks now serve
/assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario
builds its ITV categories, channels and schedule from those fixtures
instead of faker names with picsum logos, and the mock resolves asset
URLs on get_all_channels too, which is the response the app renders
the channel list from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 07:20:56 +02:00
4grayandClaude Fable 5.1 fa9084fca3 feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized /
fullscreen), Electron only, mirrored into the main-process config by
SETTINGS_UPDATE and applied at the next window creation. `--fullscreen`
forces one fullscreen launch (consumed by the first window). F11 toggles
OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on
Windows/Linux where the title bar is hidden — and is skipped while the
player owns document.fullscreenElement.

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

Closes #1455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:49:41 +02:00
4gray 740b784268 feat(playback): make the shared player controls the default (#1408) (#1485) 2026-08-29 21:24:44 +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
242640e8c6 chore(deps): bump ngx-indexed-db from 21.0.0 to 22.0.0 (#1472)
Coordinated replacement for the Dependabot branch: the bot updated only the
root package.json, leaving the ^21 specifier in libs/shared/interfaces, which
failed the @nx/dependency-checks lint rule.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 02:26:02 +02:00