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

@@ -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())