UI-24 from the UI consistency audit.
- No-artwork slides paint their gradient in CSS from the slide hue: a light
tint in the light theme, unchanged near-black in the dark one. The dark
gradient under the light page-coloured scrim read as a grey slab.
- The side scrim holds 88% of the page colour up to the slide's right edge
(inset + min(560px, 55%)), so the end of a full slide no longer sits on
about 45%.
- Narrow layout (container <= 720px): a full-bleed 90% scrim behind the text
block, a scrim-coloured text shadow, and an entrance without a fade so
that scrim never flashes the art on a rotation.
- --hero-body is 85% of the heading colour (was 72%).
- Light --app-rating-color #a16207 -> #7a4a00: measured 3.36:1 on the chip
over artwork, now 5.10:1. The details pages share the chip and token.
- Buttons cap at the slide width and end long labels in an ellipsis.
- The page gets one visually hidden h1 ("Dashboard"); slide titles are h2.
- One live region outside the re-created slide announces slide changes;
progress bars are named and VOD ones read "N% watched"; dots are 24px.
dashboard-hero-legibility.e2e.ts replaces every image with a checkerboard
and measures each piece of slide text from the screen in both themes, wide
and narrow, for backdrop, poster, no-artwork and live slides. On master the
worst cases were 2.35:1 (body text) and 2.65:1 (pills).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Load Roboto 600/700 and DM Sans 700 so Cyrillic and Greek headings render
real semibold and bold faces instead of a synthetic bold, keep
<html lang> in step with the UI language, and move every font weight onto
the 400/500/600/700 scale (JetBrains Mono at 500 or lighter; the dashboard
LIVE badge now uses the interface font at 700).
Add the `styles:font-weights:validate` ratchet guard and its CI step. It
reads stylesheets much as Sass and the browser do (cascade, layers,
mixins, content blocks, `@extend`, `@at-root`, `:is()`/`:where()`,
keyframes) and lists what it deliberately does not trace in its header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): scroll a focused rail card fully into view
Chromium skips its focus scroll when 32px or more of the element already
shows, so Tab onto the last source card of a rail that overflows by less
than a card left it half-hidden under the edge fade. The rail track now
handles focusin and scrolls to the first card-start snap position that
reveals the whole card; a plain "nearest" scroll is not enough because
mandatory snapping can round it back (seen on the live channel rail).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): keep mouse clicks on partly hidden rail cards
A mouse press focuses the card link on mousedown. Revealing the card at
that moment could slide it from under the pointer when the target snap
position overshoots (the live channel rail moves 316px for a 306px
card), so the click landed elsewhere. The rail now reveals a card only
for keyboard and programmatic focus, using the CDK FocusMonitor origin.
Adds an Electron E2E that checks the final layout after snapping: Tab and
focus() leave the last source card fully visible, and a mouse press keeps
the rail still and still opens the source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): tell pointer focus apart without touching the DOM
FocusMonitor toggles cdk-*-focused classes on the monitored track, so a
mouse press on a source card mutated the DOM before the click. The J2
"open a source" performance journey rejects iterations with DOM activity
between its settle snapshot and the click. Read the input modality from
the CDK InputModalityDetector in a focusin handler instead: it only
listens, so the rail stays untouched until the click.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): reveal script-focused rail cards after a mouse click
The input modality stays "mouse" after any click, so a later focus() on
a partly hidden card left it clipped. The rail now skips the reveal only
for focus caused by a press inside the track: the focus has to arrive
within 100ms of that pointerdown (650ms for touch, whose focus comes
with the tap's compatibility mouse events, as in the CDK FocusMonitor).
Only event timestamps are compared, so the DOM still stays untouched
before the click.
The E2E now clicks elsewhere before the script focus, and unit tests
cover a tap and focus() after an earlier mouse press.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): keep an over-wide focused rail card in view
In a window narrower than a card (or under zoom), a focused card could
never fit, so its own snap offset fell short of the needed scroll and
the rail jumped to the next card's snap point, moving the focused card
offscreen. Such a card is now aligned at its own start instead.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): select rail internals through stable test ids
The dashboard contract makes data-test-id hooks the supported Electron
E2E selector surface. The rail now exposes -viewport, -track and
-card-link hooks next to its existing ones, and the focus E2E selects
those (and the rail heading by role) instead of internal class names.
The dashboard doc lists the new hooks and records the focus-reveal
contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): destructive confirmations, verb labels and provider icons
Confirmations: ConfirmDialogData.confirmLabel is required, so no dialog can
fall back to "Yes"/"No"; the dismiss defaults to "Cancel" and
`tone: 'destructive'` styles the confirm with .app-destructive-button. Every
caller names its action ("Remove playlist", "Clear", "Refresh playlist",
"Cancel download" with a "Close" dismiss). The confirm button has the
confirm-dialog-confirm test id and drops its no-op color="primary".
The no-op `warn` color input becomes .app-destructive-button on the EPG
mapping, playlist item, error view, EPG/reset settings, delete-all and source
cleanup buttons, and on the unsaved-changes dialog's Discard.
Provider icons come from SOURCE_TYPE_ICONS in shared/interfaces (Xtream
cloud, Stalker cast, M3U playlist_play / link / description / subject) in the
add dialog, auto-import, empty state, playlist switcher, playlist rows,
dashboard source rail, command palette, Sources filters and both reset
summaries. Stalker no longer borrows the Dashboard icon, and Xtream no longer
shares a glyph with M3U URL playlists.
The playlist error view removed a playlist through the stale
PlaylistActions.removePlaylist: it dropped the playlist from state before the
delete ran, swallowed failures, skipped the source activity guard and showed
no toast. It now uses PlaylistDeleteActionService like every other removal,
commits only a completed delete, toasts and goes home. The unused action and
its effect are removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): one provider icon per playlist row, imperative Korean remove label
A restored Stalker or Xtream playlist can also carry a URL, and the row's
independent checks then showed the M3U URL icon next to the provider icon.
The row now switches on resolvePlaylistSourceIconKey(), the precedence every
other surface uses, so each source shows exactly one icon.
HOME.PLAYLISTS.REMOVE now names the confirm button and the row's delete
tooltip; in Korean it read "the playlist has been removed". It now says
"remove playlist", like every other locale.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): keep the auto-refresh badge on playlist rows with one provider icon
Showing one provider icon per row moved the auto-refresh badge into the M3U
branches only, so a restored Stalker playlist with a URL and auto-refresh
lost it although the URL is still re-fetched. The row now renders one icon
container: the provider icon from the shared precedence, then the badge for
any row with a URL or a local M3U, exactly the rows that showed it before.
The Xtream portal-status dot, used without source health, keeps that corner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ui): let the playlist row's cancel action render in the error color
The row's action buttons set `color: inherit`, and the selected row does so
again with more specific selectors. Both beat Material's token-driven icon
color, so the .app-destructive-button cancel action kept the row color
(selection blue on the active row). Pin the cancel button to
--mat-sys-error in both row states.
The large-deletion Electron E2E now checks the cancel color in both themes;
without this rule it reads rgb(47, 123, 255) instead of the error red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(ui): give the dialog service spec the now-required confirm labels
ConfirmDialogData.confirmLabel became required, and the spec still built
confirmations without one. Jest only transpiles, so the suite stayed green,
but the "Typecheck Jest spec programs" CI step rejected it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(dashboard): fill the hero rotation dot on the compositor
The active hero dot animated `width` 0 → 18px for every 8 s rotation, so
an idle dashboard with two or more slides ran style, layout and paint on
every frame. The fill is now a full-width bar that slides in with
`transform` under the pill's rounded clip. The `animationend` advance,
the pause and reduced-motion behaviour are unchanged.
Measured on the E2E build (visible, four slides, 120 s): layouts
10,405-10,677 -> 366-369, renderer process CPU 14.2-16.4 s -> 3.7-4.1 s,
GPU process CPU 14.8-18.8 s -> 14.0-14.8 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(e2e): drive the real hero rotation animation
The unit specs dispatch `animationend` on the dot span by hand, so a fill
whose real event no longer reached the handler would still pass. The new
Electron spec shortens `--hero-rotation-ms` and checks that the running
`::before` fill advances the slide, that pause holds it and that Play
resumes it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(dashboard): slide live EPG progress fills with a compositor transform
The live-programme bars on channel rail cards and the hero animated their
width over 0.4 s whenever the 30 s live-EPG tick moved them. Width is a
layout property, so each tick re-laid out the whole document for about 24
frames, also while the window was minimized. Slide a full-width fill with
translateX driven by a --live-progress custom property instead; Chromium
runs that transition on the compositor. Reduced motion drops it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): format the live progress spec
* perf(dashboard): stop animating the live EPG progress fills
A live-EPG tick moves the bar by under one percent, so the 0.4 s transform
transition was invisible but still produced a burst of compositor frames
on every tick. Place the fill without a transition.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(dashboard): pin the hero progress fill to its custom property
The hero moved into DashboardHeroComponent (#1738); its progress bar gets
the same transform fill as the rail cards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(dashboard): hold rail skeletons back so empty rails stop shifting the page
On every launch with sources the dashboard shifted by about 0.23 (the
"good" CLS threshold is 0.1). The rails render as soon as their own data
arrives, and each loading rail showed a 328 px skeleton immediately. On a
normal profile the live-favorites and recent-content sources resolve empty
15-20 ms later, so their skeletons flashed and collapsed and every rail
below jumped up by about 360 px. J1 never saw it: its layout-shift window
ends at the first card, which is painted just before the collapse.
Rail skeletons now wait out a 300 ms grace period (createRailSkeletonGrace)
and appear only for a rail still loading after it; the hero keeps its
immediate skeleton because it reserves the top of the page. Recorded over
three renderer reloads of a seeded profile, the dashboard's layout shift
drops from 0.219-0.234 to 0.0004, with the real rails painted at the same
time as before. Plan thread C5, journey J1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(dashboard): gate rail skeletons per rail, never above visible rails
Addresses the Codex and Greptile reviews on #1738. The component-wide grace
timer started when the dashboard was created, so a rail that begins loading
later (Xtream recently added, TMDB) showed its skeleton at once and could
still flash and collapse; and after the grace period a slow rail's skeleton
could appear above rails that already showed cards, pushing them down and,
if it resolved empty, back up.
createRailSkeletonGates now keeps one gate per rail, in template order: the
grace period counts from that rail's own loading start, a skeleton is never
inserted above a rail that already has cards (the real rail inserts at most
once instead), and a shown skeleton stays until its own rail finishes so the
first arriving rail does not collapse the others in a cascade. Nine specs
cover the fast path, per-rail start, the no-content-below rule, latching,
reloading, destroy and a zero grace period. The seeded-profile timeline is
unchanged at 0.0004 across three renderer reloads.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* build(test): make spec tsconfigs resolve what Jest resolves
Lib spec tsconfigs used module: commonjs with node10 resolution, which cannot
see Angular's exports-only secondary entry points, and dropped global.d.ts, so
tsc reported thousands of resolution errors and no window.electron typing.
Switch them to module: preserve with bundler resolution (ts-jest still forces
CommonJS emit outside ESM mode), add global.d.ts to every spec program, type
jest.unstable_mockModule for the ESM workspace, include the ui-epg and
ui-playback specs that jest.web-esm.workspace.ts runs under the web spec
config, and drop the snack-bar stub that shadowed the real Material types.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* ci(test): gate spec type-checking with typecheck:spec
Add tools/typecheck/spec-typecheck.mjs, which runs tsc --noEmit over every
tsconfig.spec.json with a small pool and fails on any diagnostic, wire it into
the unit-and-typecheck job after typecheck:ci, and document the gate and the
spec tsconfig conventions in the validation map. Also bring the non-Tier-A
spec configs (remote-control-web, ui-remote-control, stalker-mock-server) to
the same conventions so the gate covers the whole workspace.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test: fix the spec type errors surfaced by typecheck:spec
With the spec programs resolving modules and ambient typings correctly,
tsc reported 432 genuine errors across the Tier A projects: read-only
capability flags assigned on Partial<> doubles, signal-store values used as
types, fixtures missing required fields, index-signature property access,
partial bridge doubles cast through incompatible shapes, and deferred
resolvers narrowed to never. Type the doubles instead of casting to any:
writable mapped types for capability flags, InstanceType<typeof StalkerStore>,
typed jest.fn signatures, protectedState: false on test signal stores, and
completed fixtures. Production changes are limited to bracket access for
index-signature properties under the libs' noPropertyAccessFromIndexSignature
setting and two narrowing guards in the global favorites loader.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(playback): use the ESM setup's jest global in the controls fixtures
The fixture imported jest from @jest/globals, which is not a direct
dependency. Jest provides that module at runtime, so tests passed, but on a
clean pnpm install tsc cannot resolve it and typecheck:spec failed in CI.
The ESM test setup already installs import.meta.jest as the global, typed
by @types/jest, as the other ESM specs use it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: type the parental lock doubles merged since the gate was written
The parental lock feature (#1601) and the Stalker actor route landed on master
with spec doubles declared as zero-argument jest.fn()s that the tests then
drive with the real arguments, plus a copy of the ResizableDirective override
imported from a library that does not export it. Give the doubles the lock
service's real signatures, drop the dead override as in the sibling layout
specs, use bracket access for the actor route's personId param, and keep the
Stalker layout spec within the 1200-line limit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Tier A coverage runs projects a few at a time (largest first, bounded Jest workers, buffered output, fail-fast kept) and ts-jest transpiles with isolatedModules instead of type-checking per process; five type re-exports become export type, two decorated inputs use import type. Unit Tests and Typechecks job: 26 min -> 9 min (Tier A step 23 min -> 6.5 min).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Xtream and Stalker live cards on the dashboard carried no XMLTV key, so the
rails never asked anything for them and showed only the LIVE chip. Their
programme now comes from the portal, one card at a time and only once the card
is on screen.
- `lib-dashboard-rail` reports the cards inside its viewport through an
IntersectionObserver rooted at the track; `DashboardLiveEpgPresenter` unions
them with the pinned hero row and hands the set to
`DashboardPortalLiveEpgPresenter`.
- `DashboardPortalLiveEpgService` runs the bounded queue — two requests in
flight, 200 ms apart, one card each — through
`StreamResolverService.loadEpgForItems`, publishing every answer the moment
it lands, so the page never waits and a slow portal delays no other card. A
card scrolled past before its turn is never requested.
- A programme is trusted for 60 s, an empty answer for 30 s (the resolver
reports a dead portal and a guide-less channel identically), and a
completion captures both the display offset and the EPG source revision,
requeueing itself when either moved.
- The presenter hands its wanted set back on destroy. Desktop only: the shared
collection resolver is gated on the local XMLTV bridge.
- A shimmer placeholder shows only before a card's first portal answer; M3U
cards keep the batched XMLTV lookup.
Every dashboard title carried a "Xtream · Series" / "Stalker · Movie" subtitle. Provider kind and content kind are the app's own taxonomy, not a property of the title, and they are identical on every card in a rail — so the one line that could tell two cards apart said nothing.
The hero now shows the source name alone, through `playlistDisplayLabel` (a stored playlist name is routinely the pasted URL with credentials, or a MAC). Continue Watching cards show what actually varies: the "S1·E5" chip plus "12 min left". Favorites keep the title alone, Recently Added keeps the source name, and a meta row with nothing in it is no longer rendered.
Stalker shows filed under Movies had no badge, no progress and no resume. An embedded-VOD row announces its episodes through a `series[]` array and carries no `is_series` flag, so `extractStalkerItemType` reports `movie` on purpose — the item must keep routing to the VOD catalog — while its progress is a set of episode positions keyed by the parent id, which the dashboard was looking up as a single `vod` row and never finding.
Split the two questions: `PortalActivityItem.watch_kind` records the progress model when it differs from the routing type, and every reader that has to choose goes through `resolvePortalActivityWatchKind` instead of `type`. That makes the resume handoff reachable for Stalker, so wire it through `STALKER_SERIES_RESUME_TARGET`: consumed once after the series positions are read, hydrating a lazy Ministra season first with a bounded two-attempt retry, and playing nothing at all when the position read failed rather than restarting the episode from zero.
Also fixes the global-recent route template, which bound `[seriesResume]` only on its Xtream branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* chore(deps): upgrade Angular to 22.1 and Nx to 23.2
* fix(deps): complete Angular migrations after rebasing on master
* fix(ci): use the Node pin for Windows runtime refresh
* docs(deps): synchronize the workspace-shell Node requirements
`epgTimestampMs` in the dashboard live-EPG helpers returned
`EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are
unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg`
multiplies by 1000). The bug was latent: the backend
`GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO
fallback always ran.
Scale a positive finite timestamp by 1000 and treat zero/non-finite
values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec
that feeds seconds-based timestamps and asserts the formatted range and
progress, and fix the rails component spec fixture, which stored
millisecond values in the seconds fields and so encoded the old bug.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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>
* 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>
* 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>
The dashboard hero showed no backdrop for items whose detail page had one.
Two independent causes, both about identity rather than the matching gate.
Stalker items never reach the `content` table, so the xtream back-fill of
`content.backdrop_url` has no equivalent for them — but the enriched backdrop
is already sitting in the stored playlist entry (`info.tmdb_backdrop`). The
activity mappers now surface it as `backdrop_url`, where the hero already
looks first.
The hero's TMDB lookup ran on the display title alone, while the detail view
searched with the original title and the release year. Without a year
`pickConfidentMatch` requires a single exact title match, which common titles
never satisfy, and the miss lands in the negative cache under a lookup key the
detail view's hit can never be found at. The query is now built from the same
fields (`extractStalkerItemTmdbHints`), and the resolved `tmdb_id`
short-circuits the search entirely.
A 'movie' verdict retries as 'tv' without the id — 'movie' is the answer every
row falls back to, and an id is valid only for its own media type. A 'tv'
verdict, reached only on positive series evidence, gets no retry back to
'movie'.
Also removes `buildStalkerRecentItems`, a dead duplicate of the mapper the
dashboard actually uses.
* 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>
* 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>
* feat: add strip country prefix setting
* feat: scope country-prefix stripping to live content and cover missing surfaces
- narrow the heuristic: pipes always strip, dash/colon separators only
when the prefix is a short uppercase tag ("UK - BBC One" strips,
"Sky - Sports F1" and "Mission: Impossible - Fallout" stay intact)
- fall back to the original name when stripping would leave nothing
- scope stripping to live content only: grid type (live/itv/radio),
playback isLive, external sessions without contentInfo, dashboard
cards with contentType 'live'
- cover previously missed surfaces: M3U player EPG timeline header,
M3U inline player title, radio player, dashboard live rails
- replace hardcoded settings strings with translate keys and add
SETTINGS.STRIP_COUNTRY_PREFIX(_DESCRIPTION) to all 18 locales
- add unit specs for the utility plus regression specs for
channel-list-item and external-playback-dock
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: cover strip-country-prefix call sites for codecov
- dashboard-rail: new spec for cardTitle live/movie/series scoping
- grid-list: strip enabled/disabled, VOD passthrough, 'No name' fallback
- portal-inline-player: live strip vs VOD passthrough
- unified-live-tab: timeline channel name strip + M3U name precedence
- video-player: timeline/radio/inline titles with the setting on and off
- settings-store: default false + persisted true round-trip
- settings-form.utils: new spec for form default and ?? false fallback
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <serega05@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat(dashboard): TMDB trending rail and hero enrichment (backdrop, badges, S/E)
Two dashboard additions, both async after first paint so the page renders
exactly as fast as before:
Trending rail ("Trending this week", dashboardRails.tmdbTrending toggle,
default on, rendered only when TMDB is opted in AND the Electron DB
worker is available):
- TmdbTrendingService fetches /trending/{movie,tv}/week (one request
each, cached one day per language in tmdb_metadata under
trending:week), merges by popularity; exposed via the enrichment
facade (getTrendingWeek)
- DashboardTrendingService matches the titles against imported Xtream
playlists with ONE batched DB_MATCH_TITLES request, applying the same
two-tier + year-compatibility rule as actor pages; matched cards show
the playlist name and navigate straight to the detail view, unmatched
cards open the global search prefilled (?q=)
- The load fires only after the dashboard's own recent/favorites data
is in (never competes for the worker at startup) and once per session
- DashboardRailCard gained optional queryParams for the search links
Hero enrichment:
- DashboardHeroTmdbService patches the hero with a TMDB backdrop (only
when the item has none), a rating badge and up to two genre chips —
via the enrichment facade, so previously opened items resolve from
the SQLite cache without network; memoized per title per session,
staleness-guarded against hero changes in flight
- Series heroes show the tracked "S{n}·E{n}" badge from the playback
position; the watch-progress bar no longer applies to live heroes
Settings: new dashboardRails.tmdbTrending toggle in Settings > Dashboard.
i18n: 3 new keys translated into all 17 locales via tools/i18n patches.
Tests: dashboard-trending.service.spec.ts (gating, matching, year guard,
single-flight); settings fixtures updated. Docs updated
(tmdb-metadata-enrichment.md Dashboard Integration section, CLAUDE.md).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): resolve hero TMDB extras for Stalker embedded-series items
Stalker vclub items carry type 'movie' in activity rows but are TV shows
on TMDB, so the hero's movie lookup found no confident match and the
backdrop/badges never appeared — while the detail view (which resolves
via is_series) showed them. When a movie-typed hero item has no movie
match, retry the lookup as TV: the detail view has usually already
cached that resolution, and misses are negative-cached.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tmdb): cross-portal "Similar" rail — Stalker gets it, Xtream gains other-portal matches
The Similar rail only existed on Xtream because it matched against the
locally loaded catalog; Stalker catalogs are server-paginated, so its
detail views had no rail despite tmdb_recommendations being cached.
New CrossPortalSimilarService (libs/services) matches recommendations
against ALL imported Xtream playlists with one batched DB_MATCH_TITLES
worker request — the same two-tier normalized-title + year-compatibility
rule as actor pages and the trending rail. Electron-only; resolves to []
in the PWA.
- Stalker: the shared VodDetailsComponent (movies; covers catalog and
inline detail hosts) and stalker-series-view (series) now render a
"Similar" rail from cross-portal matches, each card badged with the
source playlist and navigating into that portal's detail view.
- Xtream: vod/serial detail rails keep instant local-catalog matches and
append cross-portal matches (current playlist excluded, deduplicated
against local hits by normalized exact title), also playlist-badged.
- Loads async after the detail view renders, staleness-guarded; the
section only appears when there is something to show.
Tests: cross-portal-similar.service.spec.ts (PWA gate, navigation
targets, playlist exclusion, type/year guards). Docs updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(tmdb): drop TestBed from cross-portal similar spec
The services Jest target has no @angular/core/testing (same CI failure
as the cache spec earlier) — construct the service via Injector.create +
runInInjectionContext instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dashboard): address PR review — reactive opt-out gates, retry after empty trending load
- Trending rail and hero TMDB extras now vanish immediately when the
TMDB opt-in is switched off mid-session: the render computeds read the
settings signal through isAvailable/isEnabled instead of trusting data
loaded earlier (Codex P2 ×2).
- loadedOnce latches only after a successful non-empty load, so a
transient TMDB outage on first visit no longer suppresses the rail for
the whole session — the next dashboard visit retries (greptile P2).
- Unified the duplicated heroTmdbExtras() read in the hero computed
(greptile P2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
For series in the Continue Watching rail, surface which episode the user
left off on as a small "S2·E5" chip next to the card subtitle. Sources
the season/episode straight off the matched PlaybackPositionData so the
information is always in sync with the resume target.
Fixes a latent bug in the same patch: getPlaybackPositionForItem could
not resolve series whose recent_items row carried the series id (the
landing-page path), because playback_positions are keyed by the episode
id. The lookup now matches both shapes (contentXtreamId === xtreamId OR
seriesXtreamId === xtreamId) and prefers the most recently updated
episode, which also restores the resume progress bar that had been
silently dropping out for series.
Localised in all 17 non-English locales — most use language-specific
short forms (St·F in German, T·E in Spanish/Portuguese, С·Э in
Russian/Belarusian, 시즌N N화 in Korean) rather than the English S·E.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related dashboard fixes driven by the v0.22 mockup intent and the
UX-audit feedback that the rail labels lied about their data:
1. Live rail is favorites-first
- Source: globalFavoriteLiveItems() — the channels the user actually
starred. Falls back to globalRecentLiveItems() when no favorites
exist so fresh-install users still see something useful.
- Title flips with the source: "Live now on your favorites" when
pulling from favorites, "Continue with live TV" when pulling from
recent-watch history. The label is always honest about the data.
- "See all" link routes to the right collection page for the source
(/workspace/global-favorites vs /workspace/global-recent).
2. Mixed Global Favorites rail removed from the dashboard
- The rail had movies, series, live channels, and radio all sharing
one row — different card formats fighting for visual attention.
UX wiki principle: similar elements should look alike within a
scanning unit.
- Live favorites are promoted into the live rail above (with current
EPG). The full mixed catalogue is still one click away at
/workspace/global-favorites where the collection page can give it
proper per-type filters.
- Net dashboard density goes from 5 rails to 4 — closer to the
streaming-app sweet spot.
i18n: new WORKSPACE.DASHBOARD.LIVE_CONTINUE key, translated across all
17 locales by per-locale agents (placeholder integrity verified).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the playback-position store into DashboardDataService so the hero
and Continue Watching cards can show how far through each title the user
is. Per-playlist bulk fetch via getAllPlaybackPositions (one IPC call
each, no N+1) cached in an in-memory Map keyed by playlist + content id
+ content type — supports both VOD and episode keys, since the same
xtream-id can map to either.
Hero:
- Thin 4px progress bar under the subtitle when a position is known
- "1h 04m left · 38% watched" meta line below it (formatRemainingLabel
handles sub-minute, minute, hour, and hour+minute remainders)
- Tighter padding (16 vs 20) and smaller poster (140 vs 160) per the
v0.22 mockup spec — frees ~30px of vertical space without losing
legibility
Continue Watching cards:
- 3px progress overlay pinned to the bottom of the poster art, painted
in the unified blue primary (--app-selection-color)
- Renders only when watchProgress is set, so live channels and untracked
M3U items remain unaffected
Live channels and M3U items never have positions in the schema, so the
new UI is purely additive — both surfaces degrade gracefully to the
prior layout when the lookup misses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Live now on your favorites" rail no longer reuses the 2:3 poster
card built for movies. TV station logos are typically 100–256px square,
so inflating them into portrait cards wastes most of the height and
hides the data users actually care about (what's airing right now).
Introduce a `layout: 'cover' | 'channel'` input on DashboardRailComponent.
The new channel layout is a wider, much shorter card: logo + channel
name + current program title + LIVE chip + progress bar + time range.
Current programs are lazy-loaded via EpgService.getCurrentProgramsForChannels
(already batched + 60s-cached at the service), keyed by the recent item's
display name — works out of the box for M3U sources whose XMLTV channels
resolve through the tvg-id → tvg-name → name fallback chain. A 30s tick
keeps the progress bar fresh between program boundaries.
The card renders gracefully without EPG enrichment (idle progress bar,
no program title) so Xtream/Stalker live items without an XMLTV side
channel still look fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per UX audit: a single "Recently Watched" rail mixed channels and VOD
into a grid where the two card formats fought for visual attention.
Split it into two rails — "Continue watching" (movies/series) and
"Live now on your favorites" (live channels) — so each surface uses
the card format that fits its content type.
Also relabel the rail header link from "Manage all" to "See all {N}"
when the total count exceeds what's rendered, so the user knows how
many items the link expands into; falls back to "See all" otherwise.
i18n: three new keys (LIVE_RECENT, SEE_ALL, SEE_ALL_COUNT) translated
across all 17 locales by per-locale agents reusing each file's existing
glossary; placeholder integrity verified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent fixes that compound: the dashboard now renders progressively
as each rail's data resolves, and the slowest of those rails (Xtream
recently-added) no longer scans the entire content table.
Per-rail skeletons (template restructure):
The old @if (!ready()) gate hid the whole dashboard until ALL FOUR loading
flags resolved (playlistsLoaded, globalRecentLoaded, globalFavoritesLoaded,
xtreamRecentlyAddedLoaded). The slowest one pinned the entire skeleton up
for the full tail latency — visibly seconds of "loading" even when 3 of 4
rails could have rendered immediately.
Replace with per-rail conditionals:
- Hero: renders the moment globalRecentItems[0] is available; skeleton
shows only while data.globalRecentLoading() is true and no item exists.
- Each rail: shows real content if its cards are non-empty, its own
skeleton if its dedicated loading flag is true, nothing otherwise.
- The Xtream recently-added rail's skeleton is gated on having Xtream
playlists at all, so M3U-only users never see a skeleton for it.
The loading signals were already exposed on DashboardDataService
(globalRecentLoading, globalFavoritesLoading, xtreamRecentlyAddedLoading)
but went unused because of the monolithic gate. Same skeleton markup is
reused per rail; no styling changes.
Drop CAST(added AS INTEGER) in getGlobalRecentlyAdded:
The query ordered by sql<number>\`CAST(content.added AS INTEGER)\`. SQLite
cannot use an index on a column wrapped in a function, so the existing
idx_content_type_added index was bypassed and the planner did a full table
scan + sort on content (10k–100k+ rows for a typical Xtream catalog) on
every dashboard load.
Sort by schema.content.added directly. Xtream stores Unix-epoch timestamps
as 10-digit numeric strings (anything since 2001-09-09), so lexicographic
and numeric sort are equivalent. The (type, added) index now drives the
ORDER BY too — full table scan becomes an index range scan + LIMIT 20.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 1e3392724bc8
Render live TV channel logos with object-fit: contain on a soft blurred
wash sampled from the logo itself, instead of stretching small (100-256px)
logos to fill 2:3 portrait cards with object-fit: cover. Mirrors the
treatment used by Apple Podcasts and Plex Live TV. VOD/series posters
still get the full-bleed cover treatment.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 6bb6f5796090