fix(xtream): protect favorite snapshots and collection navigation

This commit is contained in:
4gray committed 2026-10-13 23:57:30 +02:00
1 parent 8ccfcb68cc
commit e5a5d0581d
16 files changed
+427 -50

No files matched your search

@@ -55,16 +55,6 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
'movie'
);
if (!row) throw new Error('Expected imported content');
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
const restored = await window.electron.dbRestoreXtreamFavorites(
playlistId,
[
@@ -82,6 +72,28 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
);
if (!restored.success)
throw new Error('Could not seed the older favorite');
const completed = await window.electron.dbSetAppState(
`xtream-import-status:${playlistId}:movie`,
'completed'
);
if (!completed.success)
throw new Error('Could not complete the catalog');
const [saved] =
await window.electron.dbGetFavoritesForBackup(playlistId);
if (saved?.title !== oldTitle)
throw new Error(
'Unconfirmed catalog identity replaced the saved title'
);
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
},
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
);
@@ -5,7 +5,10 @@ import {
ContentMetadataPatch,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
import { favoriteMatchesContentId } from './favorite-catalog';
import {
favoriteMatchesContentId,
favoriteSnapshotMatchesContent,
} from './favorite-catalog';
import type { AppDatabase } from '../database.types';
/**
@@ -118,7 +121,12 @@ export async function persistContentMetadataIfMissing(
])
)
)
.where(favoriteMatchesContentId(contentId));
.where(
and(
favoriteMatchesContentId(contentId),
sql`EXISTS (SELECT 1 FROM content WHERE id = ${contentId} AND ${favoriteSnapshotMatchesContent()})`
)
);
}
export async function setContentMetadataIfMissing(
@@ -17,6 +17,12 @@ export function resolvedFavoriteContentId() {
ORDER BY candidate.id LIMIT 1)`;
}
/** Missing candidate metadata cannot authorize replacing an already known snapshot. */
export function favoriteSnapshotMatchesContent() {
return sql`(${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = ${schema.content.tmdbId})
AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`;
}
export function favoriteCatalogStatus() {
return sql<'present' | 'missing' | 'unknown'>`CASE
WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown'
@@ -92,6 +98,7 @@ export async function reconcileCompletedFavoriteCatalog(
})
.where(
and(
sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`,
eq(schema.favorites.playlistId, match[1]),
eq(
schema.favorites.type,
@@ -4,6 +4,7 @@ import type { AppDatabase } from '../database.types';
import { unlockedCategoryCondition } from '../parental-lock-state';
import {
favoriteCatalogStatus,
favoriteSnapshotMatchesContent,
favoriteImportStateKey,
resolvedFavoriteContentId,
} from './favorite-catalog';
@@ -20,24 +21,24 @@ export function selectFavoriteRows(
favorite_id: schema.favorites.id,
id: schema.content.id,
category_id: schema.content.categoryId,
title: sql<string>`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`,
title: sql<string>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title}) ELSE ${schema.favorites.title} END`,
rating: schema.content.rating,
added: schema.content.added,
poster_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl}) ELSE ${schema.favorites.posterUrl} END`,
backdrop_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl}) ELSE ${schema.favorites.backdropUrl} END`,
tmdb_id: sql<
number | null
>`COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId}) ELSE ${schema.favorites.tmdbId} END`,
release_year: sql<
number | null
>`COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear}) ELSE ${schema.favorites.releaseYear} END`,
original_title: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle}) ELSE ${schema.favorites.originalTitle} END`,
xtream_id: schema.favorites.xtreamId,
type: schema.favorites.type,
tv_archive: schema.content.tvArchive,
@@ -11,6 +11,7 @@ describe('durable Xtream favorites against initialized SQLite', () => {
'restore',
'identity',
'collision',
'snapshot',
'scope',
'lock',
'receipt',
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { verifyFavoriteSnapshot } from './favorites-snapshot';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import { eq, sql } from 'drizzle-orm';
import { closeDatabase, initDatabase } from '@iptvnator/shared/database';
@@ -269,6 +270,8 @@ async function main() {
});
await status('completed');
assert.equal((await favorites())[0]?.id, 4);
} else if (scenario === 'snapshot') {
await verifyFavoriteSnapshot(db);
} else if (scenario === 'scope') {
await catalog(2, 7, 'Other', 'movie', 'other');
await addFavorite(db, 2, 'other');
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../../database.types';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import { persistContentMetadataIfMissing } from '../content-metadata.operations';
import {
addFavorite,
removeFavorite,
getFavorites,
getFavoritesForBackup,
} from '../favorites.operations';
import { deleteXtreamContent } from '../xtream.operations';
import { setAppState } from '../playlist.operations';
export async function verifyFavoriteSnapshot(db: AppDatabase) {
const catalog = createFavoriteTestCatalog(db);
await persistContentMetadataIfMissing(db, 1, {
tmdbId: 10,
releaseYear: 2000,
originalTitle: 'Original',
});
const [original] = await db.select().from(schema.favorites);
const snapshot = (row: typeof original) => ({
title: row.title,
poster: row.posterUrl,
backdrop: row.backdropUrl,
original: row.originalTitle,
tmdb: row.tmdbId,
year: row.releaseYear,
});
await deleteXtreamContent(db, 'p');
await catalog(2, 7, 'Replacement', 'movie', 'p', {
posterUrl: 'https://art.invalid/replacement.jpg',
backdropUrl: 'https://art.invalid/replacement-wide.jpg',
originalTitle: 'Replacement original',
});
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
snapshot(original),
'unknown catalog identity must not overwrite the durable snapshot'
);
for (const read of [getFavorites, getFavoritesForBackup]) {
const [row] = await read(db, 'p');
assert.equal(row.title, original.title);
assert.equal(row.poster_url, original.posterUrl);
assert.equal(row.backdrop_url, original.backdropUrl);
assert.equal(row.original_title, original.originalTitle);
}
await persistContentMetadataIfMissing(db, 2, {
tmdbId: 20,
releaseYear: 2020,
});
assert.equal((await getFavorites(db, 'p'))[0].catalog_status, 'missing');
assert.equal((await getFavorites(db, 'p'))[0].title, original.title);
await deleteXtreamContent(db, 'p');
await catalog(3, 7, 'ABC renamed', 'movie', 'p', { tmdbId: 10 });
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
snapshot(original),
'partial identity is insufficient to replace the saved fields'
);
await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 });
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.equal(
(await db.select().from(schema.favorites))[0].title,
'ABC renamed',
'confirmed compatible identity may refresh the snapshot'
);
// A replacement's year/artwork must not fill gaps before its TMDB identity is known.
await removeFavorite(db, 3, 'p');
await deleteXtreamContent(db, 'p');
await catalog(4, 7, 'Known TMDB only', 'movie', 'p', {
tmdbId: 10,
backdropUrl: null,
});
await addFavorite(db, 4, 'p');
await deleteXtreamContent(db, 'p');
await catalog(5, 7, 'Year without identity', 'movie', 'p', {
releaseYear: 2020,
backdropUrl: null,
});
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
await persistContentMetadataIfMissing(db, 5, {
backdropUrl: 'https://art.invalid/unverified.jpg',
});
const [partial] = await getFavoritesForBackup(db, 'p');
assert.equal(partial.release_year, null);
assert.equal(partial.backdrop_url, null);
assert.equal(
(await db.select().from(schema.favorites))[0].backdropUrl,
null
);
}
+3
View File
@@ -279,6 +279,9 @@ Implemented behavior:
- Channel up/down and number select navigate `visibleChannels()` — the
search-filtered, mode-conditionally sorted list derived by the same
`deriveVisibleFavoriteChannels` helper the rendered sidebar uses
- Up/down skips favorites marked missing or unknown. Number select and status
retain the full visible list's numbering; selecting an unavailable row by
number does nothing.
- Remote actions play via the same path as an explicit double-click
(`activateItem(item, false, true)`), so external MPV/VLC starts
immediately
@@ -50,8 +50,10 @@ Favorite rows retain a durable `favorite_id`, saved title/artwork, added date
and order. Their `id`/`category_id` may be null; consumers must not invent a
catalog ID. Remove/reorder can use the stable favorite ID while detail toggles
resolve any category row through the same typed provider identity. Completed
imports reconnect valid identities and refresh display snapshots. Metadata
learned in detail views is copied into the saved snapshot before catalog loss.
imports reconnect valid identities. Display snapshots are replaced only after
the returning row confirms every saved TMDB ID/year; incomplete identity keeps
the saved title/artwork in collections and backups. Metadata learned in detail
views is copied into the saved snapshot before catalog loss.
Conflicting known TMDB IDs or release years prevent automatic reconnection;
changed provider IDs never reconnect by title.
Adding a conflicting replacement returns an explicit failure and retains the
@@ -72,8 +74,10 @@ stored but are withheld while locks apply.
Legacy pending favorites are converted before destructive refresh, import
requests and ordinary collection reads, even if portal-status preflight is
offline. Backup merges convert any older pending favorites before parking the
incoming archive. Favorite inserts and a replay receipt commit together;
offline. Ordinary collection reads isolate failed conversions per playlist and
still read healthy database favorites, retaining pending state for retry. Backup
reads remain strict and fail if conversion fails. Backup merges convert any
older pending favorites before parking the incoming archive. Favorite inserts and a replay receipt commit together;
retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a
fresh receipt ID, which stays internal and is never exported. Identity-only old
backups use a labelled saved-item fallback until catalog metadata returns.
@@ -7,6 +7,7 @@ import {
getChannelItemByNumber,
} from '@iptvnator/portal/shared/util';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
/**
@@ -65,7 +66,9 @@ export function setupUnifiedLiveTabRemoteControl(
}
const nextChannel = getAdjacentChannelItem(
[...host.visibleChannels()],
host
.visibleChannels()
.filter((channel) => !isCatalogUnavailable(channel)),
activeUid,
data.direction,
(channel) => channel.uid
@@ -84,7 +87,7 @@ export function setupUnifiedLiveTabRemoteControl(
[...host.visibleChannels()],
command.number
);
if (channel) {
if (channel && !isCatalogUnavailable(channel)) {
host.playChannel(channel);
}
});
@@ -24,11 +24,9 @@ describe('UnifiedLiveTabComponent remote control', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
let component: UnifiedLiveTabComponent;
let channelChangeCallback:
| ((data: { direction: 'up' | 'down' }) => void)
| undefined;
((data: { direction: 'up' | 'down' }) => void) | undefined;
let remoteCommandCallback:
| ((command: { type: string; number?: number }) => void)
| undefined;
((command: { type: string; number?: number }) => void) | undefined;
let unsubscribeChannelChange: jest.Mock;
let unsubscribeCommand: jest.Mock;
let updateRemoteControlStatus: jest.Mock;
@@ -209,6 +207,47 @@ describe('UnifiedLiveTabComponent remote control', () => {
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledTimes(1);
});
it.each(['missing', 'unknown'] as const)(
'skips %s favorites in both directions while retaining visible numbering',
async (catalogStatus) => {
await configure();
await setItems([
buildItem('xtream::pl-1::1', 'Alpha'),
{ ...buildItem('xtream::pl-1::2', 'Bravo'), catalogStatus },
buildItem('xtream::pl-1::3', 'Charlie'),
]);
await activate('xtream::pl-1::1');
for (const [direction, expectedUid, expectedNumber] of [
['down', 'xtream::pl-1::3', 3],
['down', 'xtream::pl-1::1', 1],
['up', 'xtream::pl-1::3', 3],
['up', 'xtream::pl-1::1', 1],
] as const) {
channelChangeCallback?.({ direction });
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe(expectedUid);
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ channelNumber: expectedNumber })
);
}
remoteCommandCallback?.({
type: 'channel-select-number',
number: 2,
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe('xtream::pl-1::1');
remoteCommandCallback?.({
type: 'channel-select-number',
number: 3,
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeUid()).toBe('xtream::pl-1::3');
}
);
it('wraps upward from the first visible channel to the last', async () => {
await configure();
await setItems([
@@ -335,7 +374,10 @@ function buildItem(
...(sourceType === 'xtream'
? { xtreamId: Number(sourceItemId) }
: sourceType === 'stalker'
? { stalkerId: sourceItemId, stalkerCmd: `ffmpeg http://s/${sourceItemId}` }
? {
stalkerId: sourceItemId,
stalkerCmd: `ffmpeg http://s/${sourceItemId}`,
}
: { streamUrl: `https://example.com/${sourceItemId}.m3u8` }),
} as UnifiedCollectionItem;
}
@@ -86,6 +86,64 @@ describe('DatabaseService browser guards', () => {
);
});
it.each([
'getGlobalFavorites',
'getAllGlobalFavorites',
'getFavorites',
] as const)(
'%s still reads healthy favorites when a pending conversion fails',
async (method) => {
const pending = JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7.5 }],
});
localStorage.setItem('xtream-restore-broken', pending);
localStorage.setItem(
'xtream-restore-healthy',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 9 }],
})
);
const rows = [{ favorite_id: 1, title: 'Saved favorite' }];
const read = jest.fn().mockResolvedValue(rows);
const restore = jest.fn(async (id: string) => {
if (id === 'broken')
throw new Error('Invalid favorite identity');
return { success: true };
});
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
dbGetPlaylist: jest
.fn()
.mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: restore,
dbGetGlobalFavorites: read,
dbGetAllGlobalFavorites: read,
dbGetFavorites: read,
dbGetFavoritesForBackup: jest.fn(),
},
});
await expect(service[method]('broken')).resolves.toEqual(rows);
expect(read).toHaveBeenCalledTimes(1);
if (method !== 'getFavorites') {
expect(restore).toHaveBeenCalledWith(
'healthy',
[{ contentType: 'movie', xtreamId: 9 }],
undefined
);
}
expect(localStorage.getItem('xtream-restore-broken')).toBe(pending);
restore.mockResolvedValueOnce({ success: false });
await expect(service.getFavorites('broken')).resolves.toEqual(rows);
await expect(service.getFavoritesOrThrow('broken')).rejects.toThrow(
'Invalid favorite identity'
);
expect(
window.electron.dbGetFavoritesForBackup
).not.toHaveBeenCalled();
}
);
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
@@ -741,7 +741,11 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
await materializePendingXtreamFavorites(
window.electron,
undefined,
'continue'
);
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
} catch (error) {
@@ -759,7 +763,11 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
await materializePendingXtreamFavorites(
window.electron,
undefined,
'continue'
);
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
@@ -901,7 +909,8 @@ export class DatabaseService {
try {
await materializePendingXtreamFavorites(
window.electron,
playlistId
playlistId,
'continue'
);
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
@@ -10,7 +10,8 @@ export async function materializePendingXtreamFavorites(
ElectronBridgeApi,
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
>,
playlistId?: string
playlistId?: string,
failureMode: 'throw' | 'continue' = 'throw'
): Promise<void> {
const prefix = getXtreamPendingRestoreStorageKey('');
const ids = playlistId
@@ -35,14 +36,20 @@ export async function materializePendingXtreamFavorites(
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
try {
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
} catch (error) {
if (failureMode === 'throw') throw error;
// Keep pending state for retry while ordinary reads show healthy data.
console.warn('A pending favorites conversion could not complete.');
}
}
}
@@ -0,0 +1,122 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import {
PlaylistDeleteActionService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { DialogService } from '@iptvnator/ui/components';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
import {
DashboardDataService,
DashboardSourceExpiryService,
DashboardTrendingService,
DashboardRecommendationsService,
type DashboardFavoriteItem,
} from '@iptvnator/workspace/dashboard/data-access';
import { DashboardLiveEpgPresenter } from './dashboard-live-epg.presenter';
import { WorkspaceDashboardRailsComponent } from './workspace-dashboard-rails.component';
describe('dashboard favorite status translations', () => {
it.each([
'onLangChange',
'onDefaultLangChange',
'onTranslationChange',
] as const)(
'refreshes both rails on %s without reloading favorites',
(event) => {
let language = 'en';
const events = {
onLangChange: new Subject(),
onDefaultLangChange: new Subject(),
onTranslationChange: new Subject(),
};
const saved: DashboardFavoriteItem = {
id: 1,
favorite_id: 1,
source: 'xtream',
playlist_id: 'p',
category_id: '1',
xtream_id: 7,
type: 'movie',
title: 'Saved',
added_at: '2026-01-01',
catalogStatus: 'missing',
};
TestBed.configureTestingModule({
providers: [
...[
MatDialog,
MatSnackBar,
Router,
Store,
DialogService,
PlaylistDeleteActionService,
PlaylistRefreshActionService,
WORKSPACE_SHELL_ACTIONS,
RuntimeCapabilitiesService,
SettingsStore,
DashboardTrendingService,
DashboardRecommendationsService,
].map((provide) => ({ provide, useValue: {} })),
{
provide: TranslateService,
useValue: {
...events,
instant: (key: string) => `${language}:${key}`,
},
},
{
provide: DashboardDataService,
useValue: {
globalFavoriteItems: signal([saved]),
globalFavoriteLiveItems: signal([
{
...saved,
type: 'live',
catalogStatus: 'unknown',
},
]),
getGlobalFavoriteLink: () => [],
getGlobalFavoriteNavigationState: () => undefined,
reloadGlobalRecentItems: jest.fn(),
reloadGlobalFavorites: jest.fn(),
},
},
{
provide: DashboardLiveEpgPresenter,
useValue: { connect: jest.fn() },
},
{
provide: DashboardSourceExpiryService,
useValue: { facts: signal(new Map()) },
},
],
});
const component = TestBed.runInInjectionContext(
() => new WorkspaceDashboardRailsComponent()
);
expect(component.liveFavoriteCards()[0].subtitle).toBe(
'en:WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
);
expect(component.favoriteMoviesAndSeriesCards()[0].subtitle).toBe(
'en:WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
);
language = 'de';
events[event].next({});
expect(component.liveFavoriteCards()[0].subtitle).toBe(
'de:WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
);
expect(component.favoriteMoviesAndSeriesCards()[0].subtitle).toBe(
'de:WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
);
}
);
});
@@ -155,12 +155,13 @@ export class WorkspaceDashboardRailsComponent {
this.continueWatchingBaseCards()
);
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() =>
this.data
readonly liveFavoriteCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.data
.globalFavoriteLiveItems()
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
.map((item) => this.toFavoriteCard(item));
});
readonly recentLiveCards = computed<DashboardRailCard[]>(() =>
this.data
@@ -218,13 +219,14 @@ export class WorkspaceDashboardRailsComponent {
this.liveEpg.enrich(this.recentLiveCards())
);
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() =>
this.data
readonly favoriteMoviesAndSeriesCards = computed<DashboardRailCard[]>(() => {
this.languageTick();
return this.data
.globalFavoriteItems()
.filter((item) => item.type === 'movie' || item.type === 'series')
.slice(0, RAIL_ITEM_LIMIT)
.map((item) => this.toFavoriteCard(item))
);
.map((item) => this.toFavoriteCard(item));
});
readonly continueWatchingSeeAllState = computed(() =>
buildDashboardRailSeeAllState(this.continueWatchingBaseCards())