mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
e9eca1c38674113aa5413c980563077437fe180b
293
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e9eca1c386 |
chore(deps): upgrade Angular to 22.1 and Nx to 23.2 (#1603)
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2 * fix(deps): complete Angular migrations after rebasing on master * fix(ci): use the Node pin for Windows runtime refresh * docs(deps): synchronize the workspace-shell Node requirements |
||
|
|
ef3f98d026 |
feat(portals): posters-only cover wall for movie and series grids (#1604)
* feat(portals): posters-only cover wall for movie and series grids Add `Settings.showCoverTitles` (Settings > General, default on). Turning it off drops the title row under VOD/series covers in catalog, favorites and recent grids and reveals the title as a bottom-gradient overlay on hover and keyboard focus, pinned open for items whose cover is missing or failed. `CoverTitlesService` is the single resolver: the opt-out AND a hover-capable pointer, so touch-only devices keep their titles. Live channel grids, search results, "recently added" rails and dashboard rails always keep labels. Catalog and collection cards become keyboard buttons (role, tabindex, aria-label, Enter/Space, focus ring) and poster alt text is the title. The default-on boolean coercion moves into `settings-opt-out.util.ts` because the settings store reached the max-lines limit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep nested Remove key presses from activating the card Enter/Space on the content card's nested Remove button bubbled into the card's own key handlers: Enter opened the item before removing it and Space opened it while cancelling the removal. Only keys pressed on the card element itself now activate it. Regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep cover titles while an in-section search filters the grid The posters-only wall exempts search results because they are identified by the name the user typed; the category grid's own in-section filter is the same case, so `app-grid-list` now keeps the title row while its `searchTerm` is non-blank. Contract docs updated, regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): keep cover titles while the collection tab search is active The unified favorites/recent tab filters by its own search term, so its matches are identified by name like every other search result. The tab now opts its cards out of the posters-only wall while the term is non-blank. Contract docs updated, regression spec added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): move the card Remove control out of the button surface An interactive control nested inside a role="button" is an invalid accessibility structure. The content card's activation surface is now its own inner element and the Remove button a sibling positioned over the poster corner, labelled by its tooltip text. Spec asserts the control is never a descendant of the button. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): draw the collection card focus ring where it is not clipped The card's overflow: hidden clipped an outline drawn on the inner activation surface on every edge, so keyboard users saw no focus indication. The ring now sits on the outer card via :has(> .content-card__activation:focus-visible). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(portals): detect any hover-capable pointer for the posters-only wall `hover` describes only the primary pointer, so a touch-first tablet with a mouse or hover-capable stylus attached lost the wall. The resolver now reads `(any-hover: hover)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
17b8aa309d | fix(m3u): restore DASH playback from favorites and recently viewed (#1597) | ||
|
|
bd848aaad6 | feat(playlist): clean up selected inactive desktop sources (#1593) (#1596) | ||
|
|
62655a8b5d | feat(playlist): show desktop health indicators for network sources (#1592) | ||
|
|
fff022afe4 | fix(ui): keep detail back navigation available while scrolling (#1576) | ||
|
|
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 |
||
|
|
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 |
||
|
|
97b0264dee |
fix(xtream): render catch-up start times in the panel timezone (#1563)
* fix(xtream): render catch-up start times in the panel timezone
The `{Y-m-d:H-M}` segment of an Xtream timeshift URL is read by the panel
with `strtotime()` in ITS timezone (`server_info.timezone`), never the
viewer's. The timezone was learned in memory only, by the store's
`checkPortalStatus()`, so the Favorites / Recent catch-up resolver — which
reads the STORED playlist row — always fell back to the viewer's local
clock and asked the panel for the wrong programme (#1562).
- Normalize the panel's clock once (`resolveXtreamServerTimezone`): an
ICU-resolvable name is kept, otherwise a `UTC±HH:MM` offset is derived
from the `time_now` / `timestamp_now` clock pair, so spellings such as
`UTC+3` no longer silently mean "local time".
- Persist it on the playlist row through `transformPlaylistMeta` (no-op
when unchanged) and project it back from the payload in
`DB_GET_PLAYLIST`, so both catch-up entry points and a restart see it.
- Format with `hourCycle: 'h23'` (server midnight is `00`, never `24`) and
read timestamp-less EPG `start`/`end` strings in the panel's clock.
- Mock: `tzoffset:tzoffset` scenario with an unusable timezone name and a
+03:00 clock pair; Electron e2e covers Live TV, Favorites, a restart into
Global favorites, and the clock-pair derivation at a UTC-3 viewer.
Closes #1562
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): guard the account-info answer by playlist identity and reject rolled-over dates
Review follow-ups (Greptile):
- A source switch while `get_account_info` is in flight no longer hands
playlist A's status or clock to playlist B: the store is patched only
while the asking playlist is still selected, the timezone is persisted
under the asking playlist's id regardless, and a late failure cannot mark
the newly selected playlist unavailable.
- `parseNaiveUtcMs` reads the constructed date back, so out-of-range panel
strings (`2026-13-01 25:00:00`) are rejected instead of silently rolling
over into a real instant.
- Document that a clock-derived fixed offset is a DST-less snapshot, refreshed
by every account-info check and only ever used for non-standard servers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop a panel clock that no longer belongs to the source
Review follow-ups (Codex + Greptile):
- A metadata update or DB_UPDATE_PLAYLIST that points the source at another
server drops the persisted `serverTimezone` (payload-only) until the next
account-info check, so Favorites / Recent cannot keep rendering the OLD
panel's clock; an update that supplies a clock keeps it.
- A late account-info answer is persisted only onto a row that still points
at the panel it came from — an edit that moved the source during the
request keeps the clock the edit flow dropped.
- The PWA data source and the route-session converter carry the persisted
timezone into the store playlist, so a later response without a usable
clock has a previous value to preserve.
- Mirror the catch-up timezone contract into AGENTS.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): drop the stale panel clock inside the UPDATE statement
Review follow-up (Codex): the database worker interleaves requests, so a
read-modify-write of the playlist payload could hand a concurrent upsert's
newer payload back to the past. The `serverTimezone` removal on a server
URL change is now one `CASE … json_remove(payload, '$.serverTimezone')`
expression inside the same UPDATE, guarded by `json_valid`; the spec runs
the real statement against Electron's SQLite on the actual `playlists`
table (moved, renamed, clock-less, malformed-payload and NULL-URL rows).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(xtream): split the server-clock primitives out of the timezone util
Review follow-up (Greptile): `xtream-server-timezone.util.ts` had grown past
the 300-line file guideline. The zone-agnostic wall-clock primitives (stored
forms, Intl parts, naive parsing) now live in `xtream-server-clock.util.ts`;
the timezone util keeps the Xtream policy and re-exports the public helpers,
so every import and the spec are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): offer the learned panel clock to storage on every check
Review follow-up (Codex): a transient storage failure left the clock in the
store but not on the row, and the next check compared the answer with the
in-memory value and never retried. The resolved timezone is now always
handed to `transformPlaylistMeta`, whose row-level equality check keeps the
common case a read without a write; a failed write is retried by the next
account-info check.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): apply an account-info answer only to the panel it came from
Review follow-up (Codex): an in-place edit keeps the playlist id while
moving the source, so an answer already on the wire for the OLD panel
passed the id-only guard and patched the new panel's status and clock into
the store. One `answersFor(candidate, credentials)` predicate now gates the
store patch, the error path and the persisted-row transform alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): never report another panel's status for the selected playlist
Review follow-up (Greptile): callers gate content initialization on the
value `checkPortalStatus()` returns for whatever is selected NOW. When the
answer no longer describes the selected playlist (source switch or in-place
edit during the request), the store's own verdict about the current
selection is returned instead of the old panel's status — on success and on
failure alike.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): persist the panel clock with one conditional UPDATE
Review follow-up (Codex): `transformPlaylistMeta` reads the row and then
upserts it whole, while the Xtream edit dialog saves through
`DB_UPDATE_PLAYLIST` outside `PlaylistsService`'s queue and the database
worker interleaves requests — an edit landing between that read and the
upsert was silently undone.
Persistence now goes through `IXtreamDataSource.rememberServerTimezone`:
- Electron: new `DB_SET_PLAYLIST_SERVER_TIMEZONE` worker op — one UPDATE
that `json_set`s the payload only while the row still points at the
request's connection and does not already carry the value; a malformed
payload is never rewritten (CASE, not AND, so json_extract cannot run
before json_valid). Wired through the worker types, main handler,
preload, bridge interface, both IPC contract tables and
`DatabaseService.setXtreamPlaylistServerTimezone`.
- PWA: `transformPlaylistMeta`, whose read and write share one IndexedDB
readwrite cursor transaction, plus the localStorage copy.
The store no longer injects `PlaylistsService`; it offers the resolved clock
to the data source and keeps only its in-memory guards. Real-SQLite coverage
for the op (fresh / same / moved / NULL / malformed / missing rows),
delegation specs for both data sources, docs updated.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): keep the stored panel clock across clockless full upserts
Review follow-up (Codex): a `PlaylistsService` mutation that read the row
before `DB_SET_PLAYLIST_SERVER_TIMEZONE` landed and upserted afterwards
replaced the payload with its clockless snapshot. `DB_UPSERT_APP_PLAYLIST(S)`
now carry the STORED clock into a snapshot that has none while the row still
points at the same connection (`playlistConflictUpdate`, nested CASE so the
json_* readers never run on a malformed payload); a snapshot with its own
clock, or one that moves the source, wins as is. Real-SQLite coverage for
kept / moved / own-clock / batch rows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(release): split capture-navigation under the max-lines cap
`tools/release/capture-navigation.ts` had grown to 567 counted lines, past
the 400-line rule, which failed `release-tools:lint` and — because the file
was not in the baseline — the max-lines baseline test on master and on
every PR branched from it. The 19 named setup actions are now grouped by
subject over one leaf module of shared page helpers:
- `capture-navigation-helpers.ts`: playlist-id registry, dialog handling,
navigation moves, `settleUi`
- `capture-navigation-setup-actions.ts`: add-playlist dialogs, settings
sections, remote control
- `capture-navigation-portal-actions.ts`: portal catalogs, live lists,
alternative sources (the two identical live-category flows share one
helper)
- `capture-navigation-download-actions.ts`: the download manager shots
- `capture-navigation.ts`: the `runAction` dispatcher, theme switching and
the re-exported API the seeding driver and the capture script import
Actions call their siblings directly instead of recursing through
`runAction`, so no module depends on the dispatcher. The action vocabulary
is unchanged (same 19 names, same waits and timeouts); every file is under
300 lines and the new modules are listed in the `release-tools` lint target.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(electron): move the panel-clock SQL into its own operations module
Review follow-up (Greptile): the timezone persistence, invalidation,
upsert-preservation and row projection had landed in
`playlist.operations.ts`, a baselined 1,000-line file. They now live in
`playlist-server-timezone.operations.ts` (155 lines) — the three SQL
shapes plus the payload projection — and the playlist operations compose
them; the baselined file shrinks by 107 lines. Behaviour and the
real-SQLite coverage are unchanged.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
|
||
|
|
15c2ac1f39 |
feat(packaging): add AppManager discovery metadata to AppImages (#1559)
* feat(packaging): add AppManager discovery metadata to AppImages * test(xtream): restore live queue URL service mock * test(xtream): extract live layout component stubs |
||
|
|
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> |
||
|
|
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> |
||
|
|
0dcfba7045 |
fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555)
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface The second report in #1458 ("all channels disappear after clearing the playback history, reset does not bring them back") was not data loss: the history write never touches playlist items. The reporter's screenshot shows a collapsed channel rail, a state persisted under one localStorage key shared by the M3U player, the Xtream/Stalker live layouts and the favorites/recent live tab. It survived restart, "Remove all playlists" and re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B. - LiveLayoutSidebarStateService keeps the state per surface (m3u / portal / collection) under live-sidebar-state:<surface>; the M3U player now goes through the service instead of its own signal. The legacy shared key is forgotten on startup and never read, so the update itself restores the list for everyone who got stuck. - The workspace header renders a view_sidebar toggle on every route that renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so the control exists in both states instead of disappearing with the rail. Collection pages keep their own toggle beside the content switch. - While the rail is collapsed and nothing plays, every live host shows app-channel-list-hidden-state (title, shortcut hint, full-size "Show channels list" button) instead of asking to pick from a list that is not on screen. app-portal-empty-state gained optional hint/action inputs. - New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales. Tests: service, empty-state, hidden-state and header component specs, a separate video-player-sidebar spec (the main M3U spec sits at the test line budget), and an Electron E2E covering history clearing, restore via button/header/shortcut across restart and re-import, per-surface scoping against an Xtream portal, and legacy-key cleanup. Refs #1458 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(live-tv): mirror the EPG offset setting in the sidebar spec mock Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the new sidebar spec was cloned from the movie-gate harness before that field landed, so its playing-channel case threw inside the EPG effect. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web-e2e): scope the Stalker radio rail toggles to the rail The workspace header now carries a second "Hide/Show channels list" toggle, so the role+name locators matched more than one button and tripped Playwright's strict mode. Target the rail's own chevron and the floating restore button, and assert the header toggle mirrors the state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones Codex review follow-ups on #1555: - The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent collection page had no handler; only the routed M3U/Xtream/Stalker live layouts did. The page now toggles the collection surface while its live tab is on screen, with the same typing/inert guards as the other hosts. - At the phone breakpoint the header already holds the drawer toggle, switcher, search and Add; the live rail is a bottom drawer with its own toggle there, so the header rail toggle is hidden below 640px. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API master (#1554) added `XtreamLiveChannelNavigationService` and `stalker-live-navigation.ts`, which expand the rail through `sidebar.setState('expanded')` on the pre-split signature. Point them at the `portal` surface and update their specs; drop the now-unused hidden-state stub from the Xtream layout spec, which master pushed to the max-lines budget. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
61b06b9f31 |
fix(portals): preserve live channel navigation while browsing (#1554)
* fix(portals): preserve live channel navigation while browsing * test(portals): await media source assertion in remote E2E * fix(xtream): capture destination queue for live auto-open |
||
|
|
5a8c5ca4a4 |
fix(stalker): keep live search within the selected category (#1552)
* fix(stalker): keep live search within the selected category * test(stalker): assert retained video ownership without source timing * test(stalker): distinguish paged All Items from the initial cache grid * fix(stalker): reveal remote selections in uncached search results * test(stalker): wait for category rows and retain settled playback |
||
|
|
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 |
||
|
|
249cd38a66 | fix(stalker): align season markers and preserve episode loading (#1545) | ||
|
|
eb3cf89f62 | fix(stalker): let media transports control seek byte ranges (#1544) | ||
|
|
d9d6f49757 | feat(playback): slide-in channel list for fullscreen playback (#1519) | ||
|
|
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 |
||
|
|
eba68d687e | fix(xtream): scope category bulk actions to search results (#1534) | ||
|
|
0245d73d78 | feat(portals): make connection cooldown configurable in desktop settings (#1536) | ||
|
|
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> |
||
|
|
2fc1bd5601 |
fix(stalker): keep live playback when switching the ITV/radio category (#1517)
Switching the Live TV or radio category in the shell context panel tore down the Stalker player: `onStalkerCategoryClicked` cleared the selected item for every section, and the live layout gates its player on `selectedItem`. Xtream live (#936) and M3U groups already keep the channel playing across a category/group switch. - Context panel: return before `clearSelectedItem()` for `itv`/`radio`; VOD/series clicks still drop the open detail before navigating. - Live layout: the category-change reset effect no longer wipes the playing channel's short-EPG fallback or cancels a fallback load in flight; only a section change (itv <-> radio) does that now. - Regression coverage in the context panel spec, the live layout spec and a new web E2E scenario; docs and a `.changes/` note added. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
fa9084fca3 |
feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized / fullscreen), Electron only, mirrored into the main-process config by SETTINGS_UPDATE and applied at the next window creation. `--fullscreen` forces one fullscreen launch (consumed by the first window). F11 toggles OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on Windows/Linux where the title bar is hidden — and is skipped while the player owns document.fullscreenElement. attachWindowStateEvents tracks native and HTML fullscreen as two flags, since Electron leaves only the HTML state when the window was already natively fullscreen. macOS ignores the constructor `fullscreen` option on a hidden window, so ready-to-show repeats the request after show(). Toggles are decided by an observe-only, event-fed tracker (native-fullscreen-transitions.ts), never against isFullScreen(). Closes #1455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
bee7df1e02 |
feat(playlist): auto-detect import method that parses pasted provider messages (#1445)
Adds an "Auto-detect" method to the Add playlist dialog: paste the message a provider sent — links, Xtream credentials, a MAC address with device identity — and a deterministic parser recognizes the source(s) and prefills the matching import form. - detectProviderImportCandidates (libs/shared/interfaces) extracts URLs, MAC addresses and labeled fields, classifies each finding as Xtream, Stalker or an M3U link/body, and returns ranked candidates. Pure and synchronous. - Built against a corpus of 19 real reseller handouts kept verbatim in the spec: Unicode "font" labels, arrow/dingbat separators, separator-less hex serials, dual device IDs, multi-MAC lists, bare three-line handouts, and a guard so a parental PIN is never read as the account password. - Detection only proposes: the target form's own validation and behavioral probes remain the sole path into the store, and no pasted text leaves the app. Passwords are masked on candidate cards, including query and HTTP Basic userinfo forms. - Covered by parser, component and dialog unit tests plus two web E2E specs for the paste → pick → prefilled form workflow; i18n for all 19 languages. |
||
|
|
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> |
||
|
|
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> |
||
|
|
00000de798 | feat(downloads): align manager cards, queue rows, and headings with the rail design language (#1448) | ||
|
|
61fca6f016 |
fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)
An Xtream activity row is built from its `content` row, and the catalog endpoints that create those rows carry only a title and a poster. So the dashboard hero and the recommendations rail rebuilt their TMDB query from the display title alone, while the detail view had searched with the original title, the release date and often a TMDB id. Without a year `pickConfidentMatch` requires a globally unique exact title, which common titles never satisfy — "Inside Out" matches several films and resolves to nothing, every time. Three `content` columns close that gap next to the existing `backdrop_url`: `tmdb_id`, `release_year`, `original_title`. The detail views back-fill them from what is on screen, the activity SELECTs project them, and `buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same facts through its stored entry, and rows with neither keep the title-only fallback. Measured against a real profile before building: of 58 distinct Xtream movie/series activity rows, 16 (28%) produce a year-less key — the cohort where a miss is guaranteed rather than likely. Contracts worth preserving: - Per-column, never overwrite. Enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one forever. - `release_year` is the year the PROVIDER stated. The TMDB merge fills the date field when the provider left it empty, so it marks its own substitution with `tmdb_supplied_release_date` and the extractor skips those — making contamination structurally impossible rather than avoided. - The id is stored unvetted: every consumer re-gates it through `assessProviderId`, which re-decides per lookup where a write-time verdict would be permanent. - No media-type column — for Xtream the catalog files movies and series apart, so `content.type` already is the media type. Worker requests now await `getDatabase()` before dispatching. The renderer loads before `initDatabase()` and the worker opens the database file without running migrations, so a query issued during startup on an upgraded install could otherwise hit a schema whose new columns do not exist yet. Not covered: the PWA, whose catalog cache is rebuilt from the API on every load, so a stored id would never outlive the detail view that resolved it. |
||
|
|
78587f95ba |
fix(portals): return from a portal handoff without losing the collection view (#1435)
* fix(portals): return from a portal handoff without losing the collection view "View in portal" left `stalkerReturnTo` pointing at the collection URL, and the portal detail's back affordance re-navigated there with `navigateByUrl()`. That starts a stateless history entry, but the collection's active tab, scope and open inline detail live only in `window.history.state` — so back landed on the default `live` tab with the title closed, and the portal page stayed one browser Back away. The handoff now also sets `stalkerReturnByHistory`, and both Stalker back handlers step back a single history entry instead. That entry is the one the handoff itself pushed, so it restores the collection exactly as the user left it and adds nothing to the history stack. The flag is set only by this builder and only alongside `returnTo`, so the dashboard handoff and every other `stalkerReturnTo` caller keeps re-navigating. Reported by Codex on #1422 after it merged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): scope the history-return marker to its own handoff `openStalkerItem` is consumed on arrival, but `stalkerReturnTo` and the new `stalkerReturnByHistory` stay on the history entry, and a Stalker detail opens in place without pushing one. So after Back + browser Forward the same entry can host a different title, whose back affordance would follow the leftover marker out to the collection instead of just closing it. The marker now carries the handed-off item's identity instead of a bare `true`, and a marker that does not match the open title is treated as stale: it suppresses the whole return contract, so back simply closes the detail. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(portals): share the Stalker back-navigation decision Both back handlers duplicated the marker/`returnTo` precedence verbatim, and the catalog view kept its own copy of the identity normalization the marker binding mirrors — two places for one rule to drift. `resolveStalkerBackNavigation()` now owns the decision and both handlers just apply it, while `stalkerItemIdentity()` delegates to the shared `normalizeStalkerHandoffIdentity()`. Behaviour is unchanged; the precedence gains direct unit coverage instead of only being exercised through the two components. Follow-up to Greptile's review notes on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): make the return marker one-shot and id-shape agnostic Two defects in the marker binding, both reported by Codex on #1435: The comparison identity read only `item.id`, but the marker is built from `extractStalkerItemId()`, which also accepts `stream_id`/`series_id`/ `movie_id`. A collection row carrying only `movie_id` therefore compared against an empty identity, and since a marker was present the handler returned without stepping back or re-navigating — the back affordance simply stopped working. It now follows the same field order. Binding also only fixed a *different* title reopened on the entry: selecting the original title again still matched. Honouring the marker now retires both return keys from the entry, so a browser Forward cannot replay the handoff. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): bind the return marker to the id the detail can report The previous attempt widened the comparison to `extractStalkerItemId()`'s field set, but `buildStalkerSelectedVodItem()` — which every opened detail goes through — derives `id` from `id ?? stream_id` and drops `series_id`/`movie_id`. The wider lookup therefore ran after the lossy normalization and still could not match, leaving the back affordance doing nothing for those rows. The marker is now bound to the identity the detail will actually report, and a row whose id cannot survive normalization gets no marker at all: the handoff falls back to re-navigating via `stalkerReturnTo`, which works. The earlier regression tests hid this by injecting a `movie_id`-shaped selection directly, which normalization can never produce; they now build the selection through `buildStalkerSelectedVodItem()`. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): retire the return contract after a native browser Back Leaving the handed-off portal entry with the browser's own Back runs no back affordance, so nothing consumed the marker. A Forward replay then reopened the catalog with the contract intact, and opening the same title again matched the identity — its Back exited to the collection instead of closing the freshly opened detail. `CategoryContentViewComponent` now retires the contract whenever it lands on the entry with no handoff item and no detail open: the handoff is over, so anything opened from the list afterwards is a fresh selection. The guard on an open detail keeps arrival itself from retiring a contract it still needs. Also documents, per Greptile, that a `none` decision still closes the detail and only suppresses the navigation. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep alternate-id rows on the history return The previous revision let a row carrying only `movie_id`/`series_id` fall back to re-navigating, which reproduced the exact defect this PR exists to fix: the new entry has no `collectionViewState`, so the collection reopens on its default tab with the title closed. The builder now pins the resolved id onto the handoff state item when the raw row carries neither `id` nor `stream_id`, so `buildStalkerSelectedVodItem()` reports an identity the marker can bind to and those rows get the same history return as every other one. An existing id is never overwritten. Also scopes the arrival-side retirement to handoffs that actually set the marker, so a plain `stalkerReturnTo` caller such as the dashboard keeps its behaviour — I had made that unconditional, which contradicted the scope this PR claims. Reported by Codex on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(portals): drop a redundant guard in the identity normalizer `split(':')` always yields at least one element, and the workspace does not enable `noUncheckedIndexedAccess`, so the optional chain and `?? ''` fallback were unreachable rather than type-required. Behaviour is unchanged. Spotted by Greptile on #1435. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0f7d529b40 |
refactor(portals): split the Stalker collection detail component (#1433)
StalkerCollectionDetailComponent was 529 non-blank lines, well over the 400-line production maximum, and passed lint only because it sat in tools/eslint/max-lines-baseline.mjs — a list CLAUDE.md says must only shrink. Extract four cohesive units into siblings in the same folder: - stalker-collection-playback.controller.ts — playback ownership, inline playback and the external-player fallback - stalker-collection-detail-mode.ts — pure item / detail-mode / category resolution (movie vs regular series vs embedded VOD series vs lazy is_series) - stalker-collection-favorites.controller.ts — favorites resource, sync and toggle - stalker-collection-store-snapshot.ts — store snapshot capture/restore Both controllers follow the existing StalkerVodPlaybackController model — plain classes taking a config object, constructed in a component field initializer — rather than DI services. The playback controller needs the component's `item` input signal and the favorites controller's rxResource needs an injection context; a field initializer supplies both for free, where a DI service would have needed an extra effect to feed the input across. Public template bindings and the component's public API are unchanged: the signals are re-exported by reference, so neither the template nor the spec sees the split. The spec is untouched and its 11 tests, plus the rest of the project suite (272), pass exactly as before. Regenerate the max-lines baseline so the file drops off it, and update the three doc references that named resolveDetailMode() and resolveSelectedCategory() as methods on the component. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0cba49f3e2 | fix(dashboard): keep every catalog row per title key when matching (#1425) | ||
|
|
e3f72f7dce | perf(portals): fast-fail requests to portal hosts that stopped answering (#1421) | ||
|
|
2d7811eb5f | feat(portals): add "View in portal" action to inline collection details (#1422) | ||
|
|
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>
|
||
|
|
5ad86e094c |
refactor(stalker): drop unused limit state from selection feature (#1402)
* refactor(stalker): drop unused limit state from selection feature The `limit` field and `setLimit` method lost their last consumers when catalog pagination was replaced by infinite scroll (#1392/#1395): the facade no longer calls setLimit and getTotalPages is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): retire pagination API from the store baseline The compatibility baseline still told future refactors to preserve `limit`/`setLimit` and `getTotalPages`, all three of which are gone with the catalog pagination removal. Record them in the doc's existing Removed section instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
5b211faf73 |
feat(remote-control): cover live collections, honest volume, status resets (#1399)
* feat(remote-control): cover live collections, honest volume, status resets Remote control previously worked only on the three routed live layouts (M3U player, Xtream live, Stalker ITV); playing live TV from favorites, recently viewed, or the global collections left the mobile remote inert. - Wire channel up/down, number select, and status publishing into the unified live tab, covering per-portal and global favorites/recent for M3U, Xtream, and Stalker; navigation follows the search-filtered, sorted list exactly as rendered (shared deriveVisibleFavoriteChannels) - Treat non-live status updates as snapshots in the main process so stale now-playing fields are cleared instead of merged forever - Publish a reset snapshot from every integration on destroy, so leaving a live view clears the remote instead of freezing it - Report M3U supportsVolume only for built-in inline playback and no-op volume commands while MPV/VLC/Embedded MPV owns the audio - Publish live status for Stalker radio (same layout, same handlers) and fix its channel-number lookup for non-numeric radio ids Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH * fix(remote-control): review-loop hardening for status honesty - Make the non-live status update an authoritative reset in the main process: only portal survives, supportsVolume is forced false, stray now-playing fields from callers are dropped (Copilot review) - Stop Stalker radio status from leaking an unrelated TV channel's EPG: the ITV-keyed bulk cache survives itv->radio navigation and Ministra ids collide across the two lists, so EPG fields publish for itv only - Publish the reset snapshot when the M3U active channel clears in place (e.g. quitting external MPV), not only on route destroy - Consider a live external session in the M3U volume gate: a diagnostic-recovery MPV/VLC launch owns the audio even while a web player is configured; republish capability on session start/end - Share one REMOTE_CONTROL_RESET_STATUS constant across all four integrations instead of four hand-copied literals Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH * fix(remote-control): external session outranks DASH in M3U volume gate The managed clear-DASH MPV/VLC fallback (Shaka browser-support preflight failure) leaves activeChannelIsDash() true while the external session owns the audio, so the DASH shortcut bypassed the session check and kept advertising remote volume support. The live-session check now precedes the DASH branch; radio stays first because its inline audio element is always mounted and remains audible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmMT33wgK52QL6JAz468eH --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
6ad9f3ff8a |
feat(stalker): discover portal endpoints on add and edit (#1391)
* feat(stalker): discover portal connection on edit * docs(stalker): document smart endpoint discovery * fix(stalker): make edited connection persistence atomic * fix(stalker): serialize edit discovery * fix(stalker): fence all edit authentication * fix(stalker): serialize overlapping edits * fix(stalker): hydrate playlist identity before edit * test(stalker): await edit hydration * fix(stalker): release abandoned edit fences * fix(stalker): reject stale repairs before discovery * fix(stalker): fence stale portal modes * test(stalker): align simple portal session guard * fix(stalker): reject superseded portal responses * test(stalker): await settled append failure * fix(stalker): retain abandoned auth fences * fix(stalker): fence abandoned discovery retries * fix(stalker): retire restored repair overrides * fix(stalker): verify repair override retirement * fix(stalker): preserve edit-owned repair tokens * fix(stalker): defer repair retirement during edits * fix(stalker): fence repair history reads * fix(stalker): fingerprint portal URL credentials * fix(stalker): persist submitted identity after navigation * fix(stalker): merge late connection saves * fix(stalker): keep edits off Xtream save path * fix(stalker): preserve concurrent edit state * fix(stalker): reject replaced late edit targets * fix(stalker): guard every resolved edit write * fix(stalker): make pwa edit guard transactional * fix(stalker): migrate pwa flags transactionally * fix(stalker): reserve pwa edits across tabs * fix(stalker): coordinate playlist replacements with edit * fix(stalker): reserve lazy repairs across tabs * fix(stalker): drain local repair before edit lock * fix(stalker): block queued repairs during edit drain |
||
|
|
cf74f7e4a0 |
feat(stalker): append portal pages on scroll and drop pagination everywhere (2/2) (#1395)
* feat(stalker): append portal pages on scroll and drop pagination everywhere Second and final PR of the pagination removal (plan: .plans/2026-08-09-infinite-scroll-catalog.md). Stalker VOD/series grids now feed the shared infinite-scroll contract from server-paged appends: portal pages (server-side size, typically 14) accumulate into one deduplicated paginatedContent list, page 1 replaces it for the skeleton, hasMoreContent derives from accumulated length vs total_items (portals that ignore requested page sizes still terminate), and a failed page > 1 keeps the accumulated pages on screen with a tail retry (retryContentPage reloads the same page; loadMore refuses to skip past an unresolved append error). The facade splits the resource's loading flag by page — skeleton for page one, tail spinner for appends — and keeps per-identity scroll offsets for Stalker's INLINE detail round trips; the shared view re-arms its one-shot restore when a detail opens in the same component instance. The transitional supportsInfiniteScroll flag and every paged member are deleted from PortalCatalogFacade; the shared catalog view loses the mat-paginator, the ?page= round-trip, and the paged query-param branch. The ITV all-channels grid becomes a client-side render window over the cached full list (the app's last paginator), and Stalker search pages past its first capped request via the layout's nearEnd, with a progress guard for portals that report no usable total. Validation: 1600 unit tests across 7 projects green (new: vod/series append + failed-append retry, facade loading split/loadMore guards/scroll snapshots, ITV window model, compat selector update); catalog-sorting e2e 5/5 (Stalker spec rewritten to scroll model with p>=2 network asserts and an inline-detail spot-restore round trip; one unrelated nav-timeout flake reproduced only under parallel machine load), search e2e 16/16, web stalker e2e green (all-channels grid asserts the windowed count instead of a paginator range label); lint clean; release note added and validated; stalker-portal.md, CLAUDE.md, and ui-guidelines updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): reset paging on content-type switch and never skip failed search pages Round-1 review findings on #1395: 1. Codex P1: switching /vod -> /series with the same category id ('*' on both section roots) left page > 1 in place — setSelectedContentType did not touch paging and setSelectedCategory('*') no-ops on an unchanged id — so the new type's FIRST response was treated as an append onto the old type's accumulated list. The type setter now resets the page (and no-ops entirely when the type repeats, keeping detail round-trip restores intact). 2. Greptile P1 + Codex P2: a failed search append left searchHasMore true, so the next near-end advanced to page N+1 and permanently omitted the failed page. The search now tracks searchAppendError: a failed append keeps the accumulated pages and the next near-end RETRIES the same page; a failed fresh search (page 1) clears the previous query's cards instead of rendering them under the new term (Codex P2). The page-merge/failure logic moved into applySearchPageSuccess/Failure methods: Angular resource() never re-fires on params changes in this repo's template-less jest harnesses (store-hosted resources do), so the extracted methods carry the unit coverage — accumulation + dedupe, no-total progress guard, retry-not-skip, fresh-failure clear — plus a selection spec for the type-switch page reset. portal-stalker-feature 260, portal-stalker-data-access 464, lint clean; catalog-sorting e2e 5/5 and web stalker e2e green. search.e2e shows machine-load nav-timeout flakes on unrelated M3U/live specs (a runaway third-party process pegs the host CPU); CI provides the clean independent run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): include the portal in the search paging identity Round-2 Codex P1 on #1395: Angular reuses the search route across /stalker/A/search -> /stalker/B/search, and the paging identity covered only term + filter — the page number and accumulator survived the portal change, so the next near-end fetched portal B at the OLD page number and appended it onto portal A's results while skipping B's first page. The active playlist id now joins the page-reset identity, the resource params, the stale-response guard, and the layout's near-end reset key. Regression spec: switching the active playlist on a reused route resets the page to 1 and rotates the scroll reset key. portal-stalker-feature 261, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): end paging on no-progress appends even with a reported total Round-3 Codex P2s on #1395 (same defect in both accumulators): the no-progress guard only applied when the portal reported no usable total_items. After a mid-list portal mutation, deduplication can leave the unique list permanently shorter than the claimed total — hasMore then stayed true forever and every scroll crossing kept requesting pages past the end of the data. An append that adds no unique items now ends paging in both places: the catalog clamps totalCount to the accumulated length (hasMoreContent turns false and the count badge reflects what is actually reachable), and the search requires append progress in the total-backed branch exactly like the no-total branch. Regression specs cover a duplicate page under a larger claimed total for both. portal-stalker-data-access 465, portal-stalker-feature 262, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): explicit search retry control and per-portal scroll identities Round-4 findings on #1395: 1. Greptile P1: with the results pane parked at the bottom, repeated append failures exhausted the scroll auto-fill budget while the near-end latch stayed armed — the retry path was reachable only through another nearEnd event that could never fire. The search page now renders an explicit retry control under the results whenever an append has failed (same wording as the catalog grid tail), wired to the existing retry-same-page path, so recovery never depends on producing another scroll event. 2. Codex P2: the facade's saved-scroll map survives a same-config portal switch (the vod/series route provider is reused across /stalker/A -> /stalker/B), and its identity lacked the playlist — portal A's offset could restore onto portal B's unrelated catalog. The playlist id now leads the scroll identity; regression spec covers the cross-portal non-restore and the return restore. portal-stalker-feature 263, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): restore the search results scroll after an inline detail Round-5 Codex P2 on #1395: the search layout destroys the results container while an inline detail is shown (showDetails) and recreates it at offset zero — with the new multi-page accumulation a user could load several pages, open a result far down the list, and land back at the top on close even though the accumulated results survived. SearchLayoutComponent now exposes a scroll handoff for hosts whose details replace the results (getResultsScrollTop / restoreResultsScrollTop on the container it owns), and the Stalker search captures the offset when a detail opens and restores it one-shot after the container is recreated on close. Regression specs cover the layout handoff methods and the capture/restore round trip. portal-shared-ui 90, portal-stalker-feature 264, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): clear accumulated search results for unsearchable portals Round-6 Codex P2 on #1395: the loader's early returns (deleted or malformed playlist on a reused route) predate the accumulator and returned [] without touching it — the previous portal's cards kept rendering under the new context once loading settled. Every no-portal early return now goes through resetSearchAccumulator(), which empties the accumulated list and both paging flags; the short-term path uses it too (and now also clears a stale append error). Regression spec covers the full reset. portal-stalker-feature 265, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d73acd6bfc | fix(playback): clarify external player launch feedback (#1388) | ||
|
|
8442747c37 |
feat(xtream): replace catalog pagination with infinite scroll (1/2) (#1392)
* feat(xtream): replace catalog pagination with infinite scroll Xtream movie/series/live catalogs now load continuously while scrolling instead of paging. The selection store keeps a growing visibleCount render window over the in-memory catalog (initial 50, +50 per load) plus a saved scroll state, so opening a title and going back restores the exact spot. A shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the bottom (edge-triggered, mirroring search-layout) and auto-fills viewports taller than the initial window by measuring container overflow — capped at 10 self-initiated loads per list identity, with a ResizeObserver re-check. The shared CategoryContentViewComponent branches on the transitional PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker keeps its server-driven paginator and ?page= round-trip untouched until its append lands (PR 2), after which the paged facade members and the flag are deleted. grid-list loses its dead built-in paginator and gains tail states (append spinner, retry-on-error) plus content-visibility on cards. The in-portal search results reuse the search layout's nearEnd hook to window their full result set instead of rendering it unbounded. Validation: portal-xtream-data-access (234), portal-xtream-feature (357), portal-catalog-feature (22), portal-shared-ui (77, incl. new directive spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5 (new scroll-growth + spot-restore test against the large 200-item mock scenario, Stalker paged spec unchanged); search e2e 16/16; lint green; release note added and validated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): auto-fill search results and refresh the near-end latch Review findings from #1392: the in-portal search window could stall at its first 60-item chunk when the rendered cards did not overflow the container — nearEnd only fired on real scroll events (Greptile P1), the search layout's edge latch survived a result-set replacement (Codex), and the shared directive's latch went stale after appended content moved the bottom out of the threshold (Codex). The search layout now drives its results container through the shared InfiniteScrollDirective instead of a bespoke scroll handler: the measured auto-fill reveals further chunks on tall viewports without any scroll, the reset key (search term) and item-count changes refresh the latch, and new nearEndHasMore/nearEndAppending inputs let consumers gate emissions. Xtream search wires them for both modes — this also fixes the same latent tall-viewport stall in the global search's 100-item pages — and the Stalker search page (single capped request until PR 2) sets hasMore=false. The directive's fill check now refreshes the latch from the measured state, so an End-key jump straight to the new bottom is a genuine crossing again. New coverage: directive stale-latch regression, search-layout auto-fill + hasMore gating, in-portal window reveal/reset in search-results. Reruns: portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature green; search e2e 16/16 (fresh Playwright report verified); lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): re-measure search auto-fill on the rendered window, not the total Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the search layout bound the constant result-set total to the infinite-scroll directive's item count, so once the in-portal window grew 60 -> 120 no tracked input changed, no further overflow check was scheduled, and results beyond 120 stayed unreachable on tall viewports. The layout now takes an explicit nearEndRenderedCount (falling back to resultsCount for consumers that render everything they report) and feeds THAT to the directive. Xtream search passes the windowed slice length for in-portal mode and the loaded-set length for global mode. Regression specs: layout re-measures when the rendered window grows while the total stays constant; the component exposes the rendered count following the window. portal-shared-ui 81, portal-xtream-feature 357, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): include filter state in the search reset identity Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were keyed on the search term alone, so a filter-only transition (type filters or the hidden-categories toggle) replaced the result set without resetting them — a jump straight back into the threshold could be swallowed. The search layout now accepts an explicit nearEndResetKey (defaulting to the term); Xtream search supplies term + type filters + excludeHidden. Regression specs: layout latch resets on an identity change without a new term; the component identity changes on filter-only and hidden-toggle transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): refuse global-search appends while an edited query debounces Round-4 Codex P2 on #1392: after the reset-identity change, the layout's auto-fill can request more results inside the 300ms search debounce. The append then ran with the freshly edited term but the old result count as offset, interleaving a page of the new query into the old query's visible results until the offset-zero search landed. An append now only continues the LAST EXECUTED search: the append guard additionally requires the effective term to equal lastGlobalSearchTerm, so pagination stays suppressed from the first keystroke until the fresh search replaces the result set. Regression spec covers the mid-debounce refusal; the two existing append specs state their precondition explicitly. portal-xtream-feature 359, lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop Round-5 Codex P2s on #1392: 1. The single saved-scroll slot lost the first tab's position on a VOD -> Series -> VOD round trip — the series view's destroy hook overwrote it with series coordinates. Snapshots are now kept per selection identity (bounded to the 8 most recent), so a detour's save can never destroy another list's spot. Store API is unchanged. 2. The fixed 10-load auto-fill budget could strand items on a viewport large enough that ten chunks still do not overflow — with no scrollbar, no real scroll event can ever fire. The auto-fill now terminates on lack of progress instead: loads continue while they grow scrollHeight (until genuine overflow hands off to scroll events) and stop after three consecutive loads without growth, which only a source that reports more but renders nothing can produce. Regression specs: VOD/Series round trip keeps both snapshots; growth keeps filling past the old cap and stops at overflow; no-growth stalls stop at three; reset key clears the stall guard. portal-shared-ui 83, portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint clean. CLAUDE.md wording updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
1a6af75761 |
feat(settings): per-section pages with unsaved-changes bar (#1384)
* feat(settings): split settings into per-section pages with an unsaved-changes bar Replace the single scrolling settings page with routed section pages (/workspace/settings/:section): the context-panel rail links each section, only the active section renders, and unknown or capability-gated sections redirect to General. The shared form lives on the parent component, so staged edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the always-visible footer Save button. Rail links navigate with replaceUrl so Back still leaves settings in one step. Along the way: - delete the unreachable settings dialog mode and the dead AppPortalNavigationActionsService with both of its never-injected DI tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS) - delete the scroll-spy directive and pendingScrollTarget plumbing - revive the EPG panel's "Open EPG settings" empty-state button as a deep link to /workspace/settings/epg; the M3U player now reports m3u-needs-setup only when the channel has no programmes and no EPG source exists in settings or on the playlist itself - load TMDB cache stats when the Metadata page opens (the section component now only exists while its page is open) - add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): confirm before leaving with unsaved changes Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with a three-action dialog: save and leave, leave without saving, keep editing. The guard only intercepts leaving the settings AREA — section switches share the one settings form and pass unconditionally, so the dialog can never nag while moving between pages. A failed save cancels the navigation instead of silently dropping the edits it promised to keep; leaving without saving also reverts the live theme preview. Save-and-leave is disabled while the form is invalid, with a hint explaining why. New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stage cover size and EPG view mode; adapt e2e to section pages Cover size and EPG view mode were the only two controls that persisted eagerly on click, which made Discard (and leave-without-saving) unable to revert them: hydrateFromStore() faithfully reloaded the just-persisted edit. They now stage in the form like every other setting and reach the store on Save. Review finding by Greptile (P1) and Codex. E2E suites that walk through settings are updated for one-section-page rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the staged cover size (downloads asserts the dataset after Save); the EPG icon fallback test saves before leaving settings so the new unsaved-changes dialog does not block its navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f58460380f |
fix(stalker): fall back to short EPG when bulk EPG misses the current programme (#1386)
* fix(stalker): fall back to short EPG when bulk EPG misses the current programme Some portals' bulk get_epg_info returns only future programmes — the one airing now is absent. The ITV channel-list previews read exclusively from that bulk map, so every row showed 'No program information available', and the EPG panel preferred any non-empty bulk list over the short-EPG fallback, so it showed upcoming shows with no 'on now' entry. Recently Viewed uses get_short_epg per channel, which is why the same channel worked there. Panel: merge the short-EPG fallback into the bulk list instead of either/or, and trigger the fallback whenever the bulk list has no currently airing programme (not only when it is empty). Rows: new throttled StalkerEpgPreviewQueue (mirroring Xtream's EpgQueueService — bounded concurrency, inter-request spacing, 5-minute cache including empty results, reset on playlist switch) fetches get_short_epg for rendered channels the settled bulk guide cannot answer. Docs: stalker-epg.md fallback contract updated accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): bound the preview-queue burst and keep mapped channels portal-free Review follow-up (Codex P1/P2 on #1386): P1 — request volume: each queue sync is now capped at 30 channels (top of the list first, where a freshly opened category is scrolled to), and the sidebar scroll handler re-syncs (throttled, 300 ms) to fill the next gaps. Request count now tracks how far the user scrolls instead of how many rows are rendered; caching (including empty results) and 200 ms pacing remain. P2 — manual mappings: a channel whose bulk record comes from a manual XMLTV mapping never falls back to the portal short EPG. The panel path resolves the channel's mapping before falling back and bails when an override owns the channel; the row path excludes overridden channels from both the queue and its cache. New store query hasItvEpgMappingOverride() exposes override ownership; merging portal data into a mapped schedule could otherwise surface the portal's programme — the exact thing the mapping replaces. Docs updated (stalker-epg.md); regression tests for the cap, the mapped- channel suppression, and the override query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): scope the panel EPG fallback by channel and revalidate queued previews Review follow-up (Codex round 2 on #1386): The panel's short-EPG fallback is now stored with the channel id it was fetched for, and activeEpgPrograms merges it only while that channel is still selected. A channel switch moves the selection synchronously but the old fallback is replaced only after the new channel's EPG load runs, so the unscoped merge mixed the previous channel's programmes into the new panel during slow playback resolution — and left them there when resolution failed. The row-preview queue's completion callback now revalidates ownership: a row claimed while the fetch was in flight — by a manual mapping override or by bulk data — is never overwritten by the late portal response. Both races covered by a new focused spec (verified to fail on the pre-fix component); stalker-epg.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): supersede the preview backlog on view exit and own empty mappings Review follow-up (Codex round 3 on #1386): The preview queue is now superseded when the rendered channel list empties (a legacy-paged category switch clears it before the new channels arrive) and when the view leaves ITV for radio — an abandoned view's backlog no longer keeps issuing get_short_epg requests for rows that are gone. Mapping ownership is now tracked separately from the mapped guide's programs: a saved mapping whose XMLTV channel currently has no entries still owns its channel, so hasItvEpgMappingOverride() keeps the portal short-EPG fallback out — consistent with a mapping's purpose of replacing portal data. Both covered by regression tests (verified to fail pre-fix; the ITV-exit test re-arms the backlog after init because the playlist effect's first run resets the queue); stalker-epg.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(stalker): reconcile every EPG contract reference with the row fallback Review follow-up (Codex round 4 on #1386): the overview, get_short_epg API notes, and data-mapping sections of stalker-epg.md still stated that rows never issue per-row requests, and the stalker-portal skill instructed that only the active channel may fall back — contradicting the contract this PR establishes. All references now describe the bulk-first row previews with the throttled short-EPG fallback queue. skills:validate passes (the skill stays within its 500-word budget). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(stalker): publish empty-mapping ownership reactively Review follow-up (Codex round 5 on #1386): when the row-preview fetch finishes before the mapping lookup, a portal programme is already rendered. An empty mapped guide then recorded ownership only in a plain Set — no state was patched, the preview effect never reran, and the stale portal row survived. applyMappedItvEpg now re-patches bulkItvEpgByChannel (identical content, new reference — deliberately) whenever it establishes new ownership, even without programs, so the rerun sync removes the fallback row. Store regression test extended (fails pre-fix); stalker-epg.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |