mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* docs(agents): compact root guidance and preserve task-specific knowledge * fix(agents): parse guidance navigation with Markdown tokens * fix(agents): validate generic literal repository paths * fix(agents): distinguish code symbols and shortcut images * fix(agents): recognize SCSS filename literals * fix(agents): handle fenced imports and encoded paths * fix(agents): parse prose and rendered HTML anchors * fix(agents): validate rendered HTML navigation * fix(agents): use GitHub-compatible heading slugs * fix(agents): require standalone top-level Claude import * fix(agents): exclude HTML-contained guidance imports * fix(agents): handle image fragments and quoted imports * fix(agents): validate visible HTML and image source sets * fix(agents): recognize package scopes and route source work * fix(agents): parse JSONC and constrain package exemptions * fix(agents): decode link entities and allow package subpaths * fix(agents): route source work and check extensionless files * fix(agents): support package versions and source fragments * fix(agents): accept qualified package prose * fix(agents): retain rendered context for Markdown references * fix(agents): validate visible headings and spaced paths * fix(agents): validate media and hyphenated literal paths * fix(agents): decode full HTML entities and media assets * fix(agents): recognize possessive package mentions * fix(agents): validate extensionless imports and version comparators * fix(agents): retain visible backticks and explicit path punctuation * fix(agents): validate image-map navigation targets * fix(agents): count all Markdown line endings in budgets * fix(agents): delimit package prose at Unicode punctuation * fix(agents): normalize punctuation for extensionless imports * fix(agents): preserve filenames across prose punctuation * fix(agents): validate iframe document references * fix(agents): inspect document suffix before URL fragments * fix(agents): unify Markdown suffix and encoded import guards * fix(agents): handle wildcard versions and alternate documents * fix(agents): validate document formats and trim HTML URLs * fix(agents): cover document families and guidance basenames * fix(agents): require files for media references * fix(agents): preserve block boundaries and validate embeds * fix(agents): normalize internal HTML URL whitespace * fix(agents): reject empty media and ignore URL at-signs * fix(agents): validate srcdoc references and empty srcset * fix(agents): honor HTML bases and preserve adjacent imports * fix(agents): convert base file URLs to native paths * fix(agents): preserve imports after bare URL punctuation * fix(agents): exclude opaque URI prose from import scans * fix(agents): keep import tokens outside URI scheme matches * fix(agents): restrict opaque URI exemptions to parsed links * fix(agents): handle opening prose delimiters * fix(agents): scan nested imports and share document suffixes * fix(agents): reject pathless media and direct file URLs * fix(agents): reject file bases and preserve quoted URL boundaries * fix(agents): distinguish URL quotes and cover guidance variants * fix(agents): validate SVG images and conventional guides * fix(agents): handle declared package names handles and SVG use * fix(agents): normalize closing punctuation on federated handles * fix(agents): normalize Unicode punctuation on handles * fix(agents): normalize possessive federated handles * fix(agents): separate parenthetical prose from handles * fix(agents): exclude www autolinks from import scanning * ci: allow manual CodeQL validation of PR branches * fix(agents): reject nonportable Windows drive links
340 lines
21 KiB
Markdown
340 lines
21 KiB
Markdown
# Workspace Dashboard
|
|
|
|
This document records the current dashboard implementation inside the workspace
|
|
shell.
|
|
|
|
Related:
|
|
|
|
- [Workspace Shell](./workspace-shell.md)
|
|
|
|
## 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. Whether an item is looked up as a movie (one `vod`
|
|
row) or a series (episode rows under the parent id) is its WATCH
|
|
kind, `resolvePortalActivityWatchKind`, not its routing `type`. The
|
|
shape that needs the distinction is a Stalker embedded-VOD row: its
|
|
stored entry carries a `series[]` episode array but no `is_series`
|
|
flag, so `extractStalkerItemType` reports `movie` (deliberately — the
|
|
item belongs in the VOD catalog) while its progress lives in episode
|
|
rows. The mappers give both it and a lazy Ministra `is_series` row
|
|
(already typed `series`) `watch_kind: '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 and Stalker 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.
|
|
Continue Watching cards carry no provider/content-kind subtitle: their
|
|
meta row is the S·E chip plus a "N min left" label (`remainingLabel`,
|
|
from `formatRemainingLabel`), and the row is not rendered when both are
|
|
absent. The hero subtitle is the source name alone, through
|
|
`playlistDisplayLabel`.
|
|
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. Both rails are
|
|
enriched by `DashboardLiveEpgPresenter`, the one component-provided
|
|
facade for live EPG: it owns the XMLTV lookup described under "Scoped
|
|
lookups" in `m3u-playlist-module.md`, forwards everything portal-shaped
|
|
to `DashboardPortalLiveEpgPresenter`, and `enrich()` returns the cards
|
|
with their "now on air" row filled in.
|
|
Xtream and Stalker cards have no XMLTV key of their own; their "now on
|
|
air" line comes from the portal, **lazily and per card**:
|
|
- `buildDashboardPortalLiveEpgEntry` (dashboard data-access) turns a
|
|
live `PortalActivityItem` into the `UnifiedCollectionItem` the
|
|
collection pages hand `StreamResolverService.loadEpgForItems`, keyed
|
|
by the collection uid — favourites and recent rows of one channel
|
|
share the answer. Radio rows and rows without a usable provider id
|
|
get no entry. Cards carry that key as `liveEpgSourceKey`.
|
|
- `lib-dashboard-rail` reports the cards inside its track viewport
|
|
(plus ~one card of `rootMargin`) through `visibleCardsChanged`, from
|
|
an `IntersectionObserver` rooted at the track; without the API every
|
|
card counts as visible. Cards that leave the list are reported gone
|
|
at once.
|
|
- `DashboardPortalLiveEpgPresenter` (component-provided) unions the
|
|
visible keys of both rails with the pinned hero key and calls
|
|
`DashboardPortalLiveEpgService.sync()` with exactly those entries —
|
|
on every change, on the 30 s tick, and on a display-offset change.
|
|
It is reached through `DashboardLiveEpgPresenter`, which derives the
|
|
portal rows itself from the enabled rails and pins the hero, so the
|
|
page component only forwards what a rail can see. The queue lives in
|
|
the root service, so leaving the dashboard hands the wanted set back
|
|
(`sync([])` on destroy); otherwise the queue would keep asking for
|
|
cards on a page that is gone.
|
|
- `DashboardPortalLiveEpgService` (root) owns the queue: at most two
|
|
requests in flight, 200 ms between starts (the numbers
|
|
`EpgQueueService` proved against real panels), one card per request,
|
|
each answer published the moment it lands in `programs`, so the page
|
|
never waits and a slow portal delays no other card. Only wanted keys
|
|
are dequeued, so a card scrolled past before its turn is never
|
|
requested. A programme lives 60 s; a programme that ended is asked
|
|
again, but not within 30 s of the last answer (a portal may keep
|
|
returning the stale row). An answer with **no** programme lives only
|
|
30 s, because the resolver reports a failed portal and a guide-less
|
|
channel identically (it files per-channel failures as `null`), so
|
|
there is no failure cooldown to keep and the short TTL is what lets
|
|
an outage recover on the next tick.
|
|
- Every answer is "at the provider clock" and against one XMLTV source
|
|
set. A request captures both the display offset and
|
|
`EpgSourceSettingsService.revision()` — the same fence
|
|
`EpgService.guard()` uses — and a completion whose either fact moved
|
|
is discarded and requeued instead of published. That requeue has to
|
|
happen in the completion: while the key is in flight the retire pass
|
|
cannot queue a replacement, and without it the pre-change answer
|
|
would be trusted for a full TTL (the repo's late-result
|
|
invalidation contract).
|
|
- Desktop only in practice: the shared collection resolver is gated on
|
|
the local XMLTV bridge (`supportsProgramLookup`) and answers nothing
|
|
without it, so `sync()` returns immediately in the PWA rather than
|
|
filing an empty answer for every card. Lifting that gate for portal
|
|
lookups would change the collection pages too and is deliberately
|
|
out of scope here.
|
|
- `DashboardLiveEpgPresenter.enrich()` prefers the portal answer, falls
|
|
back to the XMLTV title match when the portal said "nothing on air",
|
|
and marks a card
|
|
`nowPlayingState: 'pending'` only before its FIRST answer — the
|
|
channel layout then shows a shimmer placeholder in the programme
|
|
slot; a refresh keeps the previous answer on screen.
|
|
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 the source name as their subtitle
|
|
(`playlistDisplayLabel`) so users can tell where each item was added;
|
|
the content kind is not repeated on every card. 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 and Stalker 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.
|
|
|
|
## Source subscription expiry
|
|
|
|
Source cards show a passive subscription-expiry chip: amber within seven days,
|
|
error-toned once expired. Account details stay behind the Account info menu.
|
|
`DashboardSourceExpiryService` in `libs/workspace/dashboard/data-access` reads
|
|
Xtream expiry from cached `PortalStatusService.checkPortalStatusDetails()`
|
|
(`exp_date`). Stalker uses the persisted `stalkerAccountInfo` snapshot from the
|
|
playlist payload, not the metadata row; each source therefore needs one memoized
|
|
full-playlist read. The chip is not a separate account-refresh request.
|