From 2ecb5c28f47653e389fdb973550940a0658a41f2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 00:59:17 +0300 Subject: [PATCH] fix(dashboard): address PR review feedback --- apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- docs/architecture/workspace-dashboard.md | 31 ++++-- .../src/lib/playlist-recently-viewed.utils.ts | 18 +++- .../src/lib/portal-activity-item.interface.ts | 5 + .../multi-epg-container.component.spec.ts | 11 +++ .../multi-epg-container.component.ts | 10 ++ .../src/lib/dashboard-data.service.spec.ts | 22 ++++- .../src/lib/dashboard-data.service.ts | 87 +++++++++++----- .../lib/rails/dashboard-rail.component.html | 5 +- .../src/lib/rails/dashboard-rail.component.ts | 1 + .../workspace-dashboard-rails.component.html | 2 +- ...orkspace-dashboard-rails.component.spec.ts | 82 ++++++++++++++++ .../workspace-dashboard-rails.component.ts | 98 +++++++++++++------ 30 files changed, 340 insertions(+), 104 deletions(-) create mode 100644 libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b7e780b4f..4ecacc5a4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "ترتيب الفئات", "DASHBOARD": { "CONTINUE_WATCHING": "متابعة المشاهدة", - "LIVE_RECENT": "يُبث الآن على المفضلة لديك", - "LIVE_CONTINUE": "متابعة البث المباشر", + "LIVE_FAVORITES": "يُبث الآن على المفضلة لديك", + "LIVE_RECENT": "متابعة البث المباشر", "UNTITLED_SOURCE": "مصدر بدون عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "إزالة من السجل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6a151fdf1..facc7890c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "ترتيب التصنيفات", "DASHBOARD": { "CONTINUE_WATCHING": "كمل المشاهدة", - "LIVE_RECENT": "مباشر دابا على المفضلة ديالك", - "LIVE_CONTINUE": "كمل مع المباشر", + "LIVE_FAVORITES": "مباشر دابا على المفضلة ديالك", + "LIVE_RECENT": "كمل مع المباشر", "UNTITLED_SOURCE": "مصدر بلا عنوان", "GLOBAL_FAVORITES": "المفضلة العامة", "REMOVE_FROM_HISTORY": "حيد من السجل", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4a406a4ac..4654352c0 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Сартаваць катэгорыі", "DASHBOARD": { "CONTINUE_WATCHING": "Працягнуць прагляд", - "LIVE_RECENT": "Цяпер у эфіры на выбраных", - "LIVE_CONTINUE": "Працягнуць прагляд эфіру", + "LIVE_FAVORITES": "Цяпер у эфіры на выбраных", + "LIVE_RECENT": "Працягнуць прагляд эфіру", "UNTITLED_SOURCE": "Крыніца без назвы", "GLOBAL_FAVORITES": "Усе фаварыты", "REMOVE_FROM_HISTORY": "Выдаліць з гісторыі", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0feac6aeb..aa51b7a9a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Kategorien sortieren", "DASHBOARD": { "CONTINUE_WATCHING": "Weiter schauen", - "LIVE_RECENT": "Jetzt live auf Ihren Favoriten", - "LIVE_CONTINUE": "Mit Live-TV weiterschauen", + "LIVE_FAVORITES": "Jetzt live auf Ihren Favoriten", + "LIVE_RECENT": "Mit Live-TV weiterschauen", "UNTITLED_SOURCE": "Unbenannte Quelle", "GLOBAL_FAVORITES": "Globale Favoriten", "REMOVE_FROM_HISTORY": "Aus Verlauf entfernen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 51993f773..53ced9c68 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ταξινόμηση κατηγοριών", "DASHBOARD": { "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", - "LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας", - "LIVE_CONTINUE": "Συνέχιση με ζωντανή TV", + "LIVE_FAVORITES": "Ζωντανά τώρα στα αγαπημένα σας", + "LIVE_RECENT": "Συνέχιση με ζωντανή TV", "UNTITLED_SOURCE": "Πηγή χωρίς τίτλο", "GLOBAL_FAVORITES": "Βασικά Αγαπημένα", "REMOVE_FROM_HISTORY": "Αφαίρεση από το ιστορικό", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 8d4bc009b..efde90ae8 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Sort categories", "DASHBOARD": { "CONTINUE_WATCHING": "Continue Watching", - "LIVE_RECENT": "Live now on your favorites", - "LIVE_CONTINUE": "Continue with live TV", + "LIVE_FAVORITES": "Live now on your favorites", + "LIVE_RECENT": "Continue with live TV", "UNTITLED_SOURCE": "Untitled source", "GLOBAL_FAVORITES": "Global Favorites", "REMOVE_FROM_HISTORY": "Remove from history", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index e7dee60af..637be0594 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordenar categorías", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar viendo", - "LIVE_RECENT": "Ahora en vivo en tus favoritos", - "LIVE_CONTINUE": "Continuar con TV en vivo", + "LIVE_FAVORITES": "Ahora en vivo en tus favoritos", + "LIVE_RECENT": "Continuar con TV en vivo", "UNTITLED_SOURCE": "Fuente sin título", "GLOBAL_FAVORITES": "Favoritos globales", "REMOVE_FROM_HISTORY": "Quitar del historial", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index ce46ce76a..161c3c106 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Trier les catégories", "DASHBOARD": { "CONTINUE_WATCHING": "Reprendre la lecture", - "LIVE_RECENT": "En direct sur vos favoris", - "LIVE_CONTINUE": "Reprendre la TV en direct", + "LIVE_FAVORITES": "En direct sur vos favoris", + "LIVE_RECENT": "Reprendre la TV en direct", "UNTITLED_SOURCE": "Source sans titre", "GLOBAL_FAVORITES": "Favoris globaux", "REMOVE_FROM_HISTORY": "Retirer de l'historique", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 8d8e5f4f2..d527787ab 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordina categorie", "DASHBOARD": { "CONTINUE_WATCHING": "Continua a guardare", - "LIVE_RECENT": "Live ora sui tuoi preferiti", - "LIVE_CONTINUE": "Continua con la TV live", + "LIVE_FAVORITES": "Live ora sui tuoi preferiti", + "LIVE_RECENT": "Continua con la TV live", "UNTITLED_SOURCE": "Sorgente senza titolo", "GLOBAL_FAVORITES": "Preferiti globali", "REMOVE_FROM_HISTORY": "Rimuovi dalla cronologia", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 5ebf361c4..44817f13f 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "カテゴリを並び替え", "DASHBOARD": { "CONTINUE_WATCHING": "視聴を再開", - "LIVE_RECENT": "お気に入りで現在放送中", - "LIVE_CONTINUE": "ライブTVの視聴を再開", + "LIVE_FAVORITES": "お気に入りで現在放送中", + "LIVE_RECENT": "ライブTVの視聴を再開", "UNTITLED_SOURCE": "無題のソース", "GLOBAL_FAVORITES": "グローバルお気に入り", "REMOVE_FROM_HISTORY": "履歴から削除", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ce94623ca..3e5849dc4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "카테고리 정렬", "DASHBOARD": { "CONTINUE_WATCHING": "이어서 시청", - "LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다", - "LIVE_CONTINUE": "라이브 TV 이어서 시청", + "LIVE_FAVORITES": "즐겨찾기에서 지금 라이브 중인 채널입니다", + "LIVE_RECENT": "라이브 TV 이어서 시청", "UNTITLED_SOURCE": "제목 없는 소스", "GLOBAL_FAVORITES": "전체 즐겨찾기", "REMOVE_FROM_HISTORY": "기록에서 제거", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 5af92d3b4..f11ced48f 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Categorieën sorteren", "DASHBOARD": { "CONTINUE_WATCHING": "Verder kijken", - "LIVE_RECENT": "Nu live op je favorieten", - "LIVE_CONTINUE": "Verder kijken met live tv", + "LIVE_FAVORITES": "Nu live op je favorieten", + "LIVE_RECENT": "Verder kijken met live tv", "UNTITLED_SOURCE": "Naamloze bron", "GLOBAL_FAVORITES": "Algemene favorieten", "REMOVE_FROM_HISTORY": "Verwijderen uit geschiedenis", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 4731dfc4f..82a01f75d 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Sortuj kategorie", "DASHBOARD": { "CONTINUE_WATCHING": "Kontynuuj oglądanie", - "LIVE_RECENT": "Na żywo w Twoich ulubionych", - "LIVE_CONTINUE": "Kontynuuj oglądanie na żywo", + "LIVE_FAVORITES": "Na żywo w Twoich ulubionych", + "LIVE_RECENT": "Kontynuuj oglądanie na żywo", "UNTITLED_SOURCE": "Źródło bez tytułu", "GLOBAL_FAVORITES": "Globalne ulubione", "REMOVE_FROM_HISTORY": "Usuń z historii", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 15039fbbc..4715f2cc8 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Ordenar categorias", "DASHBOARD": { "CONTINUE_WATCHING": "Continuar assistindo", - "LIVE_RECENT": "Ao vivo agora nos seus favoritos", - "LIVE_CONTINUE": "Continuar com TV ao vivo", + "LIVE_FAVORITES": "Ao vivo agora nos seus favoritos", + "LIVE_RECENT": "Continuar com TV ao vivo", "UNTITLED_SOURCE": "Fonte sem título", "GLOBAL_FAVORITES": "Favoritos globais", "REMOVE_FROM_HISTORY": "Remover do histórico", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index bfd900760..d51948a34 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Сортировать категории", "DASHBOARD": { "CONTINUE_WATCHING": "Продолжить просмотр", - "LIVE_RECENT": "Сейчас в эфире на избранных каналах", - "LIVE_CONTINUE": "Продолжить просмотр прямого эфира", + "LIVE_FAVORITES": "Сейчас в эфире на избранных каналах", + "LIVE_RECENT": "Продолжить просмотр прямого эфира", "UNTITLED_SOURCE": "Источник без названия", "GLOBAL_FAVORITES": "Глобальные избранные", "REMOVE_FROM_HISTORY": "Удалить из истории", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9113ae998..aa1a0354a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "Kategorileri sırala", "DASHBOARD": { "CONTINUE_WATCHING": "İzlemeye Devam Et", - "LIVE_RECENT": "Favorilerinizde şu an canlı", - "LIVE_CONTINUE": "Canlı TV ile devam et", + "LIVE_FAVORITES": "Favorilerinizde şu an canlı", + "LIVE_RECENT": "Canlı TV ile devam et", "UNTITLED_SOURCE": "Adsız kaynak", "GLOBAL_FAVORITES": "Tüm Favoriler", "REMOVE_FROM_HISTORY": "Geçmişten kaldır", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 304df9989..2d2f6c20b 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "排序分类", "DASHBOARD": { "CONTINUE_WATCHING": "继续观看", - "LIVE_RECENT": "收藏频道正在播放", - "LIVE_CONTINUE": "继续观看直播", + "LIVE_FAVORITES": "收藏频道正在播放", + "LIVE_RECENT": "继续观看直播", "UNTITLED_SOURCE": "未命名源", "GLOBAL_FAVORITES": "全局收藏", "REMOVE_FROM_HISTORY": "从历史记录中移除", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index f36ae3913..ae4e893a1 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -851,8 +851,8 @@ "CATEGORY_SORT_ARIA": "排序分類", "DASHBOARD": { "CONTINUE_WATCHING": "繼續觀看", - "LIVE_RECENT": "我的最愛正在直播", - "LIVE_CONTINUE": "繼續觀看直播電視", + "LIVE_FAVORITES": "我的最愛正在直播", + "LIVE_RECENT": "繼續觀看直播電視", "UNTITLED_SOURCE": "未命名的來源", "GLOBAL_FAVORITES": "全域我的最愛", "REMOVE_FROM_HISTORY": "從歷史記錄中移除", diff --git a/docs/architecture/workspace-dashboard.md b/docs/architecture/workspace-dashboard.md index 6a5a471f7..15b8ad5f1 100644 --- a/docs/architecture/workspace-dashboard.md +++ b/docs/architecture/workspace-dashboard.md @@ -35,11 +35,11 @@ Core implementation: ┌─────────────────────────────────────────────────────────────────────┐ │ Hero — Continue Watching (most recent item) │ ├─────────────────────────────────────────────────────────────────────┤ -│ Recently Watched · See all → │ +│ Continue Watching · See all → │ │ [poster][poster][poster][poster] →→ │ ├─────────────────────────────────────────────────────────────────────┤ -│ Global Favorites · See all → │ -│ [poster][poster][poster] →→ │ +│ Live now on your favorites / Continue with live TV · See all → │ +│ [channel][channel][channel][channel] →→ │ ├─────────────────────────────────────────────────────────────────────┤ │ Recently Used Sources · See all → │ │ [tile][tile][tile][tile] →→ │ @@ -63,6 +63,9 @@ Render rules: 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. The mixed global favorites rail is not rendered on the dashboard. Live + favorites are promoted into the live rail, while mixed favorites stay on + `/workspace/global-favorites`. ## Rail Contract @@ -83,10 +86,19 @@ Render rules: ## Data Flow 1. `WorkspaceDashboardRailsComponent` injects `DashboardDataService`. -2. It derives five signals via `computed()`: +2. It derives the dashboard surface via `computed()`: 1. `hero` — first item of `globalRecentItems()`. - 2. `recentlyWatchedCards` — maps `globalRecentItems()` to rail cards. - 3. `xtreamRecentlyAddedCards` — maps `xtreamRecentlyAddedItems()` to rail + 2. `continueWatchingCards` — maps `globalRecentVodItems()` to movie/series + cover cards. Xtream playback positions are bulk-loaded per playlist so + hero and cards can show progress, remaining time, and series season/ + episode badges. Series lookup uses keyed maps for both direct episode ids + and series ids; card renders must not scan the full playback-position map. + 3. `liveOnFavoritesCardsEnriched` — maps favorited live channels first, + falling back to recently watched live channels when no live favorites + exist. 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 @@ -95,7 +107,6 @@ Render rules: 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. - 4. `favoriteCards` — maps `globalFavoriteItems()` to rail cards. 5. `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 @@ -114,6 +125,9 @@ Render rules: 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. ## Empty State @@ -141,6 +155,9 @@ The welcome state is rendered via the existing route without switching the active playlist in the header switcher. 6. `Recently Used Sources` reflects recent source usage across all provider types, not just recent imports. +7. The live rail title key must match the rendered source: favorites use + `WORKSPACE.DASHBOARD.LIVE_FAVORITES`; recently watched fallback uses + `WORKSPACE.DASHBOARD.LIVE_RECENT`. ## Adding Or Changing Rails diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts index ab037c7c0..c20219417 100644 --- a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -17,6 +17,10 @@ interface PlaylistRecentLabels { m3u: string; } +function firstNonEmpty(...values: Array): string | undefined { + return values.map((value) => value?.trim()).find(Boolean); +} + function getPlaylistRecentItems( playlist: PlaylistMeta ): PlaylistRecentlyViewedItem[] { @@ -76,9 +80,11 @@ function mapM3uPlaylistRecentItems( acc.push({ id: rawItem.id || channelUrl, title: - rawItem.title?.trim() || - rawItem.tvg_name?.trim() || - rawItem.channel_id || + firstNonEmpty( + rawItem.title, + rawItem.tvg_name, + rawItem.channel_id + ) || channelUrl, type: 'live', playlist_id: playlist._id, @@ -87,6 +93,12 @@ function mapM3uPlaylistRecentItems( category_id: rawItem.category_id || 'live', xtream_id: channelUrl, poster_url: rawItem.poster_url || undefined, + epg_lookup_key: firstNonEmpty( + rawItem.tvg_id, + rawItem.tvg_name, + rawItem.title, + rawItem.channel_id + ), source: 'm3u', }); diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index 548fdad18..b3119dc6b 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -16,6 +16,11 @@ export interface PortalActivityItem { category_id: string | number; xtream_id: string | number; poster_url?: string; + /** + * Preferred XMLTV lookup key for live EPG enrichment. For M3U channels this + * follows the app-wide tvg.id -> tvg.name -> display name fallback chain. + */ + epg_lookup_key?: string; /** * Wide backdrop URL (typically 16:9), persisted when the user first views * the detail page. Absent for stalker and for items that have never had diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts new file mode 100644 index 000000000..addd6cb0c --- /dev/null +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.spec.ts @@ -0,0 +1,11 @@ +import { isSelectedEpgDayToday } from './multi-epg-container.component'; + +describe('isSelectedEpgDayToday', () => { + it('returns true only when the selected EPG day is the actual current day', () => { + const now = new Date('2026-05-21T20:00:00.000Z'); + + expect(isSelectedEpgDayToday('20260521', now)).toBe(true); + expect(isSelectedEpgDayToday('20260520', now)).toBe(false); + expect(isSelectedEpgDayToday('20260522', now)).toBe(false); + }); +}); diff --git a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts index 5ef6564d0..3923f4822 100644 --- a/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts +++ b/libs/ui/epg/src/lib/multi-epg/multi-epg-container.component.ts @@ -54,6 +54,13 @@ interface ProgramSearchResult extends EpgProgram { display_name?: string | null; } +export function isSelectedEpgDayToday( + selectedDay: string, + now: Date = new Date() +): boolean { + return selectedDay === format(now, 'yyyyMMdd'); +} + @Component({ imports: [ DatePipe, @@ -250,6 +257,9 @@ export class MultiEpgContainerComponent */ isProgramAiringNow(program: EnrichedProgram): boolean { const nowX = this.currentTimeLine(); + if (!isSelectedEpgDayToday(this.today())) { + return false; + } return ( nowX >= program.startPosition && nowX <= program.startPosition + program.width diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 9f304671b..09f2453dc 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -242,6 +242,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', poster_url: 'https://example.com/logo-1.png', + epg_lookup_key: 'tvg-1', }), expect.objectContaining({ id: 'https://example.com/stream-2.m3u8', @@ -250,6 +251,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', poster_url: 'https://example.com/logo-2.png', + epg_lookup_key: 'tvg-2', }), ]) ); @@ -436,6 +438,7 @@ describe('DashboardDataService', () => { playlist_id: 'm3u-1', source: 'm3u', xtream_id: 'https://example.com/stream-1.m3u8', + epg_lookup_key: 'tvg-1', }), ]) ); @@ -696,10 +699,27 @@ describe('DashboardDataService', () => { const series = service .globalRecentVodItems() .find((item) => item.title === 'Shadow Bay'); - const position = service.getPlaybackPositionForItem(series!); + const positionsMap = service.playbackPositions$(); + const failOnLinearScan = jest.fn(() => { + throw new Error('series lookup should not scan all positions'); + }); + ( + positionsMap as unknown as { + values: () => IterableIterator; + } + ).values = + failOnLinearScan as unknown as () => IterableIterator; + + expect(series).toBeDefined(); + if (!series) { + throw new Error('expected Shadow Bay recent item'); + } + + const position = service.getPlaybackPositionForItem(series); expect(position?.contentType).toBe('episode'); expect(position?.seasonNumber).toBe(3); expect(position?.episodeNumber).toBe(7); + expect(failOnLinearScan).not.toHaveBeenCalled(); }); it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 62cac219e..1cf7d2962 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -65,6 +65,28 @@ function playbackPositionMapKey( return `${playlistId}::${contentXtreamId}::${contentType}`; } +function seriesPlaybackPositionMapKey( + playlistId: string, + seriesXtreamId: number +): string { + return `${playlistId}::${seriesXtreamId}`; +} + +function newestPlaybackPosition( + current: PlaybackPositionData | null | undefined, + candidate: PlaybackPositionData | null | undefined +): PlaybackPositionData | null { + if (!current) { + return candidate ?? null; + } + if (!candidate) { + return current; + } + return (candidate.updatedAt ?? '') > (current.updatedAt ?? '') + ? candidate + : current; +} + /** @deprecated Use {@link PortalRecentItem} from `@iptvnator/shared/interfaces` instead. */ export type GlobalRecentItem = PortalRecentItem; /** @deprecated Use {@link PortalFavoriteItem} from `@iptvnator/shared/interfaces` instead. */ @@ -239,6 +261,9 @@ export class DashboardDataService { private readonly playbackPositionsMap = signal< Map >(new Map()); + private readonly playbackPositionsBySeriesMap = signal< + Map + >(new Map()); readonly playbackPositions$ = this.playbackPositionsMap.asReadonly(); @@ -267,29 +292,17 @@ export class DashboardDataService { // Series recent_items rows carry either the series id (the series // landing page writes the series itself) or, for direct-play flows, - // the episode id. Match both shapes and prefer the most recently - // updated episode position so the card can show resume progress and - // a "S2 · E5" badge regardless of how the row was originally saved. - let best: PlaybackPositionData | null = null; - for (const position of this.playbackPositionsMap().values()) { - if (position.contentType !== 'episode') continue; - if ( - position.playlistId && - position.playlistId !== item.playlist_id - ) { - continue; - } - const matchesEpisode = position.contentXtreamId === xtreamId; - const matchesSeries = position.seriesXtreamId === xtreamId; - if (!matchesEpisode && !matchesSeries) continue; - if ( - !best || - (position.updatedAt ?? '') > (best.updatedAt ?? '') - ) { - best = position; - } - } - return best; + // the episode id. Match both shapes through keyed maps so card renders + // do not scan every saved playback position. + const episodePosition = + this.playbackPositionsMap().get( + playbackPositionMapKey(item.playlist_id, xtreamId, 'episode') + ) ?? null; + const seriesPosition = + this.playbackPositionsBySeriesMap().get( + seriesPlaybackPositionMapKey(item.playlist_id, xtreamId) + ) ?? null; + return newestPlaybackPosition(episodePosition, seriesPosition); } /** @@ -307,10 +320,12 @@ export class DashboardDataService { } if (playlistIds.size === 0) { this.playbackPositionsMap.set(new Map()); + this.playbackPositionsBySeriesMap.set(new Map()); return; } const next = new Map(); + const nextBySeries = new Map(); for (const playlistId of playlistIds) { try { const positions = @@ -326,6 +341,22 @@ export class DashboardDataService { ), position ); + if ( + position.contentType === 'episode' && + Number.isFinite(position.seriesXtreamId) + ) { + const seriesKey = seriesPlaybackPositionMapKey( + playlistId, + position.seriesXtreamId as number + ); + nextBySeries.set( + seriesKey, + newestPlaybackPosition( + nextBySeries.get(seriesKey), + position + ) as PlaybackPositionData + ); + } } } catch (err) { console.warn( @@ -336,7 +367,10 @@ export class DashboardDataService { } } - this.ngZone.run(() => this.playbackPositionsMap.set(next)); + this.ngZone.run(() => { + this.playbackPositionsMap.set(next); + this.playbackPositionsBySeriesMap.set(nextBySeries); + }); } readonly stalkerGlobalFavorites = computed(() => { @@ -1041,6 +1075,11 @@ export class DashboardDataService { category_id: 'live', xtream_id: matchedFavoriteId, poster_url: channel.tvg?.logo || undefined, + epg_lookup_key: + channel.tvg?.id?.trim() || + channel.tvg?.name?.trim() || + channel.name?.trim() || + undefined, source: 'm3u', }); return acc; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html index 6ca18fa11..8b91b148a 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.html @@ -110,7 +110,8 @@ } - @if (card.watchProgress != null) { + @if (card.watchProgress !== null && card.watchProgress !== undefined) {