mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
c952b55da1f352ae89f167b5274e0809ff45ce12
194
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c952b55da1 | feat(playback): enrich failure diagnostics and add safe support reports (#1574) | ||
|
|
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 |
||
|
|
9f950a1530 |
fix(migration): show startup preparation and recover source read failures (#1568)
* fix(migration): show startup preparation and recover source read failures * fix(migration): keep inventory reload failures recoverable * test(migration): type the deferred recovery hook * fix(migration): reconcile failed EPG cleanup before recovery * fix(migration): await settings before inventory readiness * style(migration): add a subtle theme-aware startup watermark |
||
|
|
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 |
||
|
|
ef2418a2c5 |
feat(epg): compact timeline toolbar with icon-only Now and cycling zoom (#1557)
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom Replace the 96px zoom range slider with a single icon button that cycles day overview → by hour → detailed, make the Now button icon-only (label in tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the ribbon (preventDefault keeps Chromium from page-zooming the same gesture). Zoom math moves into TimelineZoomController so the component stays under the max-lines limit. The channel/programme heading no longer carries a 60% width cap and takes the space the controls free up. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic - TimelineZoomController keeps a logical pending scrollLeft across a burst of wheel/pinch events and applies it in one animation frame, so every event anchors on the same minute instead of the stale DOM position - the e2e settles on the detail preset from whichever band the wheel landed in (contrast checks need the block's time line, hidden at narrower tiers) - zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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 |
||
|
|
d9d6f49757 | feat(playback): slide-in channel list for fullscreen playback (#1519) | ||
|
|
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> |
||
|
|
5a11b82eaf |
feat(embedded-mpv): configurable extra libmpv options and network auto-reconnect (#1515)
Extra libmpv options (Settings > Playback) reach every embedded engine off the command line (createSession array on Windows/macOS, a 0600 --include file on Linux native-view, a stdin preamble for the frame-copy helper); the keys the embed depends on are refused, and keys libmpv rejects are reported once per session. Dropped streams reload automatically (error, or ended on live) with 2 s -> 30 s backoff, six attempts per outage and a 30 s stability reset, only for a load that already played; engine failures stay terminal, a running recording is filed as interrupted and restarted after the reload, and an external subtitle file is re-added. Settings.embeddedMpvAutoReconnect (default on) opts out; the player shows 'Reconnecting... attempt N of M'. Started by Bpl5966 in #1515 and finished by the maintainers in the same PR. Co-authored-by: Bpl5966 <amine.b1959@gmail.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
740b784268 | feat(playback): make the shared player controls the default (#1408) (#1485) | ||
|
|
069b8b3cc9 | feat(playback): advanced subtitle support in shared player controls (#1471) | ||
|
|
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> |
||
|
|
50871e581f |
feat(playback): add quality selection to shared player controls (#1470)
* feat(playback): add quality selection to shared player controls Adds a per-session video quality menu (Auto + "1080p"-style levels) to the shared player-controls layer, mirroring the audio-track pattern: - Contract: qualityLevels capability, qualityLevels/qualityAutoEnabled state, setQualityLevel command with AUTO_QUALITY_LEVEL_ID (-1) restoring ABR. - hls.js (HTML5/ArtPlayer via the neutral source bridge): levels with list-index ids, smooth switching through nextLevel, selection read from manualLevel; refresh events extended with MANIFEST_PARSED, LEVELS_UPDATED, LEVEL_SWITCHED. - Shaka (DASH): variant tracks filtered to the active audio language, ABR disabled before selectVariantTrack; manual state keyed to the exact player instance so a session restart never shows a stale selection. - Video.js: new VjsQualityLevels over videojs-contrib-quality-levels (manual = exactly one enabled level, auto = all enabled, derived statelessly). - Embedded MPV and external players report the capability false. The capability derives from the manifest (advertised only for >1 video rendition), nothing persists to Settings, and the menu rides the default-off webPlayerSharedControls rollout gate. Labels come from one shared helper so all engines render the same vocabulary. QUALITY/QUALITY_AUTO keys added to all 19 i18n files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): pin DASH quality candidates to the active audio stream Review findings on #1470: - Shaka quality candidates now match the active variant's exact audioId (language fallback only when Shaka reports none), so a DASH manifest with same-language audio tracks (main vs. commentary, stereo vs. 5.1) can no longer switch the audio track or show duplicate levels when a quality is picked. Regression test added. - Mirror the quality-selection contract into AGENTS.md's Shared Player Controls section, which must stay in sync with CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): track Video.js manual quality intent explicitly Codex re-review finding on #1470: VHS flips a rendition's `enabled` flag off itself when it temporarily excludes failing renditions, so inferring the manual/auto mode from the enabled count could report a manual selection the user never made once exclusions leave a single survivor. VjsQualityLevels now records the picked level object as explicit manual intent: error exclusions read as auto, a picked level that leaves the list reverts to auto, and the bridge resets the intent on every new source. Regression tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): re-enable surviving renditions when the picked level is removed Codex follow-up on #1470: dropping manual intent when the picked QualityLevel leaves the list reverted the UI to auto but left the surviving renditions disabled by the earlier manual pick, pinning VHS with no selectable rendition. Reverting to auto now re-enables every remaining level, both on the removal event and lazily from the state read. Regression tests added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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. |
||
|
|
03719ba51d | fix(i18n): fix translation with ChatGPT 5.6 Sol (#1390) | ||
|
|
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> |
||
|
|
f7bb3a13db |
feat(playlist): open recognized M3U movies in the VOD detail view (#1420)
M3U entries recognized as movie files now open in the portals' two-state VOD detail view, fed by TMDB metadata instead of the empty EPG zone. Watch-first: activation still plays immediately, with plot, cast, rating and artwork below the player; Escape reveals the Browse hero. Recognition is a synchronous URL-shape heuristic (movie container extension or an Xtream-style /movie/ path; radio, DASH, /series/ paths and episode-marker names keep today's live layout), gated on TMDB enrichment plus the new default-on Settings.m3uVodDetails toggle. Works in Electron and the PWA. Review follow-ups included: the playback payload no longer carries TMDB fields (its identity is the player's source-application key), the persisted volume reaches the player and survives Browse → Play, the enrichment guard keys on the full lookup identity, and the saved engine mounts first time instead of briefly falling back to Video.js. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2d7811eb5f | feat(portals): add "View in portal" action to inline collection details (#1422) | ||
|
|
4bcd4bd390 |
feat(dashboard): add TMDB "Because you watched" recommendations rail (#1419)
* feat(dashboard): add TMDB "Because you watched" recommendations rail
TMDB has no account-free "for you" endpoint, so the rail seeds per-title
recommendations from up to 3 recently watched movies/series. Seeds resolve
through the enrichment facade via a shared lookup-attempt builder (extracted
from the hero service), and recommendations already ride in every cached
details payload, so watched seeds cost zero network. Per-seed lists are
interleaved round-robin, deduplicated by id and normalized title, stripped
of watched/favorited titles, and matched against imported libraries with one
batched DB_MATCH_TITLES request; only year-compatible matches render and
fewer than 5 cards hides the rail. Loads are keyed by the seed set, and a
load where no seed resolved retries instead of latching.
The header names the seed ("Because you watched X") when exactly one seed
contributed, else falls back to the generic "Recommended for you". New
dashboardRails.tmdbRecommendations toggle (default on) in Settings ->
Dashboard; 4 new i18n keys translated across all 19 locales.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): harden recommendations rail reload semantics
Address Codex review findings: the load latch is now keyed by the seed
set PLUS the watched/favorited exclusion set, so favoriting a recommended
title re-filters the rail instead of being ignored by the seed-only memo;
an emptied watch history clears the root-provided service's items and
seed titles instead of leaving a stale rail; and a load requested while
one is in flight is queued and re-run afterwards, so a mid-flight history
change cannot commit results for an obsolete seed set. The dashboard
effect now also tracks favorites. Three regression tests added.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): catalog-aware invalidation, no empty latch, original-title aliases
Address Codex round-2 findings: the load key now includes the
imported-playlist id set, so importing or deleting a playlist re-runs the
catalog matching instead of leaving dead links or hiding fresh matches; a
below-threshold (or transiently failed) match result hides the rail
WITHOUT latching, mirroring the trending rail's retry-on-empty semantics,
since matchTitles maps worker failures to an empty list; and matching plus
watched/favorited exclusion now work through both the localized TMDB title
and the original-title alias, so a catalog named in the original language
still matches while cards keep displaying the localized form. Regression
tests added for all three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): reset latch on hide, alias-year fallback, language-keyed loads
Address Codex round-3 findings: hiding the rail below the match threshold
now also resets the saved load key, so returning to a previously
successful input set (un-favoriting, restoring a playlist) reloads instead
of dying on the equality guard; alias matching picks the first alias whose
match is also year-compatible, so a same-named different-year row hit by
the localized title no longer vetoes the correct original-title match; and
the load key now includes the effective TMDB language (exposed on the
enrichment facade), so switching the app language re-localizes the cards
instead of keeping the previous language all session. Regression tests
added for all three.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): two-tier watched-title exclusion, drop ES2019 flatMap
Address the Codex round-4 finding: a provider stores whatever the panel
named the file, so a watched "Inception 2010" never matched TMDB's
canonical "Inception" by exact key. Exclusion now runs on two tiers —
exact normalized title plus a year-gated base tier — so the year-suffixed
shape is caught while a stored "Blade Runner 2049" still cannot swallow
the 1982 film. An unknown year on either side counts as agreeing, since
re-recommending something already watched is the worse failure.
Also replaces the alias query builder's flatMap with a loop: the web app
compiles this lib against lib: es2018, where Array.prototype.flatMap does
not exist, which broke the web build and every job downstream of it.
Both exclusion tiers are pinned by mutation-verified regression tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): index recommendation exclusions the way TMDB looks them up
Address Codex round-5 findings. The watched/favorited exclusion index is
now built through the same lookup-attempt builder the seeds and the hero
use, so an activity row is indexed under the media type the detail view
enriched with rather than its routing verdict — a Stalker embedded-VOD
series routes as 'movie' but is a show to TMDB, so its recommendation
looked up series: and sailed past a movie:-only entry — and under its
stored original-language title (info.o_name), which a translated
recommendation shares no key with. Only the builder's PRIMARY attempt is
indexed: the second is a fallback guess, and indexing it would let a
watched film exclude the same-named show.
Adds Electron E2E for the new setting: the toggle now appears in the
disabled-when-dashboard-off assertion (with the trending toggle, which
was also missing), plus a restart-persistence test. Rail rendering stays
unit-covered — it needs the TMDB opt-in, live TMDB data and catalog
matches, which would make an E2E network-dependent and flaky.
All three new unit tests are mutation-verified, including one that was
passing vacuously before this round.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): keep every catalog row until the year gate has chosen
Address the Codex round-6 finding: buildTitleMatchIndex collapses to one
row per key before the candidate's year is known, so a catalog holding
both "Dune 1984" and "Dune 2021" keeps whichever the worker returned
first and a 2021 recommendation then fails the year check with the right
row already discarded. The rail now groups the rows per key itself and
lets the year gate pick, still preferring an exact-title match over a
year-stripped one so the shared helper's precedence is preserved.
Mutation-verified regression test.
The trending rail shares the same collapse-then-check shape and is
unaffected by this PR; flagged separately as a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): survive a failed refresh, document the new rail
Address Codex round-7 findings.
A refresh that cannot reach TMDB no longer leaves the rail untouched, but
it does not blank it either: a failed request is not a verdict that there
is nothing to recommend, and removing still-valid cards is the worse
answer for an offline user. What the failure cannot excuse is a card the
user has since watched or favorited, so the retained cards are re-filtered
against the fresh exclusion index and the rail hides if too few survive.
The key stays unlatched, so the next visit still retries.
Also documents the rail in the two canonical dashboard docs I missed:
the surface diagram and render rules in docs/architecture/workspace-dashboard.md
and the rail list in the feature README. Both had also never mentioned the
sibling trending rail, so that gap is closed in the same pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): year-aware exclusions, remake-safe dedupe, key reset
Address Codex round-8 findings.
The exclusion index now records each row's release year (Stalker's
info.releasedate, else a year read off the title) with every key, and both
tiers gate on it, so a watched 1954 "Godzilla" no longer excludes the 2014
one. A row that states no year records null and keeps excluding
unconditionally, so the conservative behaviour survives where nothing is
known.
Candidate dedupe is by TMDB id only; title collisions are resolved after
matching, by the catalog row a candidate resolved to. Same-titled remakes
("Dune" 1984 and 2021) are different films and must both reach the
matcher — collapsing them beforehand let whichever arrived first fail the
year gate on behalf of the one the library actually holds — while two
candidates landing on one row would render as duplicate cards.
The offline re-filter now clears the saved load key, so restoring those
exact inputs (un-favoriting the title) rebuilds the rail instead of
hitting the equality guard.
Splits the pure helpers and data shapes into dashboard-recommendations.util.ts:
the service had crossed the 400-line production limit. All three fixes are
mutation-verified, including one test that only became real after the
mutation showed it passing on the wrong ordering.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): do not latch a partially resolved seed set
Address the Codex round-9 finding: when several seeds load and only some
resolve, latching marked the whole set complete, so a seed that failed
transiently lost its recommendations for the rest of the session. The load
now latches only once every seed has answered.
A seed with no TMDB match never resolves either, so that user's rail
re-runs on each dashboard visit. That is bounded work — the enrichment
misses are cached and the catalog match is one batched worker call — and
it matches the rail's existing policy of not latching on uncertainty.
Mutation-verified regression test, plus one pinning that a fully resolved
set still latches.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): trust only stated years on the exact exclusion tier
Address Codex round-10 findings.
The first is a regression I introduced last round: recording a
title-inferred year with the exact exclusion key meant a watched
"Blade Runner 2049" carried year 2049, disagreed with TMDB's actual 2017,
and stopped excluding the very film the user had just watched. The exact
tier now gates only on a year the row STATES in a metadata field
(Stalker's info.releasedate) — the rule releaseTagYear already documents:
on a whole-title match a trailing number belongs to the name and nothing
can settle it. The base tier keeps its stripped trailing year, which is a
suffix by construction, so the Godzilla 1954/2014 case still holds.
The offline re-filter also drops cards whose playlist has been deleted.
That path is the only one that can reach retained cards without the
catalog key rebuilding the rail, so those cards would otherwise navigate
to a dead route.
Both fixes are mutation-verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): resolved media type replaces the routing one; prefer year-tagged rows
Address Codex round-11 findings.
The exclusion index no longer indexes an activity row under BOTH its
routing type and its resolved media type. A Stalker embedded-VOD series
routes as 'movie' on positive series evidence, so keeping that key made a
watched show exclude an unrelated film of the same name — and, with no
release date to gate on, unconditionally. The resolved type now replaces
the routing one; a row the builder cannot classify keeps its routing type,
which is then the only thing known.
Catalog matching now prefers a row whose stripped year IS the candidate's
over an untagged one: an untagged "Dune" row could be either cut, so
linking a 2021 recommendation to it while "Dune 2021" also exists throws
away the better evidence. Untagged rows stay next in precedence, which is
also the only tier reachable when the candidate's year is unknown.
Both mutation-verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): no TV retry for catalog-classified Xtream rows
Address the Codex round-12 finding: the movie -> tv lookup retry exists
because a Stalker embedded-VOD series is stored as a 'movie' activity row,
but an Xtream row's type comes from a catalog that files movies and series
apart, so there 'movie' is evidence rather than a default. The retry let a
same-titled show answer for a film — the mirror of the existing rule that
a 'tv' verdict never retries as 'movie'.
The lookup item type had dropped the `source` field that distinguishes
them; restoring it is enough to gate the retry. This also tightens the
hero rail, which shares the builder. Mutation-verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): confirmed movies skip the TV retry; key by the whole attempt chain
Address Codex round-13 findings, both consequences of last round's change.
A stored Stalker `info.tmdb_id` is never a provider claim — the contract
says its only source is a match this app already gated, under that very
media type — so such a row's 'movie' verdict is no longer the ambiguous
default the TV retry exists for. Retrying it let a same-titled show answer
for a film whenever the movie lookup transiently returned null. The retry
now runs only for rows nothing has confirmed.
The lookup key is now the whole attempt sequence rather than the primary
attempt alone: two rows can share title, year and id yet differ in whether
a TV fallback follows, and callers cache by this key — the hero's
root-level memo would otherwise serve a Stalker row's TV answer as an
Xtream movie's metadata, and selectSeeds() would collapse two seeds that
do not perform the same lookup.
Both mutation-verified. One existing hero test asserted the retry for a
fixture that carries a stored id; it now pins the confirmed-identity
behaviour instead, with a separate test for the id-less retry it used to
cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): rank catalog matches by year evidence across aliases
Address the Codex round-14 finding: match selection returned as soon as
any alias had a compatible row, so an untagged row under the localized
title beat a row the original-title alias found carrying the candidate's
own year — the wrong remake when both cuts exist. Compatible rows from
every alias now form one pool ranked by evidence, with alias order kept
only as the tiebreaker inside a tier. The nested loop collapses into a
single pass in the process. Mutation-verified.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <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 |
||
|
|
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> |
||
|
|
fd96b85c19 |
feat(playback): recommend recovery actions (#1374)
* docs(playback): design recovery recommendations * docs(playback): plan recovery recommendations * refactor(playback): extract diagnostic utilities * feat(playback): define recovery recommendation contracts * feat(playback): rank recovery recommendations * feat(playback): track session recovery attempts * feat(playback): identify content recovery sessions * feat(ui): add ranked playback diagnostic panel * feat(playback): switch temporarily to recommended players * test(playback): cover temporary player recommendation * test(playback): verify recommendation capability guards * docs(playback): document recovery recommendations * fix(playback): keep recovery keys credential-free * fix(playback): remove derived tracking ownership * fix(playback): preserve distinct recovery fallbacks * fix(playback): reset resume for new sources * fix(playback): preserve desktop recovery guidance * docs(playback): clarify recovery policy exceptions * fix(playback): reject stale progress updates * fix(playback): keep protected recovery guidance neutral * test(playback): cover stale progress output * fix(playback): neutralize protected diagnostic copy * fix(playback): harden runtime guidance ownership * fix(playback): stabilize recovery application ownership * fix(ci): classify playback util coverage * fix(e2e): preserve playback fixture bytes |
||
|
|
9ff1c6ae01 |
feat(stalker): identity hardening (#1370)
MAC addresses are canonicalized to the uppercase colon form a real STB
sends and validated at the input boundary, with a hint when they fall
outside Infomir's OUI — which the stock server's default filter refuses
with a bare {status: 1} no user could diagnose. Normalization applies
only to a value the user actually edits: rewriting stored bytes would
move the session fingerprint for every existing playlist with no user
action, and the MAC is the account key.
Device IDs can optionally be derived from the MAC the way StbEmu and
stalker-to-m3u do — SHA256(MAC) and SHA256(MAC + "stalker"), which a
real box never reports as equal. The portal pins the first non-empty
device_id/device_id2 it sees to the MAC permanently, refuses a different
one, and treats a later empty value as an unrecoverable lockout, so
derived values are written into the visible fields and persisted as
literal strings, never recomputed at request time. The option is offered
at import only; the edit dialog warns instead once an ID has actually
reached the portal.
get_profile now reports one coherent MAG250 (ver, stb_type — previously
empty —, hw_version, image_version, client_type), and a device conflict
gets its own StalkerPortalError kind so the UI can explain it instead of
relaying the portal's "Your STB is damaged".
Closes the identity-fields cluster: #927, #860.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
d2a83164ec | feat(stalker): protocol-correct auth lifecycle (#1354) | ||
|
|
96facd6f49 |
feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing * docs(downloads): plan season queue implementation * feat(downloads): define episode queue identity * fix(downloads): align episode identity contract * feat(downloads): coordinate season queue submissions * fix(downloads): keep queue coordination provider neutral * fix(downloads): reconcile legacy episode identities * fix(downloads): fail closed on invalid stored coordinates * refactor(downloads): adapt Xtream episode requests * fix(downloads): use canonical Stalker episode ids * test(downloads): cover Stalker adapter reactivity * feat(downloads): add selected season queue action * refactor(downloads): extract season download presenter * feat(downloads): localize season queue feedback * test(downloads): cover series batch queue flow * test(downloads): harden series queue fixtures * docs(downloads): describe season queueing * docs(downloads): clarify season queue IPC contract * fix(downloads): isolate season header build warnings * fix(downloads): label season view toggles * fix(downloads): preserve Xtream episode headers * fix(downloads): fail closed on stale episode state * fix(downloads): align renderer queue safeguards * fix(downloads): block ambiguous episode actions * fix(downloads): accept nullable legacy coordinates * fix(downloads): preserve scoped episode ownership * fix(downloads): probe restored files asynchronously * fix(downloads): bound restored file probes * fix(downloads): release timed out file probes * fix(downloads): bound file probe callers * fix(downloads): refresh stable season skips * fix(downloads): fail closed before provider prep * fix(downloads): preserve retained partial ownership * fix(downloads): reconcile partial cleanup completion * fix(downloads): await authoritative list refresh * fix(downloads): coalesce list refreshes * fix(downloads): preserve specials season identity * fix(stalker): preserve specials season mapping * fix(downloads): distinguish missing Xtream seasons |
||
|
|
0010dd7351 |
feat(portals): rework the VOD sources popup and action row (#1359)
* feat(portals): rework the VOD sources popup and action row The Sources popup now anchors above its button and always fits on screen: the overlay caps it to the space beside the chip, only the source list scrolls so the header, search, filter chips and footer stay visible, and it flips below the button when the space above is too small. Filter chips (All / Available / HD+ / language) compose with the playlist search, and "Available" runs check-all itself when nothing has been checked yet. Expanded copy rows no longer repeat the playlist domain: each copy shows its parsed language chip, the provider's raw stream title, and only the tags that differ from the parent copy. Availability checks run at most four at a time and settled verdicts are remembered per movie and source for ten minutes, so reopening a movie no longer re-contacts every foreign portal. Favorites and Download become icon-only buttons with real state: a filled heart when favorited, and a download icon that turns into a progress ring and then a checkmark that reveals the finished file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(portals): match the movie detail's icon-only favorite button The Xtream movie detail's favorite control is now an icon-only button, so the shared "add this detail to favorites" helpers no longer found it by class. They are used against series and Stalker details too, which still render the labeled variant, so they now select by accessible name — the icon button carries the same label in aria-label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep provider playback for a downloaded movie Once a movie is downloaded the primary button plays the local file, and the sources popover only exists when another playlist carries the same film. The icon-only rework left those as the only two paths, so a downloaded movie in a single-playlist library had no way at all to stream the provider's copy — the labeled action that used to do it was gone. Restores it as an icon button beside the downloaded checkmark, under the same condition the old one used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a6186a46c8 |
feat(dashboard): subscription-expiry warning badge on source cards (#1342)
* feat(dashboard): warn on source cards when a portal subscription expires soon Dashboard source cards now carry a passive expiry chip: amber "Expires in N d" within 7 days of the subscription lapsing, error-toned "Expired" once it has. Account details stay behind the card's ⋮ → Account info. Xtream expirations ride on the playlist switcher's cached PortalStatusService check — checkPortalStatusDetails() now surfaces the parsed exp_date from the same round-trip, so the dashboard adds no extra portal calls. Stalker expirations come from the stalkerAccountInfo snapshot persisted at import; it lives in the playlist payload (meta rows carry payload: null), so each Stalker source costs one full-playlist read memoized on the playlist's update timestamp. New i18n keys added to all 19 locales via the i18n-fill merger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): address review feedback on expiry badges - Recompute expiry badges on a minute tick so a dashboard left open crosses day-countdown and expiration boundaries (Greptile P1 / Codex P2) - Gate the expiry refresh on the recent-sources rail setting so hidden rails cost no portal checks or playlist reads (Codex P2) - Move chip colors to theme-aware tokens in m3-theme.scss; both themes now hold >= 4.5:1 small-text contrast (light warn 5.3:1, light expired 5.4:1, dark warn 7.4:1, dark expired 6.0:1) (Codex P2) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dashboard): make expiry-badge labels depend on the language signal sourceCards previously relied on getPlaylistProvider's indirect language read; the translate.instant() labels now read languageTick explicitly (mirroring trendingCards). Also shift the minute tick by one so the interval's first 0 differs from initialValue — the signal equality check was swallowing the first heartbeat, delaying it to two minutes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8f9e78ff90 |
fix(workspace): report a local phase while reading the cached Xtream catalog (#1345)
* fix(workspace): report a local phase while reading the cached Xtream catalog Since #1311 the sync overlay is shown for the whole import session, but the DB-first read path never emitted an import phase, so switching to an already-imported Xtream playlist showed a bare "Syncing playlist" card with no badge or description. The Electron data source now reports a 'loading-cached' phase (local-library badge, its own label and detail text) before reading categories/content from SQLite, and the PWA data source reports the remote loading phases on API fetches it previously swallowed. Adds the two new i18n keys to en.json and all 18 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): keep the loading-cached phase from marking a real import The store's onPhaseChange callbacks set isImporting unconditionally, and the initialization error path gates import-cache cleanup on that flag — so a cancelled or failed warm SQLite read would have wiped the healthy cached catalog and forced a full provider redownload. The shared publishImportPhase helper now publishes 'loading-cached' as a presentation-only phase; any remote/save phase still marks the import as running. Adds regression specs (verified to fail against the previous behavior) in a dedicated spec file to stay under the test max-lines limit. Addresses Codex P1 review feedback on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(portals): scope cancelled-import cleanup to types with remote work A session-wide isImporting flag meant that once any content type contacted the provider, cancelling during a later cache-only read cleared the healthy cached catalogs of every not-yet-completed type. Cleanup now consults a per-session set of types that actually performed remote or save work (populated from typed phase callbacks and save-content events), so cache-only types keep their catalogs on cancellation while genuinely partial types are still cleared. Mixed-scenario regression spec added (mutation-verified against the unguarded behavior). Addresses the second Codex P1 on #1345. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6c065124ed |
feat(stalker): add account info dialog for Stalker portals (#1330)
* feat(stalker): add account info dialog for Stalker portals Xtream playlists have had an account-info dialog for a while; Stalker portals stored the same facts (login, expiry, tariff, status captured at import) as dead weight in the database and showed them nowhere. Add StalkerAccountInfoComponent mirroring the Xtream dialog's visual language: status pill, days-left/tariff/MAC hero stats, account and portal panels. Data is cached-first — the import-time snapshot renders instantly with a "Saved data" badge, then StalkerAccountInfoService refreshes it: full /stalker_portal/ installations re-run handshake+get_profile, portal.php panels are queried best-effort via account_info/get_main_info. A failed refresh keeps the cached snapshot; no data at all shows a retry-able error state. Entry points are unified behind shared portal-account predicates (isXtreamAccountPlaylist / isStalkerAccountPlaylist in shared/interfaces) so both portal types get the same set: header playlist switcher (bottom section + new per-row ⋮ Account info item), dashboard source card ⋮ menu, and the command palette (now visible on stalker routes with its own description). The header service picks the dialog by playlist type; the per-row path works for non-active playlists and skips the session-scoped stream counts. Also adds the missing top-level LOADING/RETRY i18n keys the Xtream dialog already referenced (they rendered as raw keys), a get_main_info handler in the stalker mock server, and STALKER.ACCOUNT_INFO translations for all 19 locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): unwrap nested js.account_info envelope in get_main_info Ministra-style portals nest the account block — fetchStalkerExpireDate() in stalker-player-request.utils already consumes exactly that shape, so the flat-only mapper silently discarded valid responses and legacy imports (which have no cached snapshot) got an empty account panel. Merge nested fields over flat aliases, send the JsHttpRequest parameter the existing get_main_info caller sends, switch the mock server to the nested envelope so the E2E covers the realistic shape, and document the account-info feature in CLAUDE.md (review feedback from Greptile and Codex on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(stalker): pin account-info expiry fixture below the day boundary Math.round on the epoch could round up half a second, putting the fixture's expiry just past the 30-day mark so daysLeft ceil'd to 31 on CI. Floor keeps the interval strictly inside 30 days regardless of when within the second the spec runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(stalker): address account-info review round two Three P2s from Codex on #1330: - Normalize the cached stalkerAccountInfo snapshot before rendering: the import path persists portal values verbatim, so expireDate can be a date string or milliseconds at runtime despite the declared number type. normalizeStoredStalkerAccountInfo() runs the same parsers as the fresh path. - Publish the re-auth token into StalkerSessionService's cache: strict portals invalidate the previous token per handshake, so the dialog's authenticate() would otherwise strand an active portal session on a dead token. - Extract the duplicated ~460-line account-dialog stylesheet into libs/ui/styles/_account-dialog.scss, shared by both dialogs with the provider accent injected via --account-dialog-accent; each consumer keeps only its accent and layout overrides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): serialize account-profile refresh with session auth The dialog's direct authenticate() call bypassed the pendingAuth map ensureToken() uses, so a refresh could run a second handshake while a catalog or watchdog request was still authenticating. On strict portals each handshake invalidates the other's token, and the later setCachedToken() could publish an already-dead one. Move the refresh into StalkerSessionService.refreshAccountProfile(): it waits for any in-flight authentication, registers its own so later callers wait for it, and republishes the resulting token. A failed pending auth no longer aborts the refresh, and the pendingAuth entry is only cleared when it is still this call's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): move pendingAuth cleanup out of the promise initializer TS2454 under the Angular compiler: the finally block referenced authPromise inside its own initializer, so every Electron/web production build failed even though jest and lint accepted it. Await the promise at the call site and retire the map entry there instead — same only-clear-our-own-entry semantics. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): harden account-info portal detection and expiry math Review round four (Codex P2s on #1330): - Fall back to the URL rule when isFullStalkerPortal is undefined: a playlist restored from an older backup carries no flag once the one-shot metadata migration has run, and it would then be sent down the unauthenticated legacy path and labelled a legacy panel. - Parse a bare YYYY-MM-DD expiry as a local calendar date. Date.parse reads it as UTC midnight, which renders as the previous day west of UTC and shifts the days-left boundary; timestamps carrying a time or offset keep standard parsing. - Decide expiry from the raw timestamp, not the rounded counter: an expiry that passed less than a day ago ceil's to 0/-0, so the hero stat claimed "0 days left" on a dead subscription. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): make account-profile refresh own the auth slot Review round five (Codex P2s on #1330): - Claim the pendingAuth slot in a loop and publish it before the first await. One settled promise releases every waiter at once, so a single pre-check let two queued refreshes both start handshakes that invalidate each other on strict portals. - Retire the cached token before the handshake: ensureToken() reads tokenCache before pendingAuth, so catalog and watchdog requests starting mid-handshake were handed a token this refresh was about to kill instead of queueing on the slot. - Render the portal type from the same resolver the fetch path uses, so a restored backup without an explicit flag is no longer labelled a legacy panel while authenticating as a full portal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): retire only the token that actually failed auth A request dispatched with the previous token can see its authorization failure arrive after a profile refresh has already cached a fresh one. The retry path deleted the cache blindly, killing the fresh token and kicking off another handshake that in turn invalidated tokens of newer requests — cascading retries on strict portals. makeAuthenticatedRequest() now retires the cached token only while it still equals the token that failed; a late failure of a stale token leaves the refreshed token in place and the retry reuses it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(stalker): distinguish the two no-data outcomes of the account dialog A portal that answers but publishes no account facts renders the ready-state "No account details" panel; only an unreachable portal without a cached snapshot enters the error state with retry. The doc conflated both as "error with retry" (review feedback on #1330). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject negative expiry sentinels before date parsing Portals encode unlimited/missing expiry as "-1" or "0"; the unsigned-digit check let "-1" fall through to Date.parse, which V8 reads as January 1, 2001 — an unlimited account rendered as expired. Signed numeric strings now take the numeric branch, whose non-positive guard already discards them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): reject out-of-range calendar components in expiry dates The multi-argument Date constructor normalizes invalid components ('2026-00-00' becomes Nov 30, 2025), fabricating an expiry and countdown from a placeholder. Round-trip the parsed year/month/day and reject any date that does not survive unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e86e988e72 |
feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* feat(ui): turn the phone context panel into an off-canvas drawer On ≤640px viewports the workspace context panel (categories, filters, settings sections, collection filters) no longer stacks above the route content capped at 30vh — it is a hidden-by-default drawer that slides in from the left over a backdrop, opened via a new header toggle (phone-only, CSS-gated) and closed by selection, backdrop tap, Escape, or any navigation. State lives in the new WorkspaceShellContextDrawerService provided by the shell component; panels close it explicitly after selections that do not navigate (Stalker ITV/radio categories, settings sections, sources filters, collection filters), since NavigationEnd alone cannot cover those. Desktop behavior is untouched, including the ResizableDirective inline width. Closes the drawer follow-up deferred from #1100 / PR #1326. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): make the phone context drawer modal for keyboard users Addresses Greptile P1 and Codex P2 review feedback on #1332: - CdkTrapFocus on the sidebar captures focus into the drawer on open and contains it while the drawer is modal; the shell restores focus to the header toggle on close, since the closed drawer is visibility: hidden and focus left inside it would silently drop to <body>. - The drawer service closes the drawer when the viewport leaves the phone breakpoint (matchMedia), so the trap can never hold the in-flow desktop sidebar after a resize. - The toggle's tooltip and aria-label are now variant-aware — categories on portal routes, filters on sources/collection routes, settings sections on the settings route — instead of a fixed 'Categories & filters' that misdescribed two of the three; the two generic i18n keys are replaced by six variant keys across all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): remove background content from the a11y tree while the drawer is open Round-2 review feedback on #1332 (Greptile P1, Codex P2): - The rail, header, route content and playback footer are marked inert while the phone drawer is open — CdkTrapFocus constrains Tab focus, but a screen reader's virtual cursor could still reach and activate the visually obscured controls behind the backdrop. - The drawer panel itself is the trap's initial focus target (tabindex=-1 + cdkFocusInitial), so focus capture still works when a category list is loading, empty, or failed and renders no focusable rows. - Focus restore on close is deferred one tick: the toggle lives in the inert header, and focus() on a still-inert element is silently ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): gate global shortcuts and Escape behind the open phone drawer Round-3 review feedback on #1332 (Codex P2s): - The shell consumes Escape while the drawer is open: downstream Escape consumers (the portal detail shell's inline player close, the shared controls shortcuts) check defaultPrevented, so one keypress no longer closes both the drawer and the obscured playback surface. - inert does not silence document-level keydown listeners, so players opt out themselves while inside an inert region: ControlsShortcuts gains an optional hostElement handler and ignores every shortcut (including Escape) when that host has an inert ancestor, and the radio audio player applies the same check to its volume/mute keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer Round-4 review feedback on #1332 (Greptile P1, Codex P2s): - The drawer carries its own phone-only close button: touch screen-reader users have no hardware Escape and cannot reach the inert header toggle or the aria-hidden backdrop, so the trapped surface must offer dismissal itself — even when a category list is loading or empty and renders no actionable entries. - Ctrl/Cmd+F no longer opens global search while the drawer is modal; the shortcut would have navigated and focused an input inside the inert header. - EmbeddedMpvShortcuts (native-view legacy dock) gains the same hostElement/inert-ancestor guard as the shared controls shortcuts, so the obscured player cannot react to Space/arrows/M/Escape behind the drawer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326 stacked-panel behavior this PR replaces) and updates that spec to pin the drawer contract instead: panel hidden by default with full-width content, header toggle opens it over a backdrop, category selection and backdrop tap close it. Verified locally on Chromium, Firefox and WebKit (12/12). The spec's getByTestId calls needed plain [data-test-id=...] locators — the web-e2e Playwright config never mapped testIdAttribute. Also addresses Codex round-5 P2s: - Focus restore now reports whether the toggle received focus; when a drawer selection navigated to a route without a context panel (toggle gone), focus falls back to the route content instead of dropping to <body>. - The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts out while their host sits inside an inert region, matching the other document-level listeners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): suppress command palette and shortcuts dialog behind the open drawer Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K handler in WorkspaceShellFacade and the '?' help-key handler in WorkspaceKeyboardShortcutsService still opened their dialogs while the phone context drawer was modal, stacking a second focus-trapped surface on top of it. Both now check the drawer service (injected optionally, same shell-component providers) and stay quiet while it is open, like the Ctrl/Cmd+F global-search gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding Addresses the two Codex round-7 P2s on #1332: - WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R global-recent shortcut can observe the modal drawer without pulling the lazy shell chunk into the eager bundle. Cmd+R is now suppressed while the drawer is open, like Cmd+F/Cmd+K/'?'. - The shell registers the open drawer with a new EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API: the native-view video surface is composited outside DOM stacking and would paint straight over the drawer regardless of z-index. The service treats registered external modal surfaces exactly like open Material dialogs. - The service's recompute no longer reads overlayActive back before setting it: signals already skip notification on equal values, and that hidden read registered overlayActive as a dependency of any reactive context calling into the service — the shell's acquire/release effect looped forever on exactly that (caught by a live browser probe; the unit suite mocked the service). The effect also wraps the acquire in untracked() for caller-side hygiene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): expose the phone drawer as a named modal dialog Round-8 review feedback on #1332 (Codex P2s): - While open, the drawer carries role=dialog, aria-modal=true, and a variant-appropriate accessible name (categories / filters / settings sections) — assistive technology now hears that a named modal surface opened instead of an unnamed complementary landmark. Closed (and the always-visible desktop sidebar) stays a plain landmark. - The UI-guidelines drawer section no longer claims the drawer service is component-provided; it is root-provided from workspace/shell/util since the round-7 move, and the stale claim could have led a future change to re-scope it and silently break the AppComponent shortcut gate and the Embedded MPV overlay observer. Matching code comments updated everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking Greptile round-9 P1 + Codex round-9 P2 on #1332: - The M3U video player's document-level digit-key channel switching and Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as every other routed-content key listener. A codebase sweep confirms this closes the class: every document-level key listener on routed content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or opts out via closest('[inert]'); the guidelines now require the guard for any new listener. - The drawer moves from z-index 99/98 to 951/950: above the settings action bar (100) and the root EPG/update panels (900/901), which inert removes from interaction but not from paint order — below the CDK overlay container (1000), since dialogs opened from inside the drawer (Manage categories) must stack on top of it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
760099358b |
feat(downloads): redesign download manager (#1313)
* docs(downloads): specify manager MVP redesign * docs(downloads): plan manager MVP implementation * docs(downloads): tighten manager validation plan * fix(downloads): keep renderer download state global * fix(downloads): make active count accessible * feat(downloads): derive queue and library view model * test(downloads): close view model coverage gaps * fix(downloads): stabilize malformed view model data * refactor(downloads): isolate library navigation * fix(downloads): report library navigation failures * feat(downloads): add ready-to-watch library * feat(downloads): add active download queue * feat(downloads): finish manager MVP * docs(downloads): clarify detail-first offline behavior * docs(downloads): plan detail navigation follow-up * fix(downloads): open completed movies in details * test(downloads): cover pending series navigation * fix(downloads): honor the global cover size * fix(downloads): prefer local playback in shared details * fix(downloads): preserve external launch priority * fix(downloads): prefer local playback in Xtream details * test(downloads): cover offline detail journey * docs(downloads): document offline detail behavior * docs(downloads): format detail navigation plan * fix(downloads): open Stalker items in provider details * docs(downloads): clarify Stalker navigation fallback * fix(xtream): isolate reused detail identities * fix(xtream): ignore stale VOD positions * fix(downloads): keep offline Xtream playback available * docs(downloads): clarify provider playback availability * docs(downloads): design missing-file recovery * docs(downloads): plan missing-file recovery * feat(downloads): derive completed file availability * feat(downloads): recover missing completed files * feat(downloads): refresh missing local files * feat(downloads): separate missing files from ready media * feat(downloads): surface missing files for recovery * refactor(downloads): simplify ready cards * test(downloads): cover missing-file and series journeys * feat(downloads): finish missing-file recovery * docs(downloads): design offline detail views * docs(downloads): plan offline detail views * feat(downloads): persist offline metadata snapshots * fix(downloads): complete metadata snapshot bridge contract * feat(downloads): manage offline metadata snapshots * fix(downloads): harden metadata snapshot updates * fix(downloads): restrict snapshot artwork * fix(downloads): guard restart artwork URL * fix(downloads): refine artwork URL checks * feat(downloads): expose offline metadata updates * fix(downloads): keep metadata service change focused * fix(downloads): preserve metadata error conventions * feat(downloads): derive offline detail content * fix(downloads): preserve unknown episode coordinates * feat(downloads): add focused offline detail routes * fix(downloads): ignore fragments in shell route state * fix(downloads): normalize fragments before queries * feat(downloads): open ready cards in offline details * fix(downloads): use native disabled card styles * feat(downloads): enrich offline detail metadata * fix(downloads): harden offline metadata resolution * fix(downloads): preserve stalker provider titles * fix(downloads): distinguish stalker metadata seeds * fix(downloads): stabilize offline metadata refresh * fix(downloads): throttle sparse metadata refreshes * fix(downloads): type metadata language settings * feat(downloads): render offline movie and series details * fix(downloads): harden offline detail interactions * fix(downloads): close offline detail edge cases * feat(downloads): hand off to provider-only details * fix(downloads): preserve stalker provider handoff * feat(downloads): capture metadata at download time * fix(downloads): preserve snapshot source semantics * fix(downloads): preserve episode snapshot identity * docs(downloads): document offline details flow * docs(downloads): clarify stalker provider fallback * test(downloads): cover offline detail journeys * test(downloads): stabilize offline detail selectors * style(downloads): format changed files * docs(downloads): clean design spec formatting * fix(downloads): preserve offline library ownership * test(downloads): fix Windows workspace navigation * test(database): preserve Electron tsconfig resolution * perf(downloads): avoid blocking file availability probes |
||
|
|
063662028a |
feat(portals): find the same movie in your other playlists (#1286)
* feat(portals): find the same movie in your other playlists A movie that exists in several imported Xtream playlists now shows a "Sources N" chip on its detail page and in the player. Switching playlist mid-film keeps the timecode, a preferred source can be pinned per movie, and a failed stream offers the alternatives instead of a dead end. The governing rule is that a guess is never presented as a fact. Every metadata value carries where it came from — `api` (the provider said so), `parsed` (inferred from the title) or `probe` (we contacted the stream). Facts render as plain tags, guesses are prefixed `~` in a warning colour, and an unknown value renders no tag at all plus a "check" affordance. Ranking and failover read through `factualOnly()`, so a filename claiming 4K is structurally unable to outrank a source that was actually reached. A probe that could not complete reports "unknown", never "unavailable". Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces live content; both are additive later, since the candidate type already carries all three portal kinds. In the PWA every entry point is gated off and the chip renders nothing. Auto-failover is opt-in and off by default. Each source is tried at most once per session, so it terminates structurally, and the switch is never silent — the toast names the new playlist, offers an undo, and warns that the dub may differ only when both sides state an audio track as fact. Notable details: - Playlist names are routinely the pasted URL, credentials included. They are never rendered raw; a short host-only label is derived instead. - Quality is derived from pixel width, not height: a 2.39:1 1080p master is 1920x800, and bucketing that by height would publish "720p" as a fact. - Switching is a single `inlinePlayback.set()` so the player and engine survive and re-seek; the carried position is read before the 15s persistence throttle so it does not rewind. - Sources from one playlist collapse into a group, since the same film often appears there several times under different stream ids. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): stop stale source resolutions from committing Addresses three defects Greptile found in the multi-source review. **Concurrent switches committed out of order.** Selecting a second source before the first resolution returned let the slower request overwrite the newer selection and repoint Undo at itself. `switchTo` now takes a sequence number and drops its result if a newer switch already committed. **Stale switches crossed movie sessions.** Navigating to another film while a resolution was in flight let the continuation activate the old film's source inside the new controller — and restart it from that session's zero resume position. The controller is now snapshotted per operation and the movie session is revalidated after every await. `check()` had the same hazard across its two awaits and is guarded the same way. **Short titles skipped discovery entirely.** The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produced an empty MATCH expression and the query was discarded before SQLite was consulted — the chip could never appear for those films. Discovery now falls back to a bounded scan when FTS structurally cannot serve the title; the existing two-tier normalized confirmation still rejects loose hits like "Upgrade". Each fix carries a regression test; all three were mutation-checked by removing the guard and confirming exactly those tests fail. The previous test asserting that short titles return nothing encoded the bug and has been replaced. The host spec passed 400 lines, so its fixtures moved to a shared module and the race suite into its own file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(portals): make the pin decide playback and keep failover going Second round of Greptile review findings. **A pin had no behavioural effect.** Loading a stored pin only decorated the row: Play still started the route's playlist and failover ranking ignored `isPinned`, so "make this the main source" survived a restart as an icon and nothing else. The primary action now starts from the pinned source when one is set, and the pin outranks everything else in failover ranking. **Failover stopped at the first unresolvable candidate.** An expired account or a failing `get_vod_info` on the top-ranked source ended the attempt, and since production calls `failover()` only once — on the original playback failure — a healthy lower-ranked source was never reached. It now continues through untried candidates. `switchTo` reports why it stopped so the loop can tell "could not resolve, try the next one" from "something newer owns the screen"; without that distinction a superseded switch would have spun forever, because only the former marks the candidate tried. **Identity ignored enrichment.** The key was `playlistId:contentId:title`, so when `get_vod_info` added a TMDB id and release year to an unchanged title the host saw no change, never reloaded, and kept yearless discovery and title-only pin keys — a `tmdb:`-keyed pin could never be found. The key now covers every field that affects matching. **A server refusing HEAD read as unavailable.** Some stream hosts answer 405 or 501 to HEAD yet serve the media over GET. The probe now retries once with the ranged GET the main process already supported, instead of caching a working source as failed and penalising it during failover. Greptile also flagged a missing token check after the resolve await in `switchTo`; that guard landed in |
||
|
|
f80eb4d1b9 |
fix(playlist): open playlists handed over by the OS (#1299)
Opening an .m3u/.m3u8 file from the command line or a file association did nothing. The renderer parsed `process.argv` and sent an `OPEN_FILE` IPC event that had no `ipcMain` handler and no preload channel, so `sendIpcEvent` logged it as an unknown type and dropped it. The path now belongs to the main process, which is where the OS actually delivers it: - argv is parsed on first launch (skipping the executable and Chromium switches) and normalized to an absolute path; - macOS gets an `open-file` listener registered before `whenReady`, since Launch Services never puts the path in argv; - the single-instance guard forwards a second launch's argv and working directory instead of discarding them, so opening a playlist against a running app works too. Requests are queued in the main process until the renderer subscribes to the `OPEN_FILE` push and drains the queue, which closes the startup race. The import itself reuses the existing file path, so persistence, playlist-scoped EPG and the navigation to the new playlist behave exactly like a dialog import; a failed open now surfaces a snackbar instead of silence. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bfad82c26c |
fix(settings): stop settings silently reverting on restart (#1272)
Settings live in the renderer's IndexedDB, and two failure modes made them look saved while nothing reached disk. A second app instance sharing the same userData directory cannot take the Chromium storage lock, so its renderer reads defaults and every write is dropped. The app now holds a single-instance lock and focuses the running window instead of starting a rival copy. The lock is requested after the userData override so E2E runs with their own data dir keep independent locks, and after Squirrel event handling. IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1 opts out for local CDP debugging. updateSettings() patches in-memory state before persisting and the submit path had no rejection handler, so a failed write produced an unhandled rejection and no user-visible feedback. SettingsStore now records which half of the round trip failed, and the settings page surfaces it through a dismissible error snackbar; the dialog stays open on failure so the save can be retried. Two follow-ups from review, both wider than the report: - a second launch now re-creates the main window when the lock owner has none left, so closing the last window on macOS no longer leaves a second launch quitting silently with nothing on screen - App.onMainWindowCreated() re-runs window-owned bindings for every rebuilt window, so the downloads broadcaster stops holding a destroyed window. This also fixes the same bug on the pre-existing dock `activate` path. Closes #1156 Closes #102 |
||
|
|
0b967d66d4 |
feat(tmdb): metadata cache panel with a clear button in settings (#1244)
* feat(tmdb): metadata cache panel with a clear button in settings Adds "Metadata cache — N entries · X MB" with a Clear button to Settings > Metadata (TMDB), next to the API key it belongs to. Three things it is good for: dropping stale or wrong metadata so the next open refetches it, seeing what the cache actually costs on disk, and reclaiming rows that a lookup-key version bump has orphaned — a bump makes rows unreachable, not deleted, so nothing else would ever collect them. Sizing the cache is a full table scan (LENGTH() on TEXT counts characters, so the SUM casts to BLOB to get bytes), which is why stats load lazily and only once the TMDB section is the active one rather than on every settings open. Clearing is always safe: enrichment refetches on demand, so the only cost is the next few requests. Works in both environments — the PWA has no bridge, so the service reports and clears its session-scoped in-memory map instead. i18n: 4 keys across all 19 locales via the tools/i18n workflow; placeholder integrity verified. Contract fixtures updated for both the preload bridge and the DB-worker payload shapes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make cache clearing durable and stop reporting failures as empty Four review findings, all real: - A metadata write already in flight when the user cleared would land afterwards and silently restore what they removed. Writes now carry the generation they started in; a write that outlives a clear is dropped (PWA) or undone (Electron). - The PWA byte count used String.length, i.e. UTF-16 code units, so localized payloads under-reported and disagreed with the SQLite BLOB byte count. TextEncoder now measures actual bytes. - A failed stats read returned a valid zero-entry result, so the panel claimed an empty cache and disabled Clear while rows were still there. getStats/clear now return null on failure and the panel says so instead of inventing state. - No behavioural coverage existed for either side. Tests: SQL ops (entry/byte reporting, empty table, missing row, delete count) and the service (encoded bytes, clear count, and a write racing a clear). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): make the cache clear precise and version skew visible Review follow-ups on the cache panel: - A write that was in flight when the user cleared used to trigger a second full-table clear once it landed, which also deleted anything written in between. clear() now waits for the writes issued before it and lets the single clear take them; later writes survive. - An Electron shell without the maintenance ops fell through to the renderer map, which is always empty there — it reported an empty cache and disabled the Clear button while SQLite was full. Both operations now report unsupported instead. - Component coverage for the panel (deferred scan, clear + re-read, failed clear, failed read) and Electron-path service coverage. - The canonical IPC and settings sections of the enrichment doc, plus the matching CLAUDE.md lines, now list the maintenance ops. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): drop Promise.allSettled from the cache clear The web target compiles against lib es2018, so allSettled broke the Windows frontend build (TS2550). The pending writes swallow their own errors, so a plain Promise.all over neutralized promises does the job. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): keep a synchronous bridge throw inside the cache write Moving the write into a tracked promise dropped the try/catch that used to cover the call itself, so a bridge that threw synchronously would escape set(). Wrap it in an async IIFE, which turns that back into a rejection the same handler swallows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): retry the cache size read when the section is reopened The effect skipped the read once cacheError was set, so one transient IPC failure left the panel showing "could not read the cache" for the life of the settings page — and the only enabled control that could shift it was the destructive Clear button. Gate on the stats signal alone: reopening the section retries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tmdb): queue writes that start while the cache is being cleared Awaiting the in-flight writes closed one side of the race and left the other open: a set() that started during that wait dispatched its IPC immediately, was absent from the snapshot, and could reach SQLite just before the delete — so a row written after the user clicked Clear was removed anyway. clear() now holds its own promise for the whole operation and set() waits on it, which puts such a write on the far side of the delete. Rows are stamped when they are dispatched rather than when set() was called, since a write may have waited. Covered by a test that fails without the guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(tmdb): add the release note for the cache panel Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(tmdb): cover the cache panel with an Electron E2E The panel drives IPC and SQLite, and nothing exercised that path end to end. The new test seeds a row through the preload bridge — enrichment itself needs a TMDB key that CI does not have — then opens the section, asserts the reported size, clears, and reads the database back to confirm the row is gone rather than merely hidden. Verified both ways: dropping the DELETE from clearTmdbMetadata fails it. Settings nav buttons gained a data-test-id so the section can be opened without matching translated labels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
8e1320cb34 |
feat(tmdb): series production-status chip and person death dates (#1240)
Two fields TMDB already sends us and the merge threw away — no new API calls, no cache-key bump, they light up on existing cached payloads. Series detail views (Xtream and Stalker) gain a production-status chip: "Ended" tells you a show is finished before you commit to it, "Returning" that it is not. TMDB returns `status` as an ENGLISH string even under language=ru-RU, so it is normalized to a stable token (normalizeSeriesStatus) and rendered through translated labels (seriesStatusLabelKey). Unknown values are dropped rather than shown, so a status TMDB adds later can never leak raw English into 19 locales. Person pages render `deathday`, which mapPersonProfile has always parsed into ActorProfile and no template ever read. i18n: 7 keys across all 19 locales via the tools/i18n workflow. Tests: status normalization (token mapping, case-insensitivity, the British "cancelled" spelling, unknown/missing dropped). Docs: tmdb-metadata-enrichment.md, CLAUDE.md. Refs docs/architecture/tmdb-roadmap.md C1 and the zero-extra-call tier. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
e24da447c1 |
fix(ci): restore green master pipeline (hu locale drift, CodeQL upload) (#1237)
Two independent CI failures on master: 1. `Check i18n drift` failed with 3 keys missing from `hu.json` (`SETTINGS.PLAYER_UP_NEXT_RAIL`, `SETTINGS.PLAYER_UP_NEXT_RAIL_DESCRIPTION`, `PORTALS.UP_NEXT`). PR #1231 added them to every locale, but its branch predates the Hungarian locale merged in #1236, so `hu.json` never got them. Both PRs were green in isolation. The failure also aborted the job before the Tier A/B/C unit suites ran. Added the keys with real Hungarian translations rather than English fallbacks. 2. CodeQL has failed on every master push for days. The analysis itself completes; only the SARIF upload fails with "Resource not accessible by integration" because the workflow has no `permissions:` block and the default token is read-only. Added the standard grant. While in that workflow: bumped `actions/checkout` v3 -> v4 (matches every other workflow here) and dropped the obsolete `git checkout HEAD^2` step — the old template's PR-head trick that current codeql-action handles itself, and the only reason `fetch-depth: 2` was needed. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4ca2b6852e |
feat(playback): Up Next episode rail for the inline series player (#1231)
* feat(playback): Up Next episode rail for the inline series player On wide windows the inline series player now docks left and fills the leftover stage column with a Netflix-style "Up Next" rail: the rest of the current season plus next-season spillover, the playing episode highlighted, and watch-progress bars from playback positions. Clicking an episode plays it inline through the host's existing episode flow (Xtream serial-details and Stalker series view). - New app-up-next-rail component + buildUpNextRailItems() util in ui/playback; entries carry the host's raw episode object so selection needs no id lookup. - PortalInlinePlayerComponent measures the theater stage with a ResizeObserver and docks the rail only when the leftover beside the 16:9 player is >= 320px; narrower stages keep the centered theater/ambient behavior from #1223. Movies and live never show the rail. - New playerUpNextRail setting (Settings > Playback, default on, built-in web players only), mirroring playerAmbientMode; enforced at runtime for non-web engines. - i18n: SETTINGS.PLAYER_UP_NEXT_RAIL(+_DESCRIPTION) and PORTALS.UP_NEXT in all 18 locales. - The rail renders as an opaque panel on top of the stage, so the ambient fill stays behind it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(playback): address Greptile review on the Up Next rail - Stage overflow: `.player-shell__viewport` had no border-box sizing (the repo has no global reset), so the docked-rail modifier's 12px padding widened the stage past its container and the right edge was clipped. - Width gate: compute the width the rail actually receives (stage minus the docked layout's padding, the height-driven 16:9 player, and the flex gap) instead of raw stage slack, and observe the stage's border box so the modifier's own padding cannot feed back into the measurement. - Stalker lazy seasons: Ministra VOD-series seasons hold no episodes until opened, so the rail's next-season spillover stopped at the current season. Prefetch the following season while an episode plays inline. Adds regression coverage for the gate boundary, gate stability across the padding toggle, and the lazy-season prefetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): stop the rail spillover prefetch from retrying forever A failed or genuinely empty Ministra season resets isLoading while leaving episodes empty, so the prefetch effect re-requested the same season on every emission for as long as inline playback continued. Remember which seasons this view already requested and ask at most once each. Regression test asserts the empty-response case fetches exactly once and does not retrigger on further playback in the same season. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(stalker): let a failed spillover prefetch recover on the next episode The previous guard was permanent, so a transient network or authorization failure disabled the rail's next-season prefetch for the component's lifetime. Distinguish the two outcomes instead: - Answered (even with zero episodes) — a real answer, never asked again. - Failed — the claim is released, but pinned to the episode that triggered it, so the retry waits for the next playback change. Retrying immediately would loop, since the failure itself flips isLoading and re-runs the effect. The claim is taken synchronously; awaiting first let the isLoading flip re-run the effect and fire a duplicate request before the answer arrived. `loadEpisodesForSeason` now reports whether the portal answered; existing callers ignore the result and are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |