Commit Graph
100 Commits
Author SHA1 Message Date
4grayandClaude Fable 5.1 4bab307152 test(e2e): give the DASH collection round-trip its cold-load budget (#1632)
The "ClearKey reopens from recent and favorites collections" spec does
five cold `page.goto` loads of the dev-served app. On the CI runner each
one costs ~6 s, so the default 30 s test timeout expired on the last
route: every attempt in the affected runs ended as `timedOut`, and the
retry trace's final screencast frame shows `/workspace/global-favorites`
still on the startup screen at 29.7 s. The two reported "shapes" were
just where the clock ran out.

Size the test like the other multi-load specs (`test.setTimeout(90_000)`)
and assert the "All playlists" radio is checked before waiting for a row
only that scope can show, so a lost click fails on the toggle instead of
surfacing as a missing row.

Closes #1630

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:47:51 +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 43c1ceac16 test(web-e2e): reach row buttons with Option+Tab on WebKit (#1629)
The two "channel scrolling keeps focus after selection" cases (and the
Xtream "channel focus and separate scrollbar" cases, which press Tab the
same way) failed deterministically on Playwright WebKit while passing on
Chromium and Firefox. Playwright's WebKit emulates Safari's default
keyboard preference, under which plain Tab visits only text fields and
links: from the focused channel pane the key landed on the sidebar search
field instead of the first row button. Option+Tab reaches the button and
then the favorite action in the same DOM order Chromium's Tab follows, so
the app's focus contract (ChannelScrollFocusDirective) is intact and this
is Safari's Tab semantics, not an app bug.

Add a `pressTab` E2E helper that presses Alt+Tab only on webkit and keeps
the literal Tab / Shift+Tab on chromium and firefox, use it at the four
Tab presses toward buttons, and note Safari's behaviour in the keyboard
scrolling contract.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:47:32 +02:00
4grayandClaude Fable 5.1 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 c76a901e8d fix(ui): keep one Back arrow on detail pages (#1627)
Movie and series detail pages showed two arrow_back controls while the
inline player was open: the shell's sticky arrow (added in #1576 so Back
survives scrolling) meant "Close player" in watch state, while the
now-playing bar carried a second arrow that meant route-level Back — the
same icon with two meanings, next to a "Close player" button that
duplicated the first.

The shell's sticky arrow is now route-level Back in browse and watch
alike, and the bar carries no arrow of its own. Closing the player is
the bar's "Close player" button and Escape, which still unwinds one
level (close, then back). Hosts without a browse Back target (M3U,
downloads) render no arrow in either state.

Unit specs for the shell and the inline player cover the new contract;
the Electron and web E2E helpers that pressed Back from watch are
updated, and the M3U flow closes the player through the bar's button.
Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note
follow the change.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:44:17 +02:00
4grayandClaude Fable 5.1 5273e5fb55 test(website): wait for the copy button label before reading the clipboard (#1624)
The home-sections browser test clicked `.copy-btn` and immediately asserted
`textContent() === 'Copied'`. The button flips its label only after the
asynchronous `navigator.clipboard.writeText` promise resolves, which is after
Playwright's `click()` has already returned, so a loaded CI runner sometimes
still read "Copy" (PR #1619, run 35369544094).

Poll for the label with Playwright's `expect(locator).toHaveText` (bounded
5 s) before reading the clipboard. The import is dynamic and sits after
`launchBrowser()`, so the local no-Chromium skip path is unchanged.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 09:23:12 +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
4grayandClaude Fable 5.1 fa8ce26991 feat(playback): fullscreen episode panel for series playback (#1620)
Series playing in fullscreen get the same slide-in side panel the live channel list has, with season tabs and the episode list: rest the mouse on the left edge, click it, or press C; pick an episode and it plays inline without leaving fullscreen.

- Panel contract: `FullscreenChannelPanelHost` gains optional `panelSearchEnabled` and `panelKind`; the template context gains `open`. Pointer/keyboard rules and the four live providers are unchanged.
- Series host: `PortalInlinePlayerComponent` provides the token through `createEpisodePanelHost()` and stamps `app-fullscreen-episode-panel` (SeasonTabsComponent over rows with TMDB still or numeral tile, label, runtime, clamped overview, progress, watched check, now-playing marker; playing row centred on open). Episode clicks reuse the Up Next rail's inline path; season tab clicks reach the hosts' `onSeasonSelected` (Xtream TMDB season enrichment, Stalker lazy VOD load with a Retry row after a failed request).
- Gates: `Settings.fullscreenChannelPanel` (label now covers both lists in all locales), episode content only, native-view Embedded MPV withheld by the view, external players excluded.
- Inline-series e2e moved to `xtream-series-playback.e2e.ts` with shared Xtream helpers in a fixture; adds a fullscreen episode switch through the panel.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:56:20 +02:00
4grayandClaude Fable 5.1 c110519412 fix(playback): make the fullscreen channel panel discoverable and calmer to close (#1619)
* fix(playback): make the fullscreen channel panel discoverable and calmer to close

The left-edge hot zone was an invisible 28px strip with nothing telling the
user where the list lived, so "hovering the left side" rarely reached it while
`C` always worked. Mouse movement over the stage now reveals a slim edge hint
tab (CSS chevron, fades after 2.5 s idle, lit while the pointer rests in the
zone), the zone grows to 40px (48px coarse), and a click or tap on the edge
opens at once without the dwell.

Closing follows the pointer more honestly: the mouse-leave grace grows from
420 ms to 1 s, and it applies only once the pointer has engaged with the
panel, so a `C`-opened list no longer closes while the mouse merely roams over
the video under the user's typing. Clicks inside the panel never close it.

Found on the way: closing with Escape while the mouse still rests on the edge
reopened the panel 160 ms later, because Chromium synthesizes a `pointerenter`
on the hot zone the aside slid away from. An explicit close now re-arms the
zone only on the next real pointer move.

Verified live over CDP on HTML5, Video.js, ArtPlayer and Embedded MPV
frame-copy; the hover mechanism itself was sound on every engine.

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

* fix(playback): open the panel only on a primary click begun on the edge

Review follow-up. The hot zone opened on every pointerup, so a drag released
over the edge, a right or middle click and a pen barrel button all opened the
panel; now pointerdown records the primary pointer and pointerup must match
it (a leave or cancel forgets the press). The synthetic pointerenter that
follows an explicit close no longer arms the hint either: the edge stays
clear until the next real pointer move, which arms it and starts the dwell.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 21:12:00 +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
4gray d3dee05a84 ci(nightly): run the version step in bash on Windows (#1609) 2026-09-15 20:47:55 +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 7f724494b9 feat(portal): mark movies as watched from the detail page (#1605) 2026-09-14 21:05:19 +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
4gray 40092f6efc docs(tmdb): clarify user-provided API key policy (#1581) 2026-09-13 21:25:42 +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 d9c8ba4d0b fix(epg): keep the import panel free of horizontal scrolling (#1599) 2026-09-13 17:25:53 +02:00
4gray 17b8aa309d fix(m3u): restore DASH playback from favorites and recently viewed (#1597) 2026-09-13 11:17:55 +02:00
4gray bd848aaad6 feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) 2026-09-13 10:39:14 +02:00
4gray cbc918b096 test(electron): synchronize source refresh completion (#1595) 2026-09-13 00:03:35 +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 6e3f0d258f fix: prevent fullscreen hover and playlist refresh races (#1591) 2026-09-12 21:11:11 +02:00
4gray 7d1265d566 fix(xtream): detect HTTP portals during explicit connection tests (#1588) 2026-09-12 15:30:22 +02:00
4gray 0700ba966e fix(epg): decode gzip files wrapped in HTTP gzip (#1589) 2026-09-12 14:23:59 +02:00
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
4gray fff022afe4 fix(ui): keep detail back navigation available while scrolling (#1576) 2026-09-10 21:34:10 +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
4gray c952b55da1 feat(playback): enrich failure diagnostics and add safe support reports (#1574) 2026-09-08 08:26:26 +02:00
4gray 7f06690e72 feat(epg): copy catch-up programme URLs (#1569)
* feat(epg): copy catch-up programme URLs without changing playback

* fix(epg): let newer archive copy requests supersede pending work
2026-09-08 07:29:11 +02:00
4gray 9f950a1530 fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures

* fix(migration): keep inventory reload failures recoverable

* test(migration): type the deferred recovery hook

* fix(migration): reconcile failed EPG cleanup before recovery

* fix(migration): await settings before inventory readiness

* style(migration): add a subtle theme-aware startup watermark
2026-09-07 21:24:34 +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
4gray 15c2ac1f39 feat(packaging): add AppManager discovery metadata to AppImages (#1559)
* feat(packaging): add AppManager discovery metadata to AppImages

* test(xtream): restore live queue URL service mock

* test(xtream): extract live layout component stubs
2026-09-06 18:47:38 +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
4grayandClaude Fable 5.1 0a2373f192 feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)
## Summary

Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:

1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.

Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.

## Design notes

- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.

## Merged with #1555 (per-surface rail state)

#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.

## Also fixed along the way

- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).



Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:42:59 +02:00
4grayandClaude Fable 5.1 0dcfba7045 fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface

The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.

- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
  collection) under live-sidebar-state:<surface>; the M3U player now goes
  through the service instead of its own signal. The legacy shared key is
  forgotten on startup and never read, so the update itself restores the
  list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
  renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
  the control exists in both states instead of disappearing with the rail.
  Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
  app-channel-list-hidden-state (title, shortcut hint, full-size "Show
  channels list" button) instead of asking to pick from a list that is not
  on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.

Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.

Refs #1458

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

* test(live-tv): mirror the EPG offset setting in the sidebar spec mock

Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.

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

* test(web-e2e): scope the Stalker radio rail toggles to the rail

The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.

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

* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

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

* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API

master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:48:58 +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
4grayandClaude Fable 5.1 ef2418a2c5 feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom

Replace the 96px zoom range slider with a single icon button that cycles
day overview → by hour → detailed, make the Now button icon-only (label in
tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the
ribbon (preventDefault keeps Chromium from page-zooming the same gesture).
Zoom math moves into TimelineZoomController so the component stays under
the max-lines limit. The channel/programme heading no longer carries a 60%
width cap and takes the space the controls free up.

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

* fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic

- TimelineZoomController keeps a logical pending scrollLeft across a burst
  of wheel/pinch events and applies it in one animation frame, so every
  event anchors on the same minute instead of the stale DOM position
- the e2e settles on the detail preset from whichever band the wheel
  landed in (contrast checks need the block's time line, hidden at
  narrower tiers)
- zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 10:34:28 +02:00
4gray 61b06b9f31 fix(portals): preserve live channel navigation while browsing (#1554)
* fix(portals): preserve live channel navigation while browsing

* test(portals): await media source assertion in remote E2E

* fix(xtream): capture destination queue for live auto-open
2026-09-06 10:22:07 +02:00
4gray 5a8c5ca4a4 fix(stalker): keep live search within the selected category (#1552)
* fix(stalker): keep live search within the selected category

* test(stalker): assert retained video ownership without source timing

* test(stalker): distinguish paged All Items from the initial cache grid

* fix(stalker): reveal remote selections in uncached search results

* test(stalker): wait for category rows and retain settled playback
2026-09-06 09:05:21 +02:00
4gray 5febe28eba fix(web-backend): validate and pin provider redirect hops (#1553)
* fix(web-backend): validate and pin every provider redirect hop

* fix(web-backend): separate provider metadata from connection authority
2026-09-06 08:59:28 +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
4grayandClaude Fable 5.1 baba0529ef feat(website): rebuild the hero, features, download and support sections (#1551)
* feat(website): rebuild the hero, features, download and support sections

Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.

- Hero: left-aligned headline, a primary button that follows the
  visitor's OS (server-rendered fallback goes to /download/), an "All
  platforms" button and a text link for self-hosting. Project numbers
  (stars, downloads, languages, license) are set in the page's own
  typography from the GitHub API at build time (`lib/github-stats.ts`);
  anything the build cannot resolve is left out rather than faked. A
  small "Now playing" card with fictional demo content replaces the
  mascot, badges and social chips; the screenshot is lit by a blurred
  copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
  posters with a resume bar, a download queue with a REC dot, a remote
  D-pad) instead of numbered cards with icon watermarks and a marquee.
  The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
  "Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
  package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
  which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
  unused broadcast-wave, support-signal and watermark illustrations are
  removed.

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

* test(website): cover the rebuilt home sections and the OS-aware download CTA

`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.

`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.

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

* fix(website): keep iPads on the generic download path

iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:46 +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 002faafbd7 fix(xtream): improve sync overlay contrast in both themes (#1546) 2026-09-06 00:44:08 +02:00
4gray e40f31db97 fix(host-health): retain trial ownership until requests settle (#1547) 2026-09-06 00:43:54 +02:00
4gray 0140146716 fix(ui): restore detail surface boundaries in light theme (#1549)
* fix(ui): restore detail surface boundaries in light theme

* test(ui): measure detail action edges over rendered artwork
2026-09-06 00:41:53 +02:00
4grayandClaude Fable 5.1 eb96b4c9f2 feat(website): turn the screenshot showcase into a channel switcher (#1540)
* feat(website): turn the screenshot showcase into a channel switcher

Second landing redesign PR. "See it in action" was a tab strip in a
dashed frame showing one screenshot inside a drawn window chrome that
duplicated the chrome already in the shot. It is now a channel list:
number, screen name and one line about what the screen is for on the
left, a single frame on the right, and a caption linking to the matching
feature page.

- Channels advance on their own with a progress hairline under the
  active row; hovering, focusing or scrolling the block out of view
  pauses it and `prefers-reduced-motion` disables autoplay entirely.
- A brief on-screen "CH 03" badge confirms every switch.
- Arrow keys, Home and End move between channels; the list is a proper
  vertical tablist with roving tabindex, panels carry `aria-hidden`.
- Six screens: Dashboard, Live TV, Program guide, Movies & series,
  Downloads, Settings. Same files from `public/screenshots`; the
  add-playlist shot is replaced by the movie detail and the download
  manager.
- On phones the screen comes first and the list follows.

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

* test(website): cover the channel switcher, keep focus pausing after mouseleave

Hover and focus now pause autoplay independently: clicking a channel
focused it, but moving the pointer away resumed the timer and seven
seconds later the selection moved under a still-focused tab.

New `website-screenshot-showcase.test.mjs` (in the website test target):
a structural half over the built HTML (vertical tablist, roving
tabindex, one aria-hidden panel per channel) and a browser half that
serves the build and drives it in Chromium — autoplay advances, hover
pauses, click + mouseleave keeps the pause while focused, arrow/Home/End
keys move selection, focus, panel, caption and badge together, blur
resumes, and `prefers-reduced-motion` disables autoplay. The browser
half falls back to the system Chrome channel and skips when no Chromium
exists, so the structural checks run everywhere.

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

* perf(website): lazy-load every showcase screenshot

The block sits below the hero and the feature grid, so an eager first
frame only competed with above-the-fold assets for visitors who never
scroll to it. Native look-ahead loading brings it in well before the
switcher is on screen.

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

* test(website): share the browser harness, fail in CI without Chromium

`website-browser-support.mjs` owns the loopback static server and the
Chromium launcher for the website browser tests. The server resolves
every request against the build root and answers 404 for anything that
escapes it (CodeQL js/path-injection on the previous inline copy). The
launcher tries the Playwright download, then the system Chrome and
Chromium channels; when none launches it returns null locally so the
structural half still runs, and throws under `CI` so the interaction
assertions can never turn into a silent skip on the runner.

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

* perf(website): defer inactive showcase frames, document the browser tests

The six frames are stacked with opacity, so native lazy loading treated
all of them as near-viewport and fetched every screenshot at once. Only
the first frame now ships with a `src`; the switcher assigns it from
`data-src` when a channel is shown and preloads the one after it, so at
most two frames are ever in flight. The showcase test asserts the
markup and the runtime behaviour.

The website README now describes the browser-dependent suites, the
shared harness, and the skip-locally / fail-in-CI rule.

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

* docs(website): describe only the suite this PR adds

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

* fix(website): resume the switcher from where it paused, keep DOM order on phones

The dwell clock now stops while the switcher is hovered or focused and
the pause time is added back on resume, so the progress hairline
continues from its frozen position instead of snapping to zero and
granting a fresh seven seconds. The test asserts the resume.

On phones the list stays before the screen in the DOM and on screen
(tabs before their panels, focus order equals reading order); it hides
the per-channel descriptions and the keyboard hint there so the screen
stays close instead of being reordered with `order-first`.

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

* fix(website): never crop a showcase frame

The screen box stretches to the channel list's row height, and with
`object-cover` a wide screenshot lost its right side just above the
`lg` breakpoint. Frames are now contained on a dark stage (a letterbox,
as on a TV), and the per-channel descriptions are hidden between `lg`
and `xl` so the row stays close to the frame's own height.

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

* fix(website): pause the switcher dwell while offscreen instead of resetting it

Leaving the viewport is now a pause like hover and focus: the frame
loop stops, the progress hairline keeps its width, and the clock
resumes from the same mark when the block scrolls back in. Only a
channel change resets the dwell. The interaction test scrolls away and
back to assert it.

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

* perf(website): no next-frame prefetch when reduced motion disables autoplay

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

* feat(website): persistent pause control for the channel switcher

Hover and focus only pause while they last, which is no use to touch or
screen-reader visitors, so the list footer now carries a Pause/Resume
toggle (`aria-pressed`, full `aria-label`) that keeps autoplay stopped
until pressed again (WCAG 2.2.2). It is removed under reduced motion,
where nothing advances.

The interaction test now waits for the seven-second rollover and checks
that tab, panel, caption, badge and the preloaded frame all move to
channel 02, and that the toggle holds through mouseleave and blur.

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

* fix(website): keep the tablist to its tabs, no successor prefetch when autoplay is off

The channel list's header and the Pause/Resume control sat inside the
`role="tablist"` container; the tablist now wraps only the six tabs so
assistive technology reads the toggle as an ordinary button beside the
list. `show()` preloads the next frame only while autoplay can reach it
(neither reduced motion nor the toggle has stopped it). Tests assert
both.

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

* fix(website): preload the successor when auto-advance resumes

A channel picked while the switcher is paused deliberately skips its
successor; resuming now fetches that frame so the next automatic switch
does not land on a blank screen. The interaction test covers
pause → manual selection → resume.

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

* fix(website): Resume restarts autoplay at once; same-channel progress in the test

The transient hover/focus pauses now watch only the channel list and the
screen. The footer with the Pause/Resume control is not one of those
regions, so after pressing Resume — with the pointer and the focus still
on the button — autoplay visibly restarts instead of waiting for the
visitor to leave the whole block.

The interaction test compares progress within one channel (a paused
Movies before and after Resume) rather than across channels, which
could fail on a slow runner, and asserts that autoplay runs while the
toggle keeps focus and hover.

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

* perf(website): one scheduler for the switcher, no frames while paused

Every pause reason (hover, focus, the toggle, leaving the viewport) now
goes through a single `sync()`: the animation-frame loop runs only while
the block is visible and nothing pauses it, and is cancelled otherwise,
so a switcher left paused schedules no frames at all. The dwell clock
still resumes from where it froze, and the successor frame is fetched
only when the loop actually starts — so scrolling away and back under a
persistent pause loads nothing. Tests count scheduled frames while
paused and cover pause → manual selection → offscreen → return.

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

* fix(website): a channel picked while paused gets its full dwell after Resume

`show()` reset the pause mark, so the time a visitor spent paused after
picking a channel counted toward that channel's dwell and Resume could
advance immediately. When the loop is not running the new channel now
starts out paused at that moment. The interaction test asserts the
progress is still near zero right after Resume.

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

* fix(website): follow reduced-motion changes and hidden tabs in the switcher

The reduced-motion preference is read from a live MediaQueryList: when
it changes while the page is open the scheduler stops or restarts and
the Pause/Resume control is hidden or shown (it is hidden, not removed,
for that reason). A hidden document counts as a pause too — background
tabs throttle animation frames while the clock keeps running, so
without it the first frame back would skip a channel. The interaction
test flips both at runtime.

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

* fix(website): plain action button for the switcher pause, no manual animations under reduced motion

`aria-pressed` on a button whose name changes between "Pause
auto-advance" and "Resume auto-advance" announced the wrong thing; the
control is now an ordinary action button whose name says what pressing
it does next. The OSD slide and the panel fade get
`motion-reduce:transition-none`, so a manual selection under reduced
motion moves nothing.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:58:24 +02:00
4gray 249cd38a66 fix(stalker): align season markers and preserve episode loading (#1545) 2026-09-05 22:22:46 +02:00
4gray eb3cf89f62 fix(stalker): let media transports control seek byte ranges (#1544) 2026-09-05 20:51:32 +02:00
4gray d9d6f49757 feat(playback): slide-in channel list for fullscreen playback (#1519) 2026-09-05 17:00:46 +02:00
4gray 8eda5370eb fix(playback): apply themes to player and EPG panels (#1541)
* fix(playback): apply themes to player and EPG panels

* test(playback): verify active recording icon theme

* fix(epg): keep loading shimmer visible in both themes
2026-09-05 15:32:22 +02:00
4gray eb602db5fc fix(ui): restore channel and detail keyboard scrolling (#1542)
* fix(ui): restore channel and detail keyboard scrolling

* test(ui): drag below the Windows scrollbar arrow
2026-09-05 15:02:57 +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 79f3f6c897 fix(playback): close legacy picture-in-picture on video replacement (#1538)
* fix(playback): close legacy picture-in-picture on video replacement

* test(playback): wait for the selected video before PiP setup

* test(playback): await changed settings before PiP navigation

* fix(playback): release legacy WebKit picture-in-picture
2026-09-05 14:02:55 +02:00
4gray eba68d687e fix(xtream): scope category bulk actions to search results (#1534) 2026-09-05 12:39:57 +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
4grayandClaude Fable 5.1 1cf17f7301 style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects (#1539)
* style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects

First of three landing redesign PRs. This one only touches tokens and
surface treatment so every page (home, blog, /features, /compare,
/download) benefits without any structural change:

- Replace every dashed border with a solid hairline; `.card-dotted`
  becomes `.card-panel` (bg step + hairline, lighter hairline on hover)
  and the section divider is solid `surface-800`.
- Swap the display face from Playfair Display to Bricolage Grotesque
  (DM Sans body and IBM Plex Mono unchanged). The italic accent word
  that every heading repeated is gone; only page-level h1s keep the
  accent, and as colour rather than italic.
- Remove the CRT/TV effects: the `.btn-tv` block and its nine keyframes,
  the scanline overlay on the hero screenshot, the CRT hover overlay and
  teal glow shadows on feature cards, the pulsing mascot, and the unused
  scan/tv-* Tailwind animations.
- The hero download button and the header Download link are now solid
  accent buttons; the self-host link is a plain text link.
- Blog tag chips use neutral surface borders so teal stays reserved for
  actions.

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

* style(website): finish the dashed sweep in prose and package rows

The blanket `border-dashed ` removal ate the utility but not the
variant prefix in the blog prose classes, leaving
`prose-pre:prose-pre:...` and `prose-hr:prose-hr:...`; and the
Linux package rows still used `divide-dashed`.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 12:11:56 +02:00
4gray 07ccf45f32 fix(playback): hide VLC RC console on Windows (#1533)
* fix(playback): hide VLC RC console on Windows

* fix(playback): preserve VLC retry arguments containing quiet flag
2026-09-05 11:33:05 +02:00
4gray 0245d73d78 feat(portals): make connection cooldown configurable in desktop settings (#1536) 2026-09-05 11:18:05 +02:00
4grayandClaude Fable 5.1 4d8559976c fix(dashboard): read cached EPG timestamps as unix seconds (#1532)
`epgTimestampMs` in the dashboard live-EPG helpers returned
`EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are
unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg`
multiplies by 1000). The bug was latent: the backend
`GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO
fallback always ran.

Scale a positive finite timestamp by 1000 and treat zero/non-finite
values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec
that feeds seconds-based timestamps and asserts the formatted range and
progress, and fix the rails component spec fixture, which stored
millisecond values in the seconds fields and so encoded the old bug.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:22:37 +02:00
4grayandClaude Fable 5.1 2f1ffe59f9 fix(embedded-mpv): disable the youtube-dl hook in the frame-copy helper (#1526)
The native-view addons and the external MPV launch path already run with
`ytdl=no`, but the frame-copy helper left mpv's default, so a refused HTTP
open fell through to ytdl_hook and yt-dlp before failing. That delayed the
`error` transition the auto-reconnect policy waits for and could leave the
session error reading "youtube-dl failed: unexpected error occurred"
instead of the load error.

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

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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:00:43 +02:00
4grayandClaude Opus 5 302afb237a chore(deps): close transitive CVE alerts via pnpm overrides (#1527)
Four open Dependabot alerts, all on transitive npm dependencies, so no
direct dependency changes:

- browserslist 4.28.1 -> 4.28.8 (GHSA-73wf-gq98-2v4g, high)
- @xmldom/xmldom 0.8.13 -> 0.8.15 (GHSA-6gmq-8vp8-gcm6)
- @humanfs/node 0.16.7 -> 0.16.8 (GHSA-p498-v437-472g)
- postcss-selector-parser 6.1.2 -> 6.1.4 (GHSA-w9m9-85wc-3x92)

@xmldom/xmldom already had an override, but its pinned target 0.8.13 had
itself fallen into the widened advisory range (<= 0.8.14), so that entry
is bumped rather than added.

browserslist is pinned to 4.28.8 rather than the advisory's 4.28.7 because
4.28.8 was already resolved elsewhere in the tree; collapsing onto it takes
browserslist from three copies to one and drops the duplicate
caniuse-lite/electron-to-chromium/update-browserslist-db trees with it, so
the lockfile is a net reduction. postcss-selector-parser 6.0.10 is left
alone: it sits below the advisory's >= 6.1.0 lower bound.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 19:59:51 +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
4grayandClaude Opus 4.8 fe3c86394c fix(playback): keep Video.js vendor-chrome shortcuts after a mouse click on a control (#1523)
Follow-up to #1516 for the vendor-chrome path (shared controls opted out). With
Video.js's own controls, Chromium leaves a clicked control-bar button focused,
and a focused Video.js component captures the keyboard entirely, so after
clicking fullscreen Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until the user clicked the video. ArtPlayer
and the native HTML5 controls were verified unaffected.

The legacy Video.js chrome now releases the focus a pointer interaction leaves
on a control (vjs-pointer-focus-release.ts). The release is scoped to the
.vjs-control-bar and pointer-attributed, and runs on both focusin (focus
landing on a control, e.g. a menu handing focus to its button) and click (a
control clicked while already focused, which fires no focusin); keyboard Tab
focus and modal-dialog focus traps are preserved. The eligibility helper is
shared with ControlsSurface via pointer-focus-release.ts.

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

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-09-04 16:36:29 +02:00
4grayandClaude Fable 5.1 9455e0db65 test(stalker): cover radio playback surviving a category switch in web E2E (#1522)
The E2E added in #1517 proved the ITV case only. Radio shares the live
layout and the same context-panel handler, and its inline audio player is
gated on the store selection just like the ITV player, so a regression in
`onStalkerCategoryClicked` would silence a station the user never switched
away from. The new scenario mirrors the ITV one: play a station, pick
another category, wait for the sidebar title to change, and assert the
audio player is still mounted.

Closes #1521

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 22:27:15 +02:00
4grayandClaude Fable 5.1 2fc1bd5601 fix(stalker): keep live playback when switching the ITV/radio category (#1517)
Switching the Live TV or radio category in the shell context panel tore
down the Stalker player: `onStalkerCategoryClicked` cleared the selected
item for every section, and the live layout gates its player on
`selectedItem`. Xtream live (#936) and M3U groups already keep the channel
playing across a category/group switch.

- Context panel: return before `clearSelectedItem()` for `itv`/`radio`;
  VOD/series clicks still drop the open detail before navigating.
- Live layout: the category-change reset effect no longer wipes the
  playing channel's short-EPG fallback or cancels a fallback load in
  flight; only a section change (itv <-> radio) does that now.
- Regression coverage in the context panel spec, the live layout spec and
  a new web E2E scenario; docs and a `.changes/` note added.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:42:50 +02:00
4grayandClaude Fable 5.1 308ed9cb41 fix(playback): keep playback shortcuts after a mouse click on a bar button (#1516)
Chromium focuses a clicked <button>, and a focused control captures the
keyboard: Space and Enter activate it again, and ControlsShortcuts yields
to any interactive element in the key's path. After a click on the
fullscreen button, Space left fullscreen instead of pausing and the seek,
volume and mute keys did nothing until a click on the video took focus
away. Follow-up to #1512, which stopped that focus from pinning the bar
but left it on the button.

A completed pointer click now releases the focus it left on the control
(onBarClick -> ControlsSurface.releasePointerFocus). The click is
attributed by its pointerType (empty for Enter/Space activation and
element.click()), with the legacy MouseEvent fallback answered once per
recorded press, so keyboard activation keeps focus where Tab put it.
Only buttons and range sliders are released. Chromium keeps its
sequential-focus starting point at the blurred control, so a later Tab
continues from it. The release dispatches a focusout while the pointer
still rests on the control, so the volume anchor ignores it instead of
closing the popover under the hovering mouse.

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

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

Closes #1455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 19:49:41 +02:00
4grayandClaude Fable 5.1 4f723b63a0 fix(playback): auto-hide shared controls after a mouse click on a bar button (#1512)
Chromium focuses a clicked <button>, so the shared controls bar treated every
mouse click on a control (fullscreen, mute, ...) as keyboard navigation and
pinned itself open until a click on the viewport took focus away — a click
that also paused playback. Most visible on Embedded MPV frame-copy after
entering fullscreen; reproduces on HTML5, Video.js and ArtPlayer too.

Only keyboard-originated focus pins the bar now: pointer-attributed focus
reveals without a pin, the press record is discarded on the first bar focus
event or any keydown, a pointerdown inside the bar releases a keyboard pin,
and a keydown bubbling out of a bar control re-pins it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 13:06:46 +02:00
4grayandClaude Fable 5.1 82295356ed perf(database): commit catalog writes in row-budgeted transactions (#1511)
* perf(database): commit catalog writes in row-budgeted transactions

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

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

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

Closes #1292

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 10:31:31 +02:00
4grayandClaude Fable 5.1 5e51c1ea5d fix(playback): route non-HLS containers to the native source path (#1510)
The HTML5 player chose its engine by exclusion: anything that was not
mpd/ts/mp4 went to hls.js, so .mkv (the default Xtream series/VOD
container), .webm, .avi, .mov and .m4v were fed to hls.js as manifests.
hls.js raised a manifest error and the player showed the network/provider
diagnostic over media Chromium plays natively.

Add resolvePlaybackUrlSourceKind() to @iptvnator/playback/util as the one
URL-to-engine rule (mpd -> dash, m3u8/m3u -> hls, ts/m2ts/extension-less
-> mpegts, everything else -> native) and read it from both the HTML5
player and ArtPlayer's getArtPlayerVideoType(), so the two engines agree.
ArtPlayer serves every native container through a single
ART_PLAYER_NATIVE_SOURCE_TYPE custom type, keeping the source session the
owner of teardown and controls binding. The HTML5 native <source> carries
the video/mp4 hint only for MP4-family files: a hint the browser's
canPlayType() rejects makes it skip the source, and Chromium demuxes
containers it does not advertise there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 09:43:33 +02:00
4grayandClaude Fable 5.1 0a2f6121f8 fix(playback): keep fullscreen across episode, channel and source switches (#1509)
WebPlayerViewComponent remounts the engine component for every playback
application, and the DOM Fullscreen API exits the moment its element leaves
the document. The fullscreen element was the engine shell, so every next-
episode click, autoplay hand-off, channel zap and alternative-source switch
dropped the viewer back to the page.

app-player-controls gains a `fullscreenTarget` input; HTML5, Video.js,
ArtPlayer and Embedded MPV forward it, and WebPlayerViewComponent passes its
own host element, which spans all applications of one mount. Keeping
fullscreen exposed a latent bug: the Electron header handoff set plain
fields under OnPush hosts and was only rendered thanks to the fullscreen
exit's stage resize; `channel`/`vjsOptions` are signals now.

Covered by unit regressions (fullscreen target, WebPlayerView remount, OnPush
handoff), a web-e2e run through a manual and an automatic episode switch, and
a manual Electron check. Docs and release note updated.

Closes #1498

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 08:21:37 +02:00
4gray 740b784268 feat(playback): make the shared player controls the default (#1408) (#1485) 2026-08-29 21:24:44 +02:00
4gray f04f67728e ci(embedded-mpv): keep Windows runtime pin available (#1495) 2026-08-29 21:24:06 +02:00
4gray 29ca94aa43 feat(release): announcement formats, highlight cards, and draft verification (#1480) 2026-08-29 10:16:07 +02:00
4grayandClaude Fable 5 7a3d5eae56 fix(playback): re-sync embedded MPV bounds on position-only layout shifts (#1476)
An ancestor re-layout can translate the player host without resizing it
(sidebar content settling, EPG panel loading below the player).
ResizeObserver reports size changes only and no DOM event observes
"position changed", so the native child window silently kept its stale
coordinates and rendered offset from the DOM stage.

The session controller now polls the host bounds every 500 ms while a
session is active, compares them against the last synced bounds with a
half-pixel tolerance, and re-syncs only on drift — idle cost is one
getBoundingClientRect per tick with no IPC. The interval is registered
via NgZone.runOutsideAngular and never re-enters the zone, so zone change
detection does not run every tick for the whole stream. Frame-copy
sessions skip the measurement entirely: the canvas is laid out by the DOM
and moves with the layout, so only the native-view child window can go
stale on a position-only shift.

Fixes #1428

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 08:35:34 +02:00
4gray 069b8b3cc9 feat(playback): advanced subtitle support in shared player controls (#1471) 2026-08-23 13:57:46 +02:00
4gray d6a9c23148 chore(deps): coordinated security sweep for open Dependabot alerts (#1475) 2026-08-23 13:56:00 +02:00
7efb8c4f66 fix(search): keep a trailing space when the trimmed q echo lands after the debounce (#1474)
* fix(search): keep a trailing space when the trimmed q echo lands after the debounce

The #1432 guard only held while a debounce was still pending. Once it
fired, the router echo of our own trimmed q (replaceUrl navigation)
reset the one-way-bound search box to the trimmed term, deleting the
just-typed trailing space — typing "Bein Sports" collapsed into
"BeinSports".

Applied terms are now always trimmed at the apply choke point (URL sync
and portal stores only ever act on the trimmed form anyway), so the
echoed q compares directly, and the echo guard no longer requires a
pending debounce. Back/forward stays authoritative via the untouched
imperative-trigger check.

Residual part of #1338.

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

* fix(search): make the trimmed-applied-term invariant structural and update the shell contract doc

Review follow-ups on the echo-guard widening:

- setSearchState now trims too, so URL-sourced terms (deep links with
  ?q=Bein%20, actor/discover prefills passing raw provider titles) cannot
  put an untrimmed term into appliedSearchQuery — previously that path
  failed the echo guard's equality check, snapped the box, and dispatched
  the portal search twice. Regression spec added.
- docs/architecture/workspace-shell.md item 8 updated: the applied-term
  echo is now always ignored, not only while input is still debouncing.
- The facade spec's router mock exposes a mutable navigation trigger so
  facade-level tests can exercise the popstate branch.

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

* fix(search): compare the echoed q in trimmed form in the echo guard

Adoption trims, so a same-page imperative navigation still carrying a
not-yet-rewritten untrimmed q adopts to exactly the applied state —
syncing it could only cancel a pending debounce. Comparing the trimmed
form closes that window.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 11:47:43 +02:00
9494643a8d docs(agents): re-run pnpm install after the checkout moves (#1473)
A node_modules tree installed at an older commit keeps serving the old
dependency versions after git pull/reset/rebase moves the checkout,
because git rewrites pnpm-lock.yaml but never re-links node_modules.
Document the trigger and the cmp-based staleness check in the Agent
Bootstrap section of both CLAUDE.md and the mirrored AGENTS.md.

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 08:56:49 +02:00
72727a5dfa feat(dashboard): detail-first Continue Watching cards with quick actions (#1469)
* feat(dashboard): detail-first continue watching cards with quick actions (#1441)

Continue Watching cards now open the detail page on click like movie
cards; resuming the saved episode, marking it watched, and removing the
entry from history move into a per-card ⋮ menu. Series details land on
the earliest season with unwatched episodes (or the latest once all are
watched) instead of always season 1.

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

* fix(dashboard): address review findings and season auto-select regressions

- A session's own watched toggles no longer re-resolve the selected
  season when the positions map first fills — marking season 1 watched
  used to jump the view to season 2 (CI regression in the web and
  Electron season-watched-toggle E2Es).
- The all-watched season fallback skips loaded-but-empty seasons and
  picks the latest season that has episodes (Greptile P1).
- Mark as Watched uses the strict failure-propagating save boundary
  (Codex P2), and both card mutations surface persistence failures via
  a snackbar with the new WORKSPACE.DASHBOARD.ACTION_FAILED key in all
  19 languages (Greptile P2).
- Season E2Es now assert the intended post-reload behavior: the fresh
  mount lands on the earliest unwatched season while season 1 keeps its
  watched state behind its tab.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 08:06: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
50871e581f feat(playback): add quality selection to shared player controls (#1470)
* feat(playback): add quality selection to shared player controls

Adds a per-session video quality menu (Auto + "1080p"-style levels) to the
shared player-controls layer, mirroring the audio-track pattern:

- Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state,
  setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR.
- hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with
  list-index ids, smooth switching through nextLevel, selection read from
  manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED,
  LEVEL_SWITCHED.
- Shaka (DASH): variant tracks filtered to the active audio language, ABR
  disabled before selectVariantTrack; manual state keyed to the exact player
  instance so a session restart never shows a stale selection.
- Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual =
  exactly one enabled level, auto = all enabled, derived statelessly).
- Embedded MPV and external players report the capability false.

The capability derives from the manifest (advertised only for >1 video
rendition), nothing persists to Settings, and the menu rides the default-off
webPlayerSharedControls rollout gate. Labels come from one shared helper so
all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to
all 19 i18n files.

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

* fix(playback): pin DASH quality candidates to the active audio stream

Review findings on #1470:

- Shaka quality candidates now match the active variant's exact audioId
  (language fallback only when Shaka reports none), so a DASH manifest with
  same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no
  longer switch the audio track or show duplicate levels when a quality is
  picked. Regression test added.
- Mirror the quality-selection contract into AGENTS.md's Shared Player
  Controls section, which must stay in sync with CLAUDE.md.

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

* fix(playback): track Video.js manual quality intent explicitly

Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off
itself when it temporarily excludes failing renditions, so inferring the
manual/auto mode from the enabled count could report a manual selection the
user never made once exclusions leave a single survivor.

VjsQualityLevels now records the picked level object as explicit manual
intent: error exclusions read as auto, a picked level that leaves the list
reverts to auto, and the bridge resets the intent on every new source.
Regression tests added.

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

* fix(playback): re-enable surviving renditions when the picked level is removed

Codex follow-up on #1470: dropping manual intent when the picked
QualityLevel leaves the list reverted the UI to auto but left the surviving
renditions disabled by the earlier manual pick, pinning VHS with no
selectable rendition. Reverting to auto now re-enables every remaining
level, both on the removal event and lazily from the state read.
Regression tests added.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 04:39:39 +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
4grayandClaude Fable 5 df8962969e feat(portals): make year, genre and country metadata clickable (#1449) (#1453)
* feat(portals): make year, genre and country metadata clickable (#1449)

Year, genre and country chips on movie and series detail pages now open a
Discover page inside the portal: popular TMDB titles for that facet, matched
against the user's own catalog. Generalizes the existing actor-page pattern
(TMDB list -> what's in my library -> else portal search) to metadata facets.

- All three TMDB merges emit structured `tmdb_genres`/`tmdb_countries`
  (+ `tmdb_media_type` on Stalker, whose embedded-VOD series route as movies).
  Cached details payloads already carry both, so existing rows need no refetch.
- Chips are clickable only with TMDB backing, like person chips today; the
  year chip gates on a merge-written numeric `tmdb_id`, since provider
  payloads ship junk string ids.
- `TmdbDiscoverService` fetches up to 5 `/discover` pages by popularity and
  caches them in memory only — popularity rankings are volatile and must not
  reach the persisted `tmdb_metadata` table.
- New `discover` route in both portals; containers clone the actor route,
  staleness-guarded by a facet key because facets change via query params on
  the same route instance.
- The grid, filter chips and badges move out of `ActorViewComponent` into a
  shared `TitleResultsComponent` used by both pages.

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

* refactor(portals): share the discover facet navigation across detail pages

The four render sites each carried their own copy of the year/genre/country
click handlers, which also pushed serial-details.component.ts past the
400-line limit. `createDiscoverFacetNavigation()` now owns the navigation,
the numeric-tmdb_id gate and the year parsing.

Year parsing moves from a fixed 4-char slice to the first four-digit run,
so a day-first provider date resolves instead of producing NaN.

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

* fix(portals): address review findings on the Discover pages

- The matching indicator was keyed on the request's subject, so an
  obsolete response skipped clearing it while no replacement request
  ever ran — the results grid then sat under the spinner forever.
  `createLatestRequestGuard()` now owns the indicator: the newest
  request always clears it, and the subject check keeps deciding whether
  the RESULT is still wanted. The actor pages carried the same latent
  bug and use the same guard now.

- Country chips came from `production_countries` while Discover filters
  by `with_origin_country`, so clicking a co-production partner returned
  titles originating there instead of titles it produced. Chips are now
  built from `origin_country` and labelled from `production_countries`;
  a code TMDB does not name is dropped rather than shown as a bare code.

- A cold load of an `actor` or `discover` route never initialized the
  catalog, so every result claimed to be missing from a library that
  actually holds it. Both are import-driven sections now.

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

* refactor(portals): extract the series Similar rail into its own service

Rebasing onto master pushed serial-details.component.ts back over the
400-line limit. The "Similar" rail moves into SerialDetailsSimilarService,
mirroring VodDetailsSimilarService next to it: same two sources, same
component-provided lifetime so a cross-portal lookup dies with the page.

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

* fix(portals): make facet chips keyboard-operable and reject zero years

- The chips were plain spans with a click handler, so the whole feature
  was mouse-only. Actionable chips are <button> now (focusable, Enter and
  Space activate); a year chip that cannot be discovered by stays an
  informational span rather than becoming a disabled button. The button
  chrome is neutralized so they render identically to the chips beside
  them, with a visible focus ring.

- `0000-00-00`, the placeholder providers ship for "no date", read as a
  four-digit year: the chip offered it, and the request then dropped the
  filter because 0 is falsy, so the page answered with unfiltered popular
  titles. `isTmdbYearFacet()` now gates both the chip and the route
  params, so a deep link cannot reach a state the chips refuse to offer.

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

* fix(portals): hydrate the offline catalog and hide results while matching

- `toCachedContentScope` returned null for the actor and discover routes,
  so an expired, inactive or offline portal skipped hydration entirely
  and both pages answered "not in your library" from an empty catalog
  even though a full imported catalog sat in SQLite. Both map to the
  aggregate `search` scope now — neither reads a single content type.

- The results grid stayed rendered under the matching spinner. Until the
  matches land every card reads as unavailable, so a click during the
  worker request opened the portal search for a title the next tick would
  have resolved in another playlist. The grid is hidden while matching.

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

* fix(portals): gate facet chips on enrichment, keep portal results visible

- `typeof tmdb_id === 'number'` was the wrong proof that enrichment ran:
  XtreamVodInfo.tmdb_id allows a provider-sent JSON number, so with TMDB
  disabled the year chip stayed clickable and opened a Discover page that
  cannot load anything. The target now answers the real question — can a
  facet click land anywhere — and returns null when enrichment is off, so
  the id argument is gone from the chip API entirely.

- Hiding the grid on the raw matching flag blanked valid portal results
  when the user switched back to "This portal" mid-request; a stuck worker
  would have blanked them indefinitely. The spinner belongs to the global
  scope, so it only replaces the grid while that scope is active.

- CLAUDE.md and docs/architecture/stalker-portal.md list the portal child
  routes explicitly; both now include `discover`.

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

* fix(portals): label the year chip with the year it navigates to

`facetYear()` reads the first four-digit run so a day-first provider date
resolves, but the templates still sliced the first four characters — so
`31-03-1999` rendered as `31-0` while the click opened 1999. The label
now comes from the same parser as the destination (`yearLabel`), and the
informational chip keeps its previous rendering only when no year parses.

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

* fix(portals): match Discover results by original title, guard stale loads

- `/discover` returns titles localized to the app language while the
  provider catalog stores whatever the panel named the file, usually the
  original. Discarding `original_title`/`original_name` marked owned
  titles unavailable and sent the click to a search for the wrong name.
  Results carry the alias now, and both local and cross-playlist matching
  pass it the way the recommendations rail already does.

- A facet change to B and back to A leaves two in-flight loads with the
  SAME key, so the key could not tell them apart: an older request
  failing after the newer one succeeded replaced valid results with an
  empty page. Recency decides who may commit, via the same request guard
  the matching path uses.

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

* fix(portals): wait for the catalog before stating Discover availability

Triggering initializeContent() for the discover route was only half the
fix: TMDB usually answers before a cold catalog finishes importing, and
the content gate renders the route while that runs. The page dropped its
spinner as soon as the TMDB request settled, so cards computed against an
empty catalog claimed that titles the user owns are missing and their
clicks opened a search instead of the detail page.

Availability now waits for the catalog too. Readiness is keyed on what is
in flight rather than on isContentInitialized, mirroring the recently-added
route, so a failed import settles the page instead of spinning forever.

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

* test(portals): cover the Discover catalog-readiness gate

Holds the TMDB request and the catalog flags independently so the
cold-load regression cannot return: results settling first must keep the
page loading, a finished catalog must publish them, a failed import must
still settle the page, and a running import must keep it loading.

Verified to fail on the pre-fix gate: reverting isLoading to the results
signal alone turns two of the four cases red.

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

* docs(portals): describe the Discover gates the code actually implements

Three rounds of review fixes moved the contracts out from under the
prose. The year chip no longer gates on a merge-written numeric tmdb_id
(that gate was wrong: the field is number | string, so a provider-sent
number passed it with enrichment never having run) but on the navigation
target, which requires a playlist and enabled enrichment. Discover loads
are guarded by recency, not by facet key, because A→B→A leaves two
in-flight requests sharing one key. Availability additionally waits for
catalog readiness. Both canonical entries say so now.

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

* refactor(portals): drop the normalized tmdbId that proved nothing

`NormalizedVodMeta.tmdbId` existed only to gate the year chip, and its
comment claimed a numeric `tmdb_id` proved enrichment had run. That test
was wrong — the provider field is `number | string` — so the gate moved
to the navigation target and the field lost its last consumer. Removing
it beats re-documenting it: a field that survives with a false guarantee
in its doc comment is how the rejected gate gets reintroduced.

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

* fix(portals): read the year with one rule everywhere

The shared detail path fed the chip `meta.year`, which the adapter built
with a fixed-prefix fallback: a day-first `31-03-1999` became `31-0`, so
that path both displayed the wrong label and lost the facet, since the
guard could not parse it back.

`parseFacetYear()` moves to shared/interfaces and both callers delegate
to it, so the adapters and the Discover chips cannot drift into
disagreeing about what a date says. The adapter keeps its date-parse
fallback for shapes stating no four-digit run, but no longer invents one
by slicing.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:59:58 +02:00
4gray bee7df1e02 feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a
provider sent — links, Xtream credentials, a MAC address with device identity
— and a deterministic parser recognizes the source(s) and prefills the
matching import form.

- detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC
  addresses and labeled fields, classifies each finding as Xtream, Stalker or
  an M3U link/body, and returns ranked candidates. Pure and synchronous.
- Built against a corpus of 19 real reseller handouts kept verbatim in the
  spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex
  serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a
  guard so a parental PIN is never read as the account password.
- Detection only proposes: the target form's own validation and behavioral
  probes remain the sole path into the store, and no pasted text leaves the
  app. Passwords are masked on candidate cards, including query and HTTP
  Basic userinfo forms.
- Covered by parser, component and dialog unit tests plus two web E2E specs
  for the paste → pick → prefilled form workflow; i18n for all 19 languages.
2026-08-16 13:19:54 +02:00
4grayandClaude Fable 5 00aa623b83 fix(downloads): reconnect interrupted transfers and resume without validators (#1446)
* fix(downloads): reconnect interrupted transfers and resume without validators

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

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

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

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

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

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

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

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

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

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

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

Round-3 review findings:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Round-13 findings plus a CI limit:

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

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

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

Round-14 review findings plus the CI build break:

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

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

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

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

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

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

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

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

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

Round-17 review findings:

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

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

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

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

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

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

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

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

* fix(downloads): correct both 416 classification boundaries

Round-19 review findings, one in each direction:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 07:07:25 +02:00
4grayandClaude Fable 5 0a2fe263db feat(portals): mark a whole series as watched in one click (#1451)
* feat(portals): mark a whole series as watched in one click

Adds a series-level watched toggle to the season header's new overflow
menu on both Xtream and Stalker series detail pages (issue #1442 v2,
building on the season-level toggle from #1447).

- Shared: buildSeriesWatchToggleRequest flattens every loaded season
  with the season builder's mark/unmark semantics; the direction is
  always the one the label advertised, never re-inferred at persist
  time. Watch-toggle state math for both scopes moves into the new
  component-provided SeasonWatchPresenter (the container component sat
  at the max-lines cap).
- Xtream: the series request reuses SerialDetailsSeasonWatchService
  through a scope-parameterized handle(), the same stillCurrent
  ownership guard, and the XtreamStore.loadAllPositions badge refresh.
- Stalker: the season handler's core is extracted into
  runWatchToggleBatch (feedback keys per scope). Lazy Ministra VOD
  hydrates unloaded seasons sequentially first (zero writes on a failed
  fetch, silent abort on navigation), re-runs the position reconcile
  synchronously so newly hydrated episodes' legacy rows are cleaned,
  then rebuilds the request keeping the clicked direction; an
  all-watched outcome reports an honest count-0 snackbar.
- Container: new hasUnloadedSeasons input blocks the fully-watched
  verdict and the count label while lazy seasons are unloaded, and the
  empty mark request contract lets the host hydrate-then-rebuild.

Six new XTREAM i18n keys, synced to all 18 locales via the i18n-fill
workflow. No new IPC: the existing playback-position batch channels are
season-agnostic.

Refs #1442

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

* fix(portals): treat an empty Stalker season answer as loaded, not pending

A lazy season the portal ANSWERS for with zero episodes was still
counted as unloaded (episodes.length === 0 heuristic): the series label
stayed countless forever and every series toggle re-fetched the empty
season, while a glitch-empty answer could silently skip a season and
still report success as if nothing remained.

VodSeriesSeasonVm gains an episodesLoaded flag set by every successful
episode fetch — including an empty one — and the series toggle's
pending predicate, hydration re-check, and hasUnloadedVodSeasons now
key on it. A loaded-and-empty season unblocks the count label and the
fully-watched verdict instead of re-fetching; a fresh detail mount
still re-fetches, so a one-off glitch self-corrects next session.

Addresses the Greptile P1 on PR #1451.

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

* fix(portals): harden lazy season hydration against malformed and racing loads

Two review findings on the series watched toggle:

- fetchVodSeriesEpisodes now trusts an empty answer only when the
  envelope actually carried a well-formed array; a malformed envelope or
  an answer whose rows contain no recognizable episode rejects, so the
  load fails instead of the season being recorded loaded-and-empty and
  silently skipped by the series batch.
- loadEpisodesForSeason is single-flight per season: a tab click, the
  spillover prefetch, the quick-start recursion, and the series-toggle
  hydration join one in-flight request instead of duplicating portal
  traffic — previously a second request's failure could abort a series
  toggle whose original request succeeded.

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

---------

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

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

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

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

Closes #1442

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:03:14 +02:00
4gray 00000de798 feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448) 2026-08-15 13:59:28 +02:00
4grayandClaude Fable 5 20857e34c2 refactor(playback): extract web player diagnostic/recovery controller (#1444)
* refactor(playback): extract web player diagnostic/recovery controller

WebPlayerViewComponent sat at ~391 counted lines against the 400
max-lines cap. Move the diagnostic/recovery surface — the diagnostic
ownership/intent tokens, visible-diagnostic computed, playback-issue
handling, recommended-player switching, retry, and session sync — into
a dedicated WebPlayerRecoveryController in the same directory. The
component stays the template-facing facade with an unchanged public
API, so all four spec files pass without modification.

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

* refactor(playback): move recommendations into the recovery controller

The controller deps closed over the component's recommendations
computed, which itself read the controller's visiblePlaybackDiagnostic
— a reference cycle tsc rejects under noImplicitAny (TS7022/TS7023)
even though Jest and ESLint accept it. Owning the recommendations
computed in WebPlayerRecoveryController removes the cycle at its root
and shrinks the facade further; the component re-exposes the signal
unchanged for the template and specs.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:40:22 +02:00
4grayandClaude Opus 5 c723093890 test(playback): drive embedded MPV controller specs on a fake clock (#1434)
* test(playback): drive embedded MPV controller specs on a fake clock

The `waitFor` helper in the three embedded-mpv-session-controller specs
polled real timers against a `Date.now() + 1s` deadline. Under parallel
Jest workers the controller's async startup chain (support load ->
waitForStartupPaint -> session create -> update subscription) did not
always settle inside that budget, so a different pair of tests failed on
each run.

Bound the drain loop by rounds on a fake clock instead of elapsed wall
time, so machine load can no longer change the outcome. Each round
advances 1ms rather than 0ms: `waitForStartupPaint` nests rAF inside rAF,
and a zero-delay timer scheduled from inside a timer callback is clamped
to the next millisecond, so a 0ms advance fires the outer hop and strands
the inner one forever.

Assertions and behavior under test are unchanged. Fake timers also stop
the stalled tracker's 30s timeout leaking into the real timer queue,
which removes the "worker process failed to exit gracefully" warning.

Verified with 5x `nx test ui-playback --skip-nx-cache` and 5x the same
suite at --maxWorkers=32 (the condition that reproduced the flake):
975/975 tests passing each time.

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

* docs(playback): scope the fake-clock claim in the MPV controller spec

The comment said machine load "no longer changes the outcome", which
overclaims. Removing the spec's own `Date.now()` deadline means the spec
imposes no budget of its own, but Jest's per-test timeout still measures
wall time — at extreme worker oversubscription these specs can still hit
it, as every spec can.

Comment only; no behavior change.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:30:09 +02:00
4grayandClaude Opus 5 7952e6444d test(playback): stub the inline player in the VOD details spec (#1440)
`VodDetailsComponent` specs imported the real component tree, so every
`createComponent` pulled in `PortalInlinePlayerComponent` ->
`WebPlayerViewComponent` -> ArtPlayer, video.js and the embedded-MPV
bridge, and instantiated all of it. The suite's slowest test measured
2521ms idle on a 10-core machine -- a 2.0x margin against Jest's 5000ms
default, thin enough that a loaded worker failed tests that had no defect
behind them.

Swap the inline player for a stand-in that mirrors the selector and the
template's bindings; the specs only assert what the host hands the
player. Worst test drops 2521ms -> 306ms and the summed test time 3427ms
-> 632ms. Assertions are unchanged.

Also raise testTimeout to 15s for the shared web-ESM config. The 5s
default is thin for Angular component specs generally: TestBed compiles
and instantiates a real tree per test, and CI runners are much slower
per-core than a dev machine. This is headroom, not a mute -- a spec that
genuinely hangs still fails, just later.

Attribution, full suite at --maxWorkers=48 on 10 cores:
- forced back to 5000ms: VodDetails no longer fails (it did before this
  change); HtmlVideoPlayer still does, which is what the timeout covers
- shipped 15000ms: 2/2 runs, 975/975 passing

All six projects sharing the config pass: 1656 tests.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two from review, one behavioural and one documentation.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 22:27:05 +02:00