mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
1 parent
ec4b9286a7
commit
8ccfcb68cc
121 files changed
+4000
-398
No files matched your search
@@ -632,6 +632,44 @@ describe('DashboardDataService', () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps an unresolved favorite readable and removable without opening its details', async () => {
|
||||
dbServiceMock.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: null,
|
||||
category_id: null,
|
||||
favorite_id: 71,
|
||||
catalog_status: 'missing',
|
||||
title: 'Saved ABC',
|
||||
xtream_id: 501,
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
added_at: '2026-02-02T10:00:00.000Z',
|
||||
poster_url: 'https://example.test/abc.jpg',
|
||||
},
|
||||
]);
|
||||
await service.reloadGlobalFavorites();
|
||||
const item = service
|
||||
.globalFavoriteItems()
|
||||
.find((row) => row.source === 'xtream');
|
||||
if (!item) throw new Error('Expected the saved Xtream favorite');
|
||||
expect(item).toMatchObject({
|
||||
id: 'favorite:71',
|
||||
title: 'Saved ABC',
|
||||
catalogStatus: 'missing',
|
||||
favorite_id: 71,
|
||||
});
|
||||
expect(service.getGlobalFavoriteLink(item)).toEqual([
|
||||
'/workspace/global-favorites',
|
||||
]);
|
||||
expect(service.getGlobalFavoriteNavigationState(item)).toBeUndefined();
|
||||
await service.removeGlobalFavorite(item);
|
||||
expect(dbServiceMock.removeFromFavorites).toHaveBeenCalledWith(
|
||||
{ favoriteId: 71 },
|
||||
'xtream-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('includes PWA Xtream favorites from the active data source', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: undefined,
|
||||
@@ -1139,7 +1177,10 @@ describe('DashboardDataService', () => {
|
||||
// Hold every answer: asked one after another, the second playlist
|
||||
// would not be asked until the first had answered, and a DB worker
|
||||
// busy with a long request would delay the set once per playlist.
|
||||
const pending = new Map<string, (rows: PlaybackPositionData[]) => void>();
|
||||
const pending = new Map<
|
||||
string,
|
||||
(rows: PlaybackPositionData[]) => void
|
||||
>();
|
||||
playbackPositionsMock.getAllPlaybackPositions.mockImplementation(
|
||||
(playlistId: string) =>
|
||||
new Promise<PlaybackPositionData[]>((resolve) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
Injectable,
|
||||
NgZone,
|
||||
@@ -699,17 +700,19 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
private mapPwaXtreamFavoriteItem(
|
||||
item: XtreamContentItem,
|
||||
item: import('@iptvnator/portal/xtream/data-access').XtreamFavoriteItem,
|
||||
playlist: PlaylistMeta
|
||||
): DashboardFavoriteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
id: item.id ?? `favorite:${item.favorite_id}`,
|
||||
favorite_id: item.favorite_id,
|
||||
catalogStatus: item.catalog_status,
|
||||
title: item.title,
|
||||
type: this.normalizeXtreamActivityType(item.type),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Xtream',
|
||||
added_at: item.added_at || item.added || new Date(0).toISOString(),
|
||||
category_id: item.category_id,
|
||||
category_id: item.category_id ?? '',
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
@@ -996,12 +999,14 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
||||
if (isCatalogUnavailable(item)) return ['/workspace/global-favorites'];
|
||||
return getGlobalFavoriteLinkUtil(item);
|
||||
}
|
||||
|
||||
getGlobalFavoriteNavigationState(
|
||||
item: DashboardFavoriteItem
|
||||
): WorkspaceNavigationTarget['state'] {
|
||||
if (isCatalogUnavailable(item)) return undefined;
|
||||
return getGlobalFavoriteNavigationStateUtil(item);
|
||||
}
|
||||
|
||||
@@ -1019,7 +1024,9 @@ export class DashboardDataService {
|
||||
if (item.source === 'xtream') {
|
||||
if (this.hasPortalActivityStorage) {
|
||||
await this.dbService.removeFromFavorites(
|
||||
item.id as number,
|
||||
item.favorite_id != null
|
||||
? { favoriteId: item.favorite_id }
|
||||
: (item.id as number),
|
||||
item.playlist_id
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -60,13 +60,15 @@ export function mapDbFavoriteToItem(
|
||||
item: DbGlobalFavoriteItem
|
||||
): PortalFavoriteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
id: item.id ?? `favorite:${item.favorite_id}`,
|
||||
favorite_id: item.favorite_id,
|
||||
catalogStatus: item.catalog_status,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
added_at: normalizeStalkerDate(item.added_at),
|
||||
category_id: item.category_id,
|
||||
category_id: item.category_id ?? '',
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
backdrop_url: item.backdrop_url ?? undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
isStalkerRadioItem,
|
||||
isCatalogUnavailable,
|
||||
type EpgProgram,
|
||||
type PortalActivityItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
@@ -35,7 +36,7 @@ export function buildDashboardPortalLiveEpgKey(
|
||||
export function buildDashboardPortalLiveEpgEntry(
|
||||
item: PortalActivityItem
|
||||
): DashboardPortalLiveEpgEntry | null {
|
||||
if (item.type !== 'live') {
|
||||
if (item.type !== 'live' || isCatalogUnavailable(item)) {
|
||||
return null;
|
||||
}
|
||||
if (item.source === 'xtream') {
|
||||
|
||||
@@ -231,6 +231,15 @@ describe('DashboardHeroSlidesPresenter', () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('leaves unavailable saved films in the collection rather than promoting them to a playable hero', () => {
|
||||
favorites.set([{ ...favoriteFilm, catalogStatus: 'missing' }]);
|
||||
expect(
|
||||
create()
|
||||
.slides()
|
||||
.some((slide) => slide.kind === 'favorite')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('builds the rotation from resume, live, favourite and import slides', () => {
|
||||
const slides = create().slides();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
@@ -128,7 +129,9 @@ export class DashboardHeroSlidesPresenter {
|
||||
favorites: this.data
|
||||
.globalFavoriteItems()
|
||||
.filter(
|
||||
(item) => item.type === 'movie' || item.type === 'series'
|
||||
(item) =>
|
||||
!isCatalogUnavailable(item) &&
|
||||
(item.type === 'movie' || item.type === 'series')
|
||||
),
|
||||
recentlyAdded: this.data.xtreamRecentlyAddedItems(),
|
||||
// Until the resume candidates are known the newest row may be one
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
buildDashboardLiveEpgDetails,
|
||||
buildLiveEpgLookupGroups,
|
||||
getLiveEpgProgramForCard,
|
||||
liveEpgProgramKey,
|
||||
calcEpgProgress,
|
||||
formatEpgTimeRange,
|
||||
sameLiveEpgAnswers,
|
||||
@@ -25,6 +28,23 @@ function program(overrides: Partial<EpgProgram> = {}): EpgProgram {
|
||||
}
|
||||
|
||||
describe('dashboard-live-epg.utils', () => {
|
||||
it.each(['missing', 'unknown'] as const)(
|
||||
'does not query or enrich unavailable favorites (%s)',
|
||||
(catalogStatus) => {
|
||||
const card = {
|
||||
id: 'saved',
|
||||
title: 'ABC',
|
||||
icon: 'live_tv',
|
||||
link: [],
|
||||
catalogStatus,
|
||||
};
|
||||
expect(buildLiveEpgLookupGroups([card], () => [])).toEqual([]);
|
||||
const answers = new Map([
|
||||
[liveEpgProgramKey('scope', 'ABC'), program()],
|
||||
]);
|
||||
expect(getLiveEpgProgramForCard(card, answers, 'scope')).toBeNull();
|
||||
}
|
||||
);
|
||||
describe('timestamps in unix seconds', () => {
|
||||
// `EpgProgram.startTimestamp`/`stopTimestamp` are unix SECONDS
|
||||
// everywhere else in the app; treating them as milliseconds put
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
epgDisplayTimeMs,
|
||||
epgProviderClockMs,
|
||||
@@ -120,7 +121,9 @@ export function buildDashboardLiveEpgDetails(
|
||||
}
|
||||
|
||||
function liveEpgLookupKeyForCard(card: DashboardRailCard): string {
|
||||
return card.epgLookupKey?.trim() || card.title.trim();
|
||||
return isCatalogUnavailable(card)
|
||||
? ''
|
||||
: card.epgLookupKey?.trim() || card.title.trim();
|
||||
}
|
||||
|
||||
/** One XMLTV lookup: the scope it is answered in and the keys asked for. */
|
||||
@@ -244,6 +247,7 @@ export function getLiveEpgProgramForCard(
|
||||
epgMap: ReadonlyMap<string, EpgProgram | null>,
|
||||
scopeKey: string
|
||||
): EpgProgram | null {
|
||||
if (isCatalogUnavailable(card)) return null;
|
||||
const key = liveEpgLookupKeyForCard(card);
|
||||
const program = epgMap.get(liveEpgProgramKey(scopeKey, key));
|
||||
if (program) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@@ -36,6 +37,7 @@ export interface DashboardRailCard {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
catalogStatus?: XtreamFavoriteCatalogStatus;
|
||||
imageUrl?: string;
|
||||
icon: string;
|
||||
contentType?: 'live' | 'movie' | 'series';
|
||||
|
||||
@@ -513,6 +513,15 @@ export class WorkspaceDashboardRailsComponent {
|
||||
private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard {
|
||||
return {
|
||||
id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`,
|
||||
catalogStatus: item.catalogStatus,
|
||||
subtitle:
|
||||
item.catalogStatus && item.catalogStatus !== 'present'
|
||||
? this.translate.instant(
|
||||
item.catalogStatus === 'missing'
|
||||
? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
: 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
|
||||
)
|
||||
: undefined,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
icon: this.typeIcon(item.type),
|
||||
|
||||
Reference in new issue
Block a user