Files
iptvnator/docs/architecture/workspace-dashboard.md
T
4grayandClaude Fable 5 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>
2026-08-12 07:39:43 +02:00

15 KiB

Workspace Dashboard

This document records the current dashboard implementation inside the workspace shell.

Related:

Summary

  • The dashboard is the default /workspace landing page.
  • It is a rail-based content surface (Netflix / Apple TV pattern), not a customizable widget grid.
  • Layout order is static and curated — there is no edit mode, drag-drop, or size stepper. Each rail has a persisted show/hide toggle (Settings.dashboardRails, DashboardRailsSettings in libs/shared/interfaces/src/lib/settings.interface.ts, surfaced under Settings → Dashboard); every template rail is gated by dashboardRails().<key>. Rails additionally auto-hide when empty.
  • First-run users see the shared welcome empty-state with a single primary CTA to add their first playlist.

Core implementation:

  1. libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts — the page-level facade.
  2. libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts — the reusable horizontal rail.
  3. libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts — data aggregation (recent items, favorites, playlist stats). Shared across rails.
  4. libs/playlist/shared/ui/src/lib/recent-playlists/empty-state/empty-state.component.ts — reused welcome state with the primary "Add your first playlist" CTA.

Page Structure

┌─────────────────────────────────────────────────────────────────────┐
│  Hero — Continue Watching (most recent item)                        │
├─────────────────────────────────────────────────────────────────────┤
│  Continue Watching · See all →                                      │
│  [poster][poster][poster][poster] →→                                │
├─────────────────────────────────────────────────────────────────────┤
│  Live now on your favorites · See all →                             │
│  [channel][channel][channel][channel] →→                            │
├─────────────────────────────────────────────────────────────────────┤
│  Recently watched live TV · See all →                               │
│  [channel][channel][channel][channel] →→                            │
├─────────────────────────────────────────────────────────────────────┤
│  Favorite movies & series · See all →                               │
│  [poster][poster][poster][poster] →→                                │
├─────────────────────────────────────────────────────────────────────┤
│  Recently Used Sources · See all →                                  │
│  [tile][tile][tile][tile] →→                                        │
├─────────────────────────────────────────────────────────────────────┤
│  Recently Added on Xtream (aggregated across providers)             │
│  [poster][poster][poster] →→                                        │
├─────────────────────────────────────────────────────────────────────┤
│  Because you watched X (TMDB, opt-in, Electron-only)                │
│  [poster][poster][poster] →→                                        │
├─────────────────────────────────────────────────────────────────────┤
│  Trending this week (TMDB, opt-in, Electron-only)                   │
│  [poster][poster][poster] →→                                        │
└─────────────────────────────────────────────────────────────────────┘

Render rules:

  1. Dashboard rails render independently as their data sources resolve. The page no longer uses dashboardReady() as a page-wide skeleton gate. Initial hero/recent/favorites loading states render scoped skeletons so one slow rail does not hide already available content.
  2. hasPlaylists() === false → render <app-empty-state [type]="'welcome-dashboard'"> full-bleed. All rails and the hero are skipped.
  3. hero() = globalRecentItems()[0]. If present, render the hero panel.
  4. Each rail is emitted via @if (cards.length > 0). Empty rails are hidden — there is no "empty widget" placeholder.
  5. The continue-watching hero prefers a stored Xtream backdrop_url; when it is missing the UI falls back to a blurred poster treatment instead of showing a flat panel.
  6. Live favorites are promoted into their own live rail; movie/series favorites render in a separate Favorite movies & series rail (favoriteMoviesAndSeriesCards, data-test-id="dashboard-favorite-vod-rail", mapped from globalFavoriteItems() filtered to movie/series). Full mixed favorites management stays on /workspace/global-favorites.
  7. The live favorites rail keeps its scoped skeleton until the initial global favorites load has completed for both Xtream-backed and playlist-backed favorites. This avoids first-paint partial counts such as a single Stalker favorite appearing before M3U favorites finish resolving.

