diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d54afbc30..c8affaae4 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "متابعة المشاهدة", "LIVE_RECENT": "يُبث الآن على المفضلة لديك", + "LIVE_CONTINUE": "متابعة البث المباشر", "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 115179669..2f77bec25 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "كمل المشاهدة", "LIVE_RECENT": "مباشر دابا على المفضلة ديالك", + "LIVE_CONTINUE": "كمل مع المباشر", "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 1aea919c2..093ecfdcc 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Працягнуць прагляд", "LIVE_RECENT": "Цяпер у эфіры на выбраных", + "LIVE_CONTINUE": "Працягнуць прагляд эфіру", "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 f57c06dc9..35e04c7e9 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Weiter schauen", "LIVE_RECENT": "Jetzt live auf Ihren Favoriten", + "LIVE_CONTINUE": "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 3cc46be6b..1c37a158d 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Συνέχιση παρακολούθησης", "LIVE_RECENT": "Ζωντανά τώρα στα αγαπημένα σας", + "LIVE_CONTINUE": "Συνέχιση με ζωντανή 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 af8b29725..d3a739b52 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Continue Watching", "LIVE_RECENT": "Live now on your favorites", + "LIVE_CONTINUE": "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 0b4e30d8b..1d98ff72d 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Continuar viendo", "LIVE_RECENT": "Ahora en vivo en tus favoritos", + "LIVE_CONTINUE": "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 594ec08d6..4164c6b39 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Reprendre la lecture", "LIVE_RECENT": "En direct sur vos favoris", + "LIVE_CONTINUE": "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 4c4b453cc..88524018b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Continua a guardare", "LIVE_RECENT": "Live ora sui tuoi preferiti", + "LIVE_CONTINUE": "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 22376cb2c..46e9283fc 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "視聴を再開", "LIVE_RECENT": "お気に入りで現在放送中", + "LIVE_CONTINUE": "ライブ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 60f45614c..7f0858fe4 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "이어서 시청", "LIVE_RECENT": "즐겨찾기에서 지금 라이브 중인 채널입니다", + "LIVE_CONTINUE": "라이브 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 1459ff7c6..2a086f612 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Verder kijken", "LIVE_RECENT": "Nu live op je favorieten", + "LIVE_CONTINUE": "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 255f40a58..218b610ae 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Kontynuuj oglądanie", "LIVE_RECENT": "Na żywo w Twoich ulubionych", + "LIVE_CONTINUE": "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 0a300c541..a8066d7d9 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Continuar assistindo", "LIVE_RECENT": "Ao vivo agora nos seus favoritos", + "LIVE_CONTINUE": "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 fea00cf11..301da391e 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "Продолжить просмотр", "LIVE_RECENT": "Сейчас в эфире на избранных каналах", + "LIVE_CONTINUE": "Продолжить просмотр прямого эфира", "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 bfd011a33..db31ba810 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "İzlemeye Devam Et", "LIVE_RECENT": "Favorilerinizde şu an canlı", + "LIVE_CONTINUE": "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 51cc0a575..9d9f612fc 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "继续观看", "LIVE_RECENT": "收藏频道正在播放", + "LIVE_CONTINUE": "继续观看直播", "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 ca421de4b..971cb0b2e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -824,6 +824,7 @@ "DASHBOARD": { "CONTINUE_WATCHING": "繼續觀看", "LIVE_RECENT": "我的最愛正在直播", + "LIVE_CONTINUE": "繼續觀看直播電視", "UNTITLED_SOURCE": "未命名的來源", "GLOBAL_FAVORITES": "全域我的最愛", "REMOVE_FROM_HISTORY": "從歷史記錄中移除", 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 19f31d108..24657818d 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 @@ -621,6 +621,69 @@ describe('DashboardDataService', () => { expect(seriesPos?.positionSeconds).toBe(720); }); + it('exposes a live-only slice of global favorites so the dashboard can promote favorited channels into the Live rail', async () => { + // Seed two favorites: a movie and a live channel. + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([ + { + id: 500, + category_id: 18, + title: 'Favorite Movie', + rating: '7.0', + added_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/fav-movie.png', + xtream_id: 5500, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + { + id: 501, + category_id: 19, + title: 'Favorite Channel HD', + rating: '0', + added_at: '2026-04-22T10:00:00.000Z', + poster_url: 'https://example.com/fav-channel.png', + xtream_id: 5501, + type: 'live', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + }, + ]); + playlistsSignal.set([ + ...playlistsSignal(), + { + _id: 'xtream-1', + title: 'Xtream Playlist', + count: 2, + importDate: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://example.com', + }, + ]); + + await service.reloadGlobalFavorites(); + + const liveOnly = service.globalFavoriteLiveItems(); + // Includes the Xtream "Favorite Channel HD" plus the two M3U live + // favorites already seeded by the default fixture. + expect(liveOnly.map((it) => it.title)).toEqual( + expect.arrayContaining([ + 'Favorite Channel HD', + 'Channel One', + 'Channel Two', + ]) + ); + + // Crucially, the movie favorite is excluded — the rail source must + // never leak VOD posters into the channel layout. + expect(liveOnly.map((it) => it.title)).not.toContain( + 'Favorite Movie' + ); + expect( + service.globalFavoriteLiveItems().every((it) => it.type === 'live') + ).toBe(true); + }); + it('returns null playback position for live channels (no position tracking in schema)', async () => { // The default M3U playlist contains a live channel. Without // reloading playback positions the lookup still gracefully returns 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 a0dea87a5..7ba38114d 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 @@ -223,6 +223,14 @@ export class DashboardDataService { this.globalRecentItems().filter((item) => item.type === 'live') ); + // Favorited live channels — the dashboard's "Live now" rail prefers this + // source so the EPG enrichment lands on the channels users actually care + // about. Falls back to globalRecentLiveItems when this is empty so a + // fresh-install user still sees something useful. + readonly globalFavoriteLiveItems = computed(() => + this.globalFavoriteItems().filter((item) => item.type === 'live') + ); + // Playback positions, keyed by `${playlist_id}::${contentXtreamId}::${type}`. // Loaded lazily on dashboard mount (and refreshed when recent items // change) so the hero + Continue Watching cards can show "X min left" diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html index 515e3171b..79ffbfbd4 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.html @@ -131,13 +131,19 @@ /> } - + @if (liveOnFavoritesCardsEnriched().length > 0) { @@ -162,29 +168,11 @@ } - - @if (favoriteCards().length > 0) { - - } @else if (data.globalFavoritesLoading()) { - - } + @if (sourceCards().length > 0) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 67baa9f34..ea2419443 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -403,11 +403,43 @@ export class WorkspaceDashboardRailsComponent { .map((item) => this.toRecentCard(item)) ); - readonly liveOnFavoritesCards = computed(() => - this.data + // Live rail source — favorited live channels take precedence so the EPG + // enrichment lands on the channels the user actually cares about. When + // no favorites exist (fresh install, or a user who hasn't starred any + // channel yet), fall back to recently-watched live so the rail still + // has something to show. `liveRailSource` drives the title swap. + readonly liveRailSource = computed<'favorites' | 'recent'>(() => + this.data.globalFavoriteLiveItems().length > 0 + ? 'favorites' + : 'recent' + ); + + readonly liveOnFavoritesCards = computed(() => { + if (this.liveRailSource() === 'favorites') { + return this.data + .globalFavoriteLiveItems() + .slice(0, RAIL_ITEM_LIMIT) + .map((item) => this.toFavoriteCard(item)); + } + return this.data .globalRecentLiveItems() .slice(0, RAIL_ITEM_LIMIT) - .map((item) => this.toRecentCard(item)) + .map((item) => this.toRecentCard(item)); + }); + + // Title key flips with the source — "Live now on your favorites" when + // we're rendering favorites, "Continue with live TV" when we're falling + // back to recently-watched. Always honest about what the user is seeing. + readonly liveRailTitleKey = computed(() => + this.liveRailSource() === 'favorites' + ? 'WORKSPACE.DASHBOARD.LIVE_RECENT' + : 'WORKSPACE.DASHBOARD.LIVE_CONTINUE' + ); + + readonly liveRailTotalCount = computed(() => + this.liveRailSource() === 'favorites' + ? this.data.globalFavoriteLiveItems().length + : this.data.globalRecentLiveItems().length ); // Best-effort EPG lookup keyed by the channel's display name. This works