Commit Graph
330 Commits
Author SHA1 Message Date
4gray fff022afe4 fix(ui): keep detail back navigation available while scrolling (#1576) 2026-09-10 21:34:10 +02:00
Lars Emig e76447975b feat(playback): stream-info popover in the player overlay (#1578) 2026-09-10 21:33:41 +02:00
4gray bad8a0991e feat(downloads): download completed Xtream catch-up programmes as TS (#1572)
* feat(epg): copy catch-up programme URLs without changing playback

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

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

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

* fix(downloads): verify archive identity through finalization

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

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

* fix(downloads): recover explicitly verified archive completions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(downloads): expose retained archive recovery files

* fix(downloads): keep recovery instructions open while copying
2026-09-08 20:33:05 +02:00
4gray 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
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
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 9de480826c fix(epg): remove cached XMLTV data after source deletion (#1548)
* fix(epg): remove cached XMLTV data after source deletion

* fix(epg): close source reconciliation review races

* fix(epg): serialize cleanup with replacement imports

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

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

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

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

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

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

* fix(epg): preserve metadata writer order independently of timestamps
2026-09-06 07:32:30 +02:00
4gray 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
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 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
d8d36476e6 feat(epg): add global EPG display time offset (#1489)
Adds a global EPG display-time offset (Settings → EPG, whole minutes, ±720) for guides whose provider labels programme times with the wrong timezone. Display-only: parsed XMLTV values, SQLite rows, catch-up URLs and recording snapshots keep the provider's own times, so changing it needs no guide refresh. Closes the global part of #50.

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

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

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

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

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

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

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

Review follow-up for the relative seek path.

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

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

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

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

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

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

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

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

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

---------

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

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

Co-authored-by: Bpl5966 <amine.b1959@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:36:14 +02:00
4grayandClaude Fable 5.1 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 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 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
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
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
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
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 Fable 5 3103eba083 fix(playback): apply saved player changes to mounted web players (#1437)
* fix(playback): apply saved player changes to mounted web players

WebPlayerViewComponent resolved the saved engine from a one-shot
StorageMap snapshot taken at mount, so a player switch from the command
palette or settings page confirmed via snackbar and persisted the
setting while an already-mounted Xtream/Stalker player silently kept
the previous engine. The same snapshot also made first play mount the
default Video.js engine and swap to the saved one once the async read
landed.

Resolve the player (and recording folder) from the live SettingsStore
signal instead and drop the snapshot entirely. Precedence is unchanged:
temporary recovery override -> host playerOverride -> saved player ->
Video.js. Hosts passing no override (Xtream/Stalker live layouts, the
portal inline detail player) now track saved changes in place; first
mount reads the already-loaded store, so the default engine no longer
flashes.

Regression coverage (all verified to fail with the fix reverted):
three unit tests on the component and two Xtream live-route e2e tests —
a palette switch reaching the mounted player without a layout remount,
and a MutationObserver engines-ever-seen assertion that the saved
engine mounts first time.

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

* fix(playback): retain mounted engine when saved player becomes MPV/VLC

With the saved player now live-tracked, a mid-session palette switch to
managed MPV/VLC cleared the inline binding on hosts without a
playerOverride and left a blank viewport — the web player view can
neither render nor launch external players. resolveRenderableWebPlayer
keeps the mounted engine in that case; the external choice applies when
the host starts the next playback. Renderable players, including
Embedded MPV, still apply live. Raised by Codex review (P2).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 18:57:55 +02:00
4grayandClaude Fable 5 f7bb3a13db feat(playlist): open recognized M3U movies in the VOD detail view (#1420)
M3U entries recognized as movie files now open in the portals' two-state VOD
detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first:
activation still plays immediately, with plot, cast, rating and artwork below
the player; Escape reveals the Browse hero.

Recognition is a synchronous URL-shape heuristic (movie container extension or
an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker
names keep today's live layout), gated on TMDB enrichment plus the new
default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA.

Review follow-ups included: the playback payload no longer carries TMDB fields
(its identity is the player's source-application key), the persisted volume
reaches the player and survives Browse → Play, the enrichment guard keys on
the full lookup identity, and the saved engine mounts first time instead of
briefly falling back to Video.js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 17:18:06 +02:00
4gray 2d7811eb5f feat(portals): add "View in portal" action to inline collection details (#1422) 2026-08-13 07:30:57 +02:00
4grayandClaude Fable 5 36c2867d36 feat(xtream): recognize more language tags in VOD multi-source (#1417)
* feat(xtream): recognize more language tags in VOD multi-source

The sources popover's language filter and copy chips now read prefixes
with Unicode pipe lookalikes, brackets and spaced dashes, Cyrillic tags
and MULTI. When a stream title carries no tag, the language falls back
to what the stream's visible categories unambiguously state ("EN |
Netflix") — discovery aggregates category names per (playlist, stream)
in SQL, and category prefixes must pass a known-language gate because
everyday category words like new/top/hot are real ISO 639-3 codes.

Both signals stay parsed guesses: browse filter and chips only, never
ranking, failover or dub-warning inputs.

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

* fix(xtream): address Codex review on multi-source language detection

Gate the new bracket and dash title forms through isKnownLanguageTag:
those positions carry quality/rip tags ([HD], [CAM], NEW -) whose
fabricated "language" would outrank and mask a real category-derived
one. The legacy pipe form stays permissive.

Overlay a late-arriving route category onto the existing route row in
the same-key refresh path — cold/direct routes load categories after
discovery, and the category is outside the movie key on purpose. The
mid-flight case is redelivered by the bind() effect re-running on the
controller's sources signal; that tracked read is now documented as
load-bearing and pinned by a session spec.

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

* fix(xtream): pair brackets and strip the new tag forms when matching

Greptile: the bracket prefix chose its opening and closing delimiter
independently, so a malformed "[EN)" was read as a language tag.

Codex: recognizing a prefix is only half the job — normalizeTitleKeys
has to strip the same tag, or the tagged copy never matches the bare
one and multi-source cannot offer the film at all. Its leading-tag rule
now shares the pipe-lookalike set and, on the pipe branch only, takes
the same Latin+Cyrillic any-case alphabet with no required trailing
space. Dash and colon keep their uppercase-Latin spaced form: those are
ordinary punctuation, and loosening them would amputate "ОНО: Часть 2"
the way a case-insensitive rule amputates "It: Chapter Two".

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

* fix(xtream): keep normalization uppercase-only, measured on real catalogs

The previous commit widened the pipe branch of normalizeTitleKeys to any
case and to Cyrillic, on the theory that nothing but a tag precedes a
pipe. Checked against 1.27M real catalog titles that theory is wrong in
two ways at once: "Akira | 1988" and "Coco | 2017" put the film's name
before the pipe and the year after it, and Russian catalogs write
"Момо | Momo" — localized title, then original. The widening corrupted
349 keys and rescued none, so it is reverted.

What survives is what the data supports: the pipe-lookalike set (0
changed keys, and correct for panels that use them) and dropping the
required space after a pipe (35 changed keys, genuine welded tags like
"EN|Dark Shadows" and "|FR|VO|Le dernier empereur").

A leading-tag guard that refused to strip when no letter remained is
also dropped: it fixes "AKA | 2023" but breaks "IT - 65", so telling
those apart needs a tag vocabulary and belongs in its own change.

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

* docs(xtream): cite the measured evidence for the category language gate

The gate's rationale named hypothetical category shapes. On a real
catalog the four it actually turns away are VOD (5,245 movies), KIDS
(1,010), SHOW and WWE — without it the language select offers "VOD" and
"KIDS" as languages. Comments, doc and one spec case only.

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

* docs(xtream): restore the docblock currentSourceRow lost to an insertion

routeCategoryLanguage was added between currentSourceRow's docblock and
its signature, so the paragraph describing "the row standing for the
source the route is already playing" ended up introducing a function
that returns a language string. Moved below; no behavior change.

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

* fix(xtream): stop grouping the scan tier, it can drop a matching source

content is unique per (category, type, stream), so one stream sitting in
several categories is several rows and nothing forces their titles to
agree. The GROUP BY added for group_concat let SQLite keep an arbitrary
row's title, and the normalized confirmation then rejected the whole
stream on a title a sibling row would have matched — the source vanished.

The FTS tier can afford that grouping because its window makes it
necessary; the scan tier takes no window at all, so it now returns a row
per category and their names are merged per stream in TypeScript, which
also keeps the rejected sibling's category in the language derivation.

Found by Codex. Latent rather than active on the catalog I measured (0
streams currently carry differing titles across categories), but the
schema permits it.

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

* docs(xtream): record why category names stay scoped to matched rows

Codex flagged that the FTS predicate runs before the aggregate, so a
sibling row under a localized title contributes no category. True, and
deliberate: the field is a guess feeding a chip and a browse filter, and
completing it costs measured latency — 0.74s to 2.0s for a correlated
subquery on a 3.9GB catalog, 19.7s for a second bounded lookup — to
correct a cosmetic guess in a shape that occurs 0 times in 2.7M rows.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:59:07 +02:00
4grayandClaude Fable 5 e5bb127ede fix(playback): make playback keyboard shortcuts work without shared controls (#1398)
* fix(playback): make playback keyboard shortcuts work without shared controls

With the default configuration (Video.js, webPlayerSharedControls off) the
playback shortcuts advertised in the in-app help and README — Space/K, F,
arrow seek/volume, M — silently did nothing: ControlsShortcuts only exists
inside app-player-controls, which never renders on the preference-off path.

Attach a LegacyPlayerShortcuts wrapper (same arbitration and ignore rules)
in the vendor-chrome HTML5, Video.js, and ArtPlayer players, forwarding the
commands to each engine's own API. Seek stays gated on authoritative VOD
metadata plus a finite positive duration, and a visible playback diagnostic
disables the keys. The legacy ArtPlayer chrome now passes hotkey:false —
its focus-scoped vendor hotkeys ignore defaultPrevented and would
double-handle every key — with its Escape-exits-web-fullscreen behavior
restored by the new wiring.

The playback entries in the in-app shortcut help and README drop their
embedded-MPV-only qualifier, since the keys now work in every runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

* fix(playback): restore audible volume when M unmutes at zero volume

Addresses the Codex review finding on #1398: after arrowing the volume
down to zero (which mutes), M flipped muted off while leaving the volume
at 0, so the player looked unmuted but stayed silent — in all three
legacy engine adapters.

Mirror the shared controls' ControlsVolume semantics with a per-adapter
LegacyMuteMemory: muting remembers the audible volume, and unmuting while
the volume sits at zero restores it, with the same 0.5 fallback when
nothing was remembered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h2cZi5DcFSbcmV7WgB6qB

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-09 23:33:12 +02:00
4gray d73acd6bfc fix(playback): clarify external player launch feedback (#1388) 2026-08-09 13:33:07 +02:00
4grayandClaude Fable 5 d5f84fb130 feat(xtream): catch-up badge for live channels with archive (#1341)
* feat(xtream): show a catch-up badge on live channels that have archive

Live channels whose provider declares playable catch-up (tv_archive=1
with a positive tv_archive_duration) now show a small history badge in
the channel sidebar next to the name and on the all-channels grid cards,
with the archive window (days) in the tooltip.

Closes #1128

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

* fix(xtream): expose the catch-up badge status to assistive technology

The mat-icon is aria-hidden and the tooltip is pointer-only, so the
badge status was invisible to keyboard and screen-reader users. Both
badge surfaces now also render the translated status as visually-hidden
text (Codex review, P2).

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

* feat(xtream): show the catch-up badge in favorites and recent lists

Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so
the shared favorites list (portal favorites/recent tabs and global
favorites) renders the same catch-up badge as the live sidebar.
Requested in PR feedback by the issue author.

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

* feat(ui): show the programme-info button in portal sidebars, stacked vertically

Adds the (i) programme-info button to the Xtream and Stalker live
sidebars and reworks the row action column: buttons stack vertically
(favorite on top, info below), so the second button costs no horizontal
space — the column is actually narrower than the previous single-button
row. The info slot is reserved (inert, visibility:hidden) while the row
has no programme, so the star never shifts when EPG data arrives.
Requested by the issue author in PR feedback.

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

* test(ui): move the archive passthrough spec out of the budget-capped file

CI lints the merge with master, where unified-live-tab.component.spec.ts
grew (#1374) to one line under the 1200 max-lines test budget — the
archive passthrough test added here tipped the merged result over. The
test moves to a focused template-less spec (plus a null-normalisation
case), leaving the main spec at master's size.

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

* fix(ui): constrain Material touch targets to the stacked button bounds

mat-icon-button keeps a 48px touch target; stacked 28px buttons
overlapped by 20px and the later sibling (programme info) stole clicks
from the lower third of the favorite star. Verified via
document.elementFromPoint before/after: the star's visual bounds now hit
the star, and clicks left of the column reach the row again instead of
the button's oversized target (Codex review).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 10:47:44 +02:00
4grayandClaude Fable 5 c95f826739 fix(playback): keep Video.js controls on the live MPEG-TS path (#1385)
Video.js was constructed without the controls option — the component relied
on a [controls] template binding on the original <video> element instead.
player.reset(), which every raw MPEG-TS/live source change goes through,
replaces the tech <video> element; the binding's target is disposed, so live
playback ended up with no native controls and a vjs-controls-disabled control
bar: no visible controls at all.

Enable controls through the Video.js constructor options in legacy mode and
drop the template binding. The Video.js control bar is a player-level child
that survives loadTech_, so it stays across resets — and the quality selector
and aspect-ratio panel buttons it hosts become reachable again.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 21:43:05 +02:00
4gray fd96b85c19 feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations

* docs(playback): plan recovery recommendations

* refactor(playback): extract diagnostic utilities

* feat(playback): define recovery recommendation contracts

* feat(playback): rank recovery recommendations

* feat(playback): track session recovery attempts

* feat(playback): identify content recovery sessions

* feat(ui): add ranked playback diagnostic panel

* feat(playback): switch temporarily to recommended players

* test(playback): cover temporary player recommendation

* test(playback): verify recommendation capability guards

* docs(playback): document recovery recommendations

* fix(playback): keep recovery keys credential-free

* fix(playback): remove derived tracking ownership

* fix(playback): preserve distinct recovery fallbacks

* fix(playback): reset resume for new sources

* fix(playback): preserve desktop recovery guidance

* docs(playback): clarify recovery policy exceptions

* fix(playback): reject stale progress updates

* fix(playback): keep protected recovery guidance neutral

* test(playback): cover stale progress output

* fix(playback): neutralize protected diagnostic copy

* fix(playback): harden runtime guidance ownership

* fix(playback): stabilize recovery application ownership

* fix(ci): classify playback util coverage

* fix(e2e): preserve playback fixture bytes
2026-08-08 01:04:39 +02:00
4grayandClaude Opus 5 5e4f2ca3dd docs(stalker): reconcile the Stalker docs after the API-compatibility series (#1375)
Nine PRs landed between 2026-08-01 and 2026-08-04 in parallel worktrees, each
editing its own section of docs/architecture/stalker-portal.md and CLAUDE.md.
Sections that were correct when written disagreed with each other, or with
master, afterwards. Every claim here was verified against the code.

Corrected in stalker-portal.md: routes listed without the /workspace prefix;
"simple portals carry only the mac= cookie" (every request goes through the
shared identity builder — but the direct branch forwards no serial, so no
SN/__cfduid either, while playback headers are NOT mode-gated); a facade
introduced as "three modules" above a list of five; the pre-#1370 "blank
fields are not generated" opening; an ambiguous stalker-identity.utils.ts
citation (two files share the name); two of the three surfaces that apply the
scoped header override; a bare {status: 1} now being a refusal; and the
session-state fields #1354 added to the backup exclusion list (mirrored in
playlist-backup-restore.md).

CLAUDE.md had no entry at all for portal mode / endpoint discovery / lazy
repair — the largest change of the series; added one. Its session-facade list
was missing two modules and status 1 still read as plain "blocked".

Mock server: documented the /stalker, /stream/gated and marketing-poster
routes and the HOST variable; replaced the global POST /reset guidance with
the real per-MAC isolation contract (OWNED_MACS, the sibling 00:1A:79:5F:*
range, mode: 'serial'); added get_main_info; refreshed the project tree; fixed
a broken anchor; and corrected MOCK_PORT, which moves the client side only —
nothing maps it to the server's PORT.

The repo skill's "keep Stalker request rules in Stalker data access" no longer
holds: the wire-format, identity, portal-mode and auth-failure contracts live
in shared/interfaces because the Electron main process cannot import renderer
libs.

Also fixes four stale code comments carrying the same claims, including
"Single choke point for Stalker API calls" — four callers deliberately go
direct, and only fetchViaProfile() wires repair itself.

Docs and comments only; no executable change. No release note (no user-visible
behavior); no-release-note label applied for the libs/** paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 17:49:49 +02:00
4gray 96facd6f49 feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
2026-08-03 08:53:44 +02:00
4grayandClaude Opus 5 c741815b97 fix(build): include shared UI stylesheets in Nx cache inputs (#1360)
* fix(build): include shared UI stylesheets in Nx cache inputs

`libs/ui/styles` held shared SCSS partials but had no `project.json`, so its
files belonged to no Nx project and were absent from every task hash. Editing
a partial and running `pnpm nx build web` reported 4 of 4 tasks cached and
shipped the previous CSS — a silent wrong build rather than a failure.

Nx derives its project graph from TypeScript imports only, so a relative Sass
`@use` that crosses a project root creates no edge. Verified directly: after
adding the project but before declaring anything, `ui-styles` still had zero
dependents in the graph.

Make it the `ui-styles` project (no targets — it exists to be hashed) and
declare `implicitDependencies` on the 8 consumers. Chosen over adding the path
to `sharedGlobals`, which would put shared styles into every project's hash and
make a one-line SCSS tweak mark the whole workspace affected. A styles edit now
marks 15 projects affected and leaves electron-backend, website, the mock
servers and the shared libs alone.

`libs/ui/styles` was the only projectless directory holding files under `libs/`
or `apps/`.

Add `pnpm run styles:inputs:validate` to keep it closed: it resolves every
relative stylesheet import against Nx's real project graph and fails when one
escapes the input closure of a build that compiles it, naming the project to
declare. It exits 1 with 21 diagnostics on the pre-fix tree. Imports of
`apps/web/src/nav-list.scss` are deliberately accepted — `web` already hashes
that file, and a lib -> app edge would make the graph cyclic.

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

* fix(build): spawn git without a shell in the stylesheet check

`execSync("git ls-files '*.scss'")` runs through `cmd.exe` on Windows, where
single quotes are literal characters rather than quoting. Git received the
pathspec with the quotes intact, matched nothing and exited 0, so
`styles:inputs:validate` reported success after checking zero stylesheets —
silently disabling the check for Windows developers while staying green.

Spawn with `execFileSync` so no shell is involved and git expands its own
pathspec; verified to return the identical 133 files.

Both this and the eslint glob trap next to it in the docs report success while
covering nothing, so also make an empty scan fail rather than pass: the
workspace always contains SCSS, and a listing that returns none means the scan
broke.

Reported by Codex review on #1360.

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

* refactor(styles): move nav-list partial into ui-styles (#1361)

* fix(build): count every target of a comma-separated Sass @import

`@import` is the only rule that takes a list, and the scan read just its
first target. A later entry crossing an Nx project boundary escaped the
cache key while the check still reported success — the same silent-pass
failure the tool exists to prevent.

Parse every target of an `@import` list. The obvious "read all quoted
strings" fix trades one silent gap for a phantom one, so the rule decides:
`@use`/`@forward` load exactly one module and a quoted string after it is
`with (...)` configuration, and `url(...)` stays a plain CSS import the
browser resolves at runtime. Neither is a module Sass compiles.

The workspace has no relative `@import` at all today, so the scan still
finds the same 42 imports across 133 files; this closes the gap before
someone writes one.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 23:18:37 +02:00
4grayandClaude Fable 5 0010dd7351 feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row

The Sources popup now anchors above its button and always fits on screen:
the overlay caps it to the space beside the chip, only the source list
scrolls so the header, search, filter chips and footer stay visible, and
it flips below the button when the space above is too small. Filter chips
(All / Available / HD+ / language) compose with the playlist search, and
"Available" runs check-all itself when nothing has been checked yet.

Expanded copy rows no longer repeat the playlist domain: each copy shows
its parsed language chip, the provider's raw stream title, and only the
tags that differ from the parent copy.

Availability checks run at most four at a time and settled verdicts are
remembered per movie and source for ten minutes, so reopening a movie no
longer re-contacts every foreign portal.

Favorites and Download become icon-only buttons with real state: a filled
heart when favorited, and a download icon that turns into a progress ring
and then a checkmark that reveals the finished file.

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

* test(portals): match the movie detail's icon-only favorite button

The Xtream movie detail's favorite control is now an icon-only button, so
the shared "add this detail to favorites" helpers no longer found it by
class. They are used against series and Stalker details too, which still
render the labeled variant, so they now select by accessible name — the
icon button carries the same label in aria-label.

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

* fix(portals): keep provider playback for a downloaded movie

Once a movie is downloaded the primary button plays the local file, and
the sources popover only exists when another playlist carries the same
film. The icon-only rework left those as the only two paths, so a
downloaded movie in a single-playlist library had no way at all to stream
the provider's copy — the labeled action that used to do it was gone.

Restores it as an icon button beside the downloaded checkmark, under the
same condition the old one used.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 22:27:24 +02:00
4grayandClaude Fable 5 fc7f23b229 feat(playback): forward portal Cookie/Authorization to built-in players (#1335)
* feat(playback): forward portal Cookie/Authorization to built-in players

The web players (HTML5/hls.js, Video.js, ArtPlayer, Shaka) could only ever
receive User-Agent/Referer/Origin, so any Stalker stream gated on the portal
session cookie or Bearer token played exclusively in external MPV/VLC — the
long-running "only VLC works" cluster (#849, #910, #732).

- request-header-overrides.service: the scoped override now carries Cookie
  and Authorization, attached only to requests on the exact stream origin,
  in-memory only, dropped on replace/clear. Unscoped (playlist-level) calls
  drop credentials fail-closed; control characters in header values are
  rejected. Chosen over session.cookies.set(): jar cookies attach only to
  credentialed requests, which would force withCredentials into every engine
  and break against the Access-Control-Allow-Origin:* IPTV panels send, and
  jar scoping is port-blind.
- WebPlayerViewComponent is now the single owner of the scoped override for
  every built-in player: it extracts the full header set from the resolved
  playback, configures the override BEFORE handing the source over (players
  render only once the source exists), and clears the scoped layer on
  destroy. HtmlVideoPlayerComponent's own three-header call is removed — it
  would overwrite the credentialed override.
- Stalker VOD, series episodes and radio now build the same portal header
  set ITV already had (they previously carried no portal headers at all);
  same-origin playback sends the real User-Agent alongside X-User-Agent.
- Stream classification is host-based via one shared predicate
  (isStalkerStreamCredentialSafe): same-host port changes and scheme
  upgrades keep the portal profile (the #1158 class), a foreign host or
  https->http downgrade keeps the credential-free KSPlayer profile. The
  main-process fallback context uses the same predicate so
  isStalkerDirectStreamProfile can no longer discard renderer headers.
- setUserAgent bridge gains an optional credentials parameter; preload,
  ipcMain handler and ElectronBridgeApi updated together.
- stalker-mock-server: gated-stream scenario (MAC 00:1A:79:00:00:09) whose
  create_link returns a local /stream/gated/video.mp4 that 403s without the
  mac cookie + current Bearer token; new Electron e2e proves a built-in
  player actually plays it (and that the gate refuses bare requests).

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

* fix(playback): apply header override to Stalker radio, redact mock cookie log

Address Codex review feedback on #1335:

- The radio branch of the Stalker live layout renders the dedicated audio
  player, never WebPlayerViewComponent, so the resolved portal headers were
  built but never applied — an auth-gated radio stream still 403'd. The
  override sync is extracted into ElectronStreamHeadersService (single owner
  of the scoped override slot, with clear-only-while-owning semantics so a
  destroyed consumer cannot wipe a newer consumer's override), applied by
  WebPlayerViewComponent for video players and by the radio branch before
  the audio element gets its URL. The service feature-detects the bridge
  method so partial bridges behave like the PWA instead of throwing.
- The gated-stream mock no longer logs the raw Cookie header on 403 —
  presence only, matching the Authorization logging.
- The gated scenario now serves an audio fixture for radio create_link and
  the Electron e2e covers the radio path end-to-end (bare request 403s,
  built-in audio player advances past the gate).

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

* fix(playback): claim radio header ownership before awaiting the IPC

Codex round-2 P2: leaving the radio route while the header IPC was still in
flight left the portal cookie/token installed — ngOnDestroy saw a null scope
URL (it was recorded only after the await) and could not clear the override.
Ownership is now claimed synchronously before awaiting, destroy invalidates
the pending playback continuation, and the apply's stillCurrent verdict is
honored. Regression test covers destroy-during-pending-IPC.

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

* fix(playback): carry portal headers into collection playback

Codex round-3 P1: Stalker channels opened from Favorites/Recently Viewed
resolved through StreamResolverService.resolveStalker(), which returned no
portal headers — the video path handed the header owner an empty set and
collection radio bypassed it entirely, so auth-gated streams still 403'd
from collections.

- resolveStalker() now builds the same profile as the live layout via the
  shared classifier: portal-owned streams get mac cookie/Bearer token/MAG
  UA/portal Origin+Referer, foreign hosts keep the credential-free KSPlayer
  profile (both create_link results and direct radio URLs).
- UnifiedLiveTabComponent applies the scoped override for radio before the
  audio element gets its URL (ownership claimed before awaiting the IPC,
  round-2 lesson), and clears it on close and destroy.
- Regression tests: resolver header profiles for portal-host and foreign
  streams; unified tab radio apply-then-clear.

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

* fix(playback): release the radio override when a new selection mounts no player

Codex round-4 P2: after radio installed its credentials, selecting an item
that never mounts a player surface (external video playback, failed
resolution) left the old Cookie/Authorization installed — no
WebPlayerViewComponent, close, or destroy cleanup runs on that path. Both
radio hosts (unified collection tab and the Stalker live layout, which has
the identical hole) now release the previously owned radio scope at the
start of every new selection; the slot-ownership semantics keep this a
no-op when another playback already owns the override. Regression test in
the live-layout spec pins the failed-selection path.

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

* docs(playback): state the exact override release points

Codex round-5 P2 flagged that the media 'ended' event does not clear the
scoped override while the player stays mounted. That is deliberate, not a
gap: a mounted player still owns the session — replay or a seek into an
unbuffered range must keep working against a gated stream, and clearing on
'ended' would 403 exactly the streams this PR fixes. The credentials only
ever travel to the exact origin that issued them, and every dismount path
(channel/source change, player close/destroy, radio close, playerless
selection) releases them. The security doc and the release note now say
precisely that instead of the ambiguous "cleared when playback ends".

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

* docs(playback): fit the release note back under the 400-character cap

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

---------

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