mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
fix(xtream): protect favorite snapshots and collection navigation
This commit is contained in:
1 parent
8ccfcb68cc
commit
e5a5d0581d
16 files changed
+427
-50
No files matched your search
+5
-2
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
+47
-5
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+122
@@ -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'
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
+10
-8
@@ -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())
|
||||
|
||||
Reference in new issue
Block a user