Rail Contract

DashboardRailComponent is purely presentational:

  1. Inputs: label, items: DashboardRailCard[], optional seeAllLink, optional aspectRatio (default '2 / 3'), optional testId.
  2. Behavior: horizontal flex track with scroll-snap-type: x mandatory.
  3. Chevron buttons fade in on hover (desktop only via @media (hover: none)).
  4. Cards are keyboard-focusable router links; scroll-snap-align: start means arrow-key nav lands on card boundaries.
  5. Image handling: loading="lazy", decoding="async", fallback icon tile when imageUrl is missing or error fires.
  6. Dashboard hero, rail containers, rail cards, and "Manage all" links expose stable data-test-id hooks. Treat these as the supported Electron E2E selector surface; do not target internal CSS class names.

Data Flow

  1. WorkspaceDashboardRailsComponent injects DashboardDataService.
  2. It derives the dashboard surface via computed():
    1. hero — first item of globalRecentItems().
    2. continueWatchingCards — maps globalRecentVodItems() to movie/series cover cards. Portal playback positions are bulk-loaded per playlist so hero and cards can show progress, remaining time, and series season/ episode badges. This includes Stalker VOD activity normalized to series through is_series. Series lookup uses keyed maps for both direct episode ids and parent series ids; card renders must not scan the full playback-position map. The badge uses saved seasonNumber / episodeNumber metadata and does not infer it from provider payloads; legacy rows without that metadata remain badge-less until replay. Dashboard-originated Xtream series clicks also carry that exact episode target through the global-recent inline-detail handoff. Once the series metadata and playback positions load, the detail player consumes the target once and resumes the saved episode. Opening the same item normally from the global recent grid remains a detail-only action.
    3. liveFavoriteCardsEnriched and recentLiveCardsEnriched — two independent rails (dashboard-live-favorites-rail and dashboard-recent-live-rail); there is no fallback from one to the other. M3U cards carry an epg_lookup_key using the app-wide XMLTV fallback order (tvg-id -> tvg-name -> channel name); EPG enrichment must use that key before falling back to the card title.
    4. xtreamRecentlyAddedCards — maps xtreamRecentlyAddedItems() to rail cards. Aggregates newly added VOD and series across all Xtream playlists via DashboardDataService.reloadXtreamRecentlyAddedItems(), which calls getGlobalRecentlyAdded('all', limit, 'xtream') with the DB-level playlists.type = 'xtream' filter. The rail is Electron-only (PWA returns []) and auto-hides when empty, so users without Xtream playlists never see it. Cards carry a playlist_name · type subtitle so users can tell which provider each item came from. Driven by an effect that re-runs whenever the Xtream playlist count changes, but the first run waits for globalFavoritesLoaded() so the slower recently-added DB query does not block the live favorites rail on startup.
    5. recommendationCards / trendingCards — the two TMDB rails. Both need the TMDB opt-in AND the Electron DB worker that answers DB_MATCH_TITLES (each is hidden in the PWA), and both load after globalFavoritesLoaded() so the batched title match never competes for the worker at startup. recommendationCards is seeded from recently watched movies/series and only shows titles present in an imported library, hiding itself below five matched cards; its rail label names the seed ("Because you watched X") when exactly one seed contributed. trendingCards shows TMDB's weekly trending and falls back to a prefilled global search for unmatched titles. Contracts: docs/architecture/tmdb-metadata-enrichment.md ("Dashboard Integration").
    6. sourceCards — maps recentPlaylists() to rail cards. recentPlaylists() ranks M3U, Xtream, and Stalker sources by their latest recent activity from globalRecentItems(), then falls back to playlist updateDate / importDate for sources that have never been used.
  3. DashboardDataService is passive on construction. The dashboard feature owns the initial reloads for recent items, favorites, and Xtream recently added rows on page entry.
  4. No dashboard-local Layout state, no localStorage keys, no migrations. Per-rail visibility is the one persisted preference, and it lives in the global settings store (Settings.dashboardRails), not in a dashboard-owned layout blob.
  5. Navigation state + deep-link targets come from the existing getRecentItemLink() / getGlobalFavoriteLink() / getPlaylistLink() helpers on DashboardDataService and reuse the workspace navigation helpers in @iptvnator/portal/shared/util.
  6. Xtream VOD and series detail pages opportunistically backfill content.backdrop_url when metadata exposes a backdrop, but that write must not refresh recently viewed ordering by itself.
  7. The dashboard feature triggers a fresh reload of DB-backed recent/favorite rows on dashboard entry so newly backfilled backdrop data is visible as soon as the user returns from a detail page.
  8. Playback-position reloads are keyed by the VOD/series recent set and should call reloadPlaybackPositions() through untracked() so live-only recent changes do not trigger unnecessary IPC round-trips.
  9. Electron M3U dashboard favorites should use PlaylistsService.getM3uFavoriteChannels() first. That method checks the SQLite playlist migration flag and then calls dbGetAppPlaylistFavoriteChannels(playlistId), letting the DB worker return only matched favorite channels instead of sending the full playlist payload back to the renderer. If the bridge method is missing or migration is incomplete, the dashboard falls back to the full playlist read.
  10. Electron playlist summary loads should use dbGetAppPlaylistMetas() through PlaylistsService.getAllPlaylists(). This keeps dashboard/source/sidebar startup on a metadata-only SQLite path and avoids parsing full M3U payload blobs for surfaces that only need playlist title, type, counts, favorites, recent activity, and source connection fields. Workflows that need channel payloads still call getPlaylistById().

Empty State

The welcome state is rendered via the existing EmptyStateComponent (type="welcome-dashboard") from libs/playlist/shared/ui:

  1. Illustration + headline + description from the existing M3U welcome strings (HOME.PLAYLISTS.WELCOME_*).
  2. Primary button emits addPlaylistClicked. The dashboard page wires this to WORKSPACE_SHELL_ACTIONS.openAddPlaylistDialog().
  3. Feature chips (M3U / Xtream / Stalker) are provided by the component.

UX Rules

  1. Rails represent content the user is likely to resume, not provider internals. Never surface raw API objects.
  2. Each rail must auto-hide when its data source is empty.
  3. Image assets must degrade to a typed icon fallback — never show broken images or empty tiles.
  4. The page must never show "No widgets" style text. If there is no content and no playlists, render the welcome state; otherwise render whatever rails have data.
  5. Navigation from a rail card must deep-link into the appropriate workspace route without switching the active playlist in the header switcher.
  6. Xtream series hero/Continue Watching clicks with a saved episode position must resume that exact episode while preserving the collection-owned detail and Back behavior. Do not apply autoplay to ordinary collection-grid clicks.
  7. Recently Used Sources reflects recent source usage across all provider types, not just recent imports.
  8. The live rail title key must match the rendered source: favorites use WORKSPACE.DASHBOARD.LIVE_FAVORITES; the recently-watched-live rail uses WORKSPACE.DASHBOARD.RECENTLY_WATCHED_LIVE_TV (liveRailTitleKeyForSource in rails/dashboard-rail.utils.ts).

Adding Or Changing Rails

Current workflow:

  1. Add a new computed() signal for the card list in WorkspaceDashboardRailsComponent, mapping your source data to DashboardRailCard.
  2. Drop a <lib-dashboard-rail> in the template, gated by @if (cards.length > 0).
  3. If the data source is new, extend DashboardDataService rather than reaching into DB services directly from the component.
  4. Provide a seeAllLink only if there is a dedicated "manage all" route for that content type.

Deferred Work

Intentionally out of scope:

  1. Customizable layout (drag/drop, resize, freeform reordering). The rail order stays curated and opinionated. (Per-rail show/hide toggles have since shipped via Settings.dashboardRails — see Summary.)
  2. Freeform widget grid with collision management.
  3. External data rails such as RSS, sports, or news adapters.
  4. Per-user A/B variants of rail ordering.