mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -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
@@ -77,6 +77,7 @@ describe('XtreamRefreshFlowService', () => {
|
||||
let confirmPromise: Promise<void> | undefined;
|
||||
let order: string[];
|
||||
let databaseService: {
|
||||
restoreXtreamFavoriteSnapshots: jest.Mock;
|
||||
createOperationId: jest.Mock;
|
||||
deleteXtreamPlaylistContent: jest.Mock;
|
||||
updateXtreamPlaylistDetails: jest.Mock;
|
||||
@@ -94,6 +95,10 @@ describe('XtreamRefreshFlowService', () => {
|
||||
order = [];
|
||||
|
||||
databaseService = {
|
||||
restoreXtreamFavoriteSnapshots: jest.fn(() => {
|
||||
order.push('save-favorites');
|
||||
return Promise.resolve();
|
||||
}),
|
||||
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
|
||||
deleteXtreamPlaylistContent: jest.fn(() => {
|
||||
order.push('delete');
|
||||
@@ -154,6 +159,53 @@ describe('XtreamRefreshFlowService', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('converts legacy pending favorites before deleting or replacing the old catalog', async () => {
|
||||
localStorage.setItem(
|
||||
'xtream-restore-playlist-1',
|
||||
JSON.stringify({
|
||||
favorites: [{ contentType: 'movie', xtreamId: 7 }],
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
})
|
||||
);
|
||||
service.confirmAndRefresh(
|
||||
createPlaylistMeta(),
|
||||
createReporter().reporter
|
||||
);
|
||||
await confirmPromise;
|
||||
expect(
|
||||
databaseService.restoreXtreamFavoriteSnapshots
|
||||
).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
[{ contentType: 'movie', xtreamId: 7 }],
|
||||
undefined
|
||||
);
|
||||
expect(order.indexOf('save-favorites')).toBeLessThan(
|
||||
order.indexOf('delete')
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves legacy pending state and the catalog when durable conversion fails', async () => {
|
||||
const pending = JSON.stringify({
|
||||
favorites: [{ contentType: 'movie', xtreamId: 7 }],
|
||||
});
|
||||
localStorage.setItem('xtream-restore-playlist-1', pending);
|
||||
databaseService.restoreXtreamFavoriteSnapshots.mockRejectedValue(
|
||||
new Error('SQLite unavailable')
|
||||
);
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
service.confirmAndRefresh(
|
||||
createPlaylistMeta(),
|
||||
createReporter().reporter
|
||||
);
|
||||
await confirmPromise;
|
||||
expect(
|
||||
databaseService.deleteXtreamPlaylistContent
|
||||
).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(pending);
|
||||
});
|
||||
|
||||
it('ignores a confirmation that arrives while the playlist is already busy', async () => {
|
||||
const { reporter, calls } = createReporter({ isBusy: () => true });
|
||||
|
||||
|
||||
@@ -152,6 +152,17 @@ export class XtreamRefreshFlowService {
|
||||
);
|
||||
await reporter.waitForVisibleProgress?.();
|
||||
|
||||
// Migrate an older parked favorite list before replacing its state.
|
||||
// A durable receipt prevents replay after an explicit user removal.
|
||||
const pending = this.pendingRestoreService.getOrThrow(item._id);
|
||||
if (pending) {
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
item._id,
|
||||
pending.favorites,
|
||||
pending.favoritesRestoreId
|
||||
);
|
||||
}
|
||||
|
||||
// Delete content/categories and update the timestamp in parallel —
|
||||
// all three operations are fully independent.
|
||||
const updateDate = Date.now();
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
|
||||
import { StreamResolverService } from './stream-resolver.service';
|
||||
|
||||
export function registerUnavailableFavoriteEpgTests(
|
||||
getContext: () => {
|
||||
service: StreamResolverService;
|
||||
xtreamApi: { getShortEpg: jest.Mock };
|
||||
dataService: { sendIpcEvent: jest.Mock };
|
||||
}
|
||||
) {
|
||||
it.each(['missing', 'unknown'] as const)(
|
||||
'skips EPG requests for an unavailable saved channel (%s)',
|
||||
async (catalogStatus) => {
|
||||
const { service, xtreamApi, dataService } = getContext();
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'saved-1',
|
||||
name: 'ABC',
|
||||
contentType: 'live',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 1,
|
||||
catalogStatus,
|
||||
};
|
||||
expect((await service.loadEpgForItems([item])).size).toBe(0);
|
||||
expect(xtreamApi.getShortEpg).not.toHaveBeenCalled();
|
||||
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { registerUnavailableFavoriteEpgTests } from './stream-resolver-favorites.test-helpers';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
import {
|
||||
@@ -299,6 +300,12 @@ describe('StreamResolverService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
registerUnavailableFavoriteEpgTests(() => ({
|
||||
service,
|
||||
xtreamApi,
|
||||
dataService,
|
||||
}));
|
||||
|
||||
it('skips portal EPG lookups in browser/PWA mode', async () => {
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
epgBridge.supportsProgramLookup = false;
|
||||
@@ -830,9 +837,7 @@ describe('StreamResolverService', () => {
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(stalkerSession.ensureToken).toHaveBeenCalled();
|
||||
expect(playback.headers?.['Cookie']).toContain(
|
||||
'mac=00:11:22:33:44:55'
|
||||
);
|
||||
expect(playback.headers?.['Cookie']).toContain('mac=00:11:22:33:44:55');
|
||||
expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN77');
|
||||
expect(playback.userAgent).toBe(playback.headers?.['User-Agent']);
|
||||
expect(playback.referer).toBe('https://stalker.example.com');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils';
|
||||
@@ -213,7 +214,7 @@ export class StreamResolverService {
|
||||
const stalkerByPlaylist = new Map<string, UnifiedCollectionItem[]>();
|
||||
|
||||
for (const item of items) {
|
||||
if (item.contentType !== 'live') {
|
||||
if (item.contentType !== 'live' || isCatalogUnavailable(item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
+49
@@ -226,6 +226,55 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
service = TestBed.inject(UnifiedFavoritesDataService);
|
||||
});
|
||||
|
||||
it('keeps an unavailable Xtream favorite readable and removable without a catalog row', async () => {
|
||||
databaseService.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
id: null,
|
||||
category_id: null,
|
||||
favorite_id: 77,
|
||||
catalog_status: 'missing',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream One',
|
||||
xtream_id: 101,
|
||||
title: 'ABC',
|
||||
type: 'movie',
|
||||
poster_url: 'abc.png',
|
||||
position: 3,
|
||||
},
|
||||
]);
|
||||
|
||||
const [item] = await service.getFavorites('all');
|
||||
expect(item).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'ABC',
|
||||
favoriteId: 77,
|
||||
catalogStatus: 'missing',
|
||||
contentId: undefined,
|
||||
posterUrl: 'abc.png',
|
||||
position: 3,
|
||||
})
|
||||
);
|
||||
await service.removeFavorite(item);
|
||||
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith(
|
||||
{ favoriteId: 77 },
|
||||
'xtream-1'
|
||||
);
|
||||
await service.reorder([item], {
|
||||
scope: 'playlist',
|
||||
playlistId: 'xtream-1',
|
||||
portalType: 'xtream',
|
||||
});
|
||||
expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([
|
||||
{ favorite_id: 77, playlist_id: 'xtream-1', position: 0 },
|
||||
]);
|
||||
electronApi.dbRemoveFavorite.mockClear();
|
||||
await service.clearFavorites([item]);
|
||||
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith(
|
||||
{ favoriteId: 77 },
|
||||
'xtream-1'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps global Xtream favorites across live, movie, and series content types', async () => {
|
||||
databaseService.getAllGlobalFavorites.mockResolvedValue([
|
||||
{
|
||||
|
||||
+28
-29
@@ -1,3 +1,4 @@
|
||||
import type { ElectronBridgeFavoriteReorderUpdate } from '@iptvnator/shared/interfaces';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamContentItem,
|
||||
XtreamFavoriteItem,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
|
||||
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
|
||||
@@ -92,10 +93,15 @@ export class UnifiedFavoritesDataService {
|
||||
break;
|
||||
}
|
||||
case 'xtream': {
|
||||
if (item.contentId == null) {
|
||||
const electron = this.electronActivityBridge;
|
||||
if (electron && item.favoriteId != null) {
|
||||
await electron.dbRemoveFavorite(
|
||||
{ favoriteId: item.favoriteId },
|
||||
item.playlistId
|
||||
);
|
||||
return;
|
||||
}
|
||||
const electron = this.electronActivityBridge;
|
||||
if (item.contentId == null) return;
|
||||
if (electron) {
|
||||
await electron.dbRemoveFavorite(
|
||||
item.contentId,
|
||||
@@ -290,6 +296,7 @@ export class UnifiedFavoritesDataService {
|
||||
}
|
||||
|
||||
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
|
||||
|
||||
const electron = this.electronActivityBridge;
|
||||
|
||||
if (
|
||||
@@ -373,19 +380,7 @@ export class UnifiedFavoritesDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
items
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
): item is UnifiedCollectionItem & {
|
||||
readonly contentId: number;
|
||||
} => item.contentId != null
|
||||
)
|
||||
.map((item) =>
|
||||
electron.dbRemoveFavorite(item.contentId, item.playlistId)
|
||||
)
|
||||
);
|
||||
await Promise.all(items.map((item) => this.removeFavorite(item)));
|
||||
}
|
||||
|
||||
private async clearStalkerFavorites(
|
||||
@@ -593,7 +588,7 @@ export class UnifiedFavoritesDataService {
|
||||
logo: ct === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
categoryId: row.category_id ?? undefined,
|
||||
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
|
||||
rating: row.rating ?? undefined,
|
||||
tvArchive: row.tv_archive ?? null,
|
||||
@@ -601,12 +596,14 @@ export class UnifiedFavoritesDataService {
|
||||
addedAt:
|
||||
normalizeStalkerDate(row.added_at) || new Date(0).toISOString(),
|
||||
position: row.position ?? 0,
|
||||
contentId: row.id,
|
||||
contentId: row.id ?? undefined,
|
||||
favoriteId: row.favorite_id,
|
||||
catalogStatus: row.catalog_status,
|
||||
};
|
||||
}
|
||||
|
||||
private mapXtreamContentItem(
|
||||
item: XtreamContentItem,
|
||||
item: XtreamFavoriteItem,
|
||||
playlistId: string,
|
||||
playlistName?: string
|
||||
): UnifiedCollectionItem {
|
||||
@@ -621,7 +618,7 @@ export class UnifiedFavoritesDataService {
|
||||
logo: ct === 'live' ? (item.poster_url ?? null) : null,
|
||||
posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null,
|
||||
xtreamId: item.xtream_id,
|
||||
categoryId: item.category_id,
|
||||
categoryId: item.category_id ?? undefined,
|
||||
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
|
||||
rating: item.rating ?? undefined,
|
||||
tvArchive: item.tv_archive ?? null,
|
||||
@@ -630,7 +627,9 @@ export class UnifiedFavoritesDataService {
|
||||
normalizeStalkerDate(item.added_at ?? item.added) ||
|
||||
new Date(0).toISOString(),
|
||||
position: item.position ?? 0,
|
||||
contentId: item.id,
|
||||
contentId: item.id ?? undefined,
|
||||
favoriteId: item.favorite_id,
|
||||
catalogStatus: item.catalog_status,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -807,16 +806,16 @@ export class UnifiedFavoritesDataService {
|
||||
}
|
||||
|
||||
private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) {
|
||||
const updates: {
|
||||
content_id: number;
|
||||
playlist_id: string;
|
||||
position: number;
|
||||
}[] = [];
|
||||
|
||||
const updates: ElectronBridgeFavoriteReorderUpdate[] = [];
|
||||
for (const item of items) {
|
||||
if (item.sourceType === 'xtream' && item.contentId != null) {
|
||||
if (
|
||||
item.sourceType === 'xtream' &&
|
||||
(item.favoriteId != null || item.contentId != null)
|
||||
) {
|
||||
updates.push({
|
||||
content_id: item.contentId,
|
||||
...(item.favoriteId != null
|
||||
? { favorite_id: item.favoriteId }
|
||||
: { content_id: item.contentId }),
|
||||
playlist_id: item.playlistId,
|
||||
position: updates.length,
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
role="button"
|
||||
tabindex="0"
|
||||
[attr.aria-label]="title()"
|
||||
[attr.aria-disabled]="disabled() || null"
|
||||
(click)="onCardClick()"
|
||||
(keydown.enter)="onCardKey($event)"
|
||||
(keydown.space)="onCardKey($event)"
|
||||
@@ -57,11 +58,18 @@
|
||||
@if (!postersOnly()) {
|
||||
<div class="card-info">
|
||||
<h3>{{ title() }}</h3>
|
||||
@if (date()) {
|
||||
@if (statusLabel()) {
|
||||
<p class="date-info" data-testid="favorite-catalog-status">
|
||||
{{ statusLabel() }}
|
||||
</p>
|
||||
} @else if (date()) {
|
||||
<p class="date-info">
|
||||
{{
|
||||
date()
|
||||
| date: 'd MMM, HH:mm' : undefined : currentLocale()
|
||||
| date
|
||||
: 'd MMM, HH:mm'
|
||||
: undefined
|
||||
: currentLocale()
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
.content-card__activation {
|
||||
border-radius: inherit;
|
||||
outline: none;
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(> .content-card__activation:focus-visible) {
|
||||
|
||||
@@ -38,6 +38,8 @@ export class ContentCardComponent {
|
||||
|
||||
/** Title to display */
|
||||
readonly title = input.required<string>();
|
||||
readonly disabled = input(false);
|
||||
readonly statusLabel = input('');
|
||||
|
||||
/** Content type (live, movie, series) */
|
||||
readonly type = input<string>();
|
||||
@@ -106,7 +108,7 @@ export class ContentCardComponent {
|
||||
}
|
||||
|
||||
onCardClick(): void {
|
||||
this.cardClick.emit();
|
||||
if (!this.disabled()) this.cardClick.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +118,7 @@ export class ContentCardComponent {
|
||||
*/
|
||||
onCardKey(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.cardClick.emit();
|
||||
this.onCardClick();
|
||||
}
|
||||
|
||||
onRemoveClick(event: Event): void {
|
||||
|
||||
+11
-2
@@ -34,8 +34,17 @@
|
||||
[isDraggable]="canDragDrop()"
|
||||
[logo]="ch.logo"
|
||||
[name]="ch.name"
|
||||
[disabled]="isUnavailable(ch)"
|
||||
[statusLabel]="
|
||||
isUnavailable(ch)
|
||||
? ((ch.catalogStatus === 'missing'
|
||||
? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
: 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
|
||||
) | translate)
|
||||
: ''
|
||||
"
|
||||
[selected]="activeUid() === ch.uid"
|
||||
[showEpg]="showEpg() && ch.radio !== 'true'"
|
||||
[showEpg]="!isUnavailable(ch) && showEpg() && ch.radio !== 'true'"
|
||||
[isRadio]="ch.radio === 'true'"
|
||||
[epgProgram]="ch.currentEpgProgram"
|
||||
[progressPercentage]="ch.progressPercentage"
|
||||
@@ -47,7 +56,7 @@
|
||||
[showDetailsContextMenu]="hasChannelContextMenu(ch)"
|
||||
(clicked)="onChannelClick(ch)"
|
||||
(activated)="onChannelActivate(ch)"
|
||||
(keyboardActivated)="playbackRequested.emit(ch)"
|
||||
(keyboardActivated)="onPlaybackRequested(ch)"
|
||||
(favoriteToggled)="onFavoriteToggled(ch)"
|
||||
(contextMenuRequested)="onChannelContextMenu(ch, $event)"
|
||||
/>
|
||||
|
||||
+13
-2
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
CdkDragDrop,
|
||||
@@ -165,12 +166,20 @@ export class GlobalFavoritesListComponent {
|
||||
});
|
||||
}
|
||||
|
||||
readonly isUnavailable = isCatalogUnavailable;
|
||||
|
||||
onPlaybackRequested(channel: UnifiedFavoriteChannel): void {
|
||||
if (!isCatalogUnavailable(channel))
|
||||
this.playbackRequested.emit(channel);
|
||||
}
|
||||
|
||||
onChannelClick(channel: UnifiedFavoriteChannel): void {
|
||||
if (isCatalogUnavailable(channel)) return;
|
||||
this.channelSelected.emit(channel);
|
||||
}
|
||||
|
||||
onChannelActivate(channel: UnifiedFavoriteChannel): void {
|
||||
if (this.openStreamOnDoubleClick()) {
|
||||
if (this.openStreamOnDoubleClick() && !isCatalogUnavailable(channel)) {
|
||||
this.playbackRequested.emit(channel);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +209,7 @@ export class GlobalFavoritesListComponent {
|
||||
}
|
||||
|
||||
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
|
||||
if (isCatalogUnavailable(channel)) return false;
|
||||
return (
|
||||
Boolean(channel.m3uChannel) ||
|
||||
this.mode() === 'recent' ||
|
||||
@@ -211,6 +221,7 @@ export class GlobalFavoritesListComponent {
|
||||
}
|
||||
|
||||
canOpenInPlaylist(channel: UnifiedFavoriteChannel): boolean {
|
||||
if (isCatalogUnavailable(channel)) return false;
|
||||
return getLiveCollectionPlaylistNavigation(channel) !== null;
|
||||
}
|
||||
|
||||
@@ -226,7 +237,7 @@ export class GlobalFavoritesListComponent {
|
||||
|
||||
openEpgMapping(): void {
|
||||
const item = this.contextMenuChannel();
|
||||
if (!item) {
|
||||
if (!item || isCatalogUnavailable(item)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+38
-15
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
computed,
|
||||
effect,
|
||||
@@ -50,6 +51,8 @@ export interface CollectionDetailState {
|
||||
*/
|
||||
export function createCollectionDetailState(options: {
|
||||
mode: Signal<CollectionMode>;
|
||||
items: Signal<UnifiedCollectionItem[]>;
|
||||
isLoading: Signal<boolean>;
|
||||
portalType: Signal<string | undefined>;
|
||||
/** Absent while the host projects no detail template. */
|
||||
detailTemplate: Signal<unknown>;
|
||||
@@ -72,6 +75,7 @@ export function createCollectionDetailState(options: {
|
||||
);
|
||||
|
||||
const canOpen = (candidate: UnifiedCollectionItem): boolean =>
|
||||
!isCatalogUnavailable(candidate) &&
|
||||
Boolean(options.detailTemplate()) &&
|
||||
candidate.contentType !== 'live' &&
|
||||
(candidate.sourceType === 'xtream' ||
|
||||
@@ -82,6 +86,7 @@ export function createCollectionDetailState(options: {
|
||||
next: UnifiedCollectionItem,
|
||||
nextSeriesResume?: SeriesResumeTarget | null
|
||||
): void => {
|
||||
if (isCatalogUnavailable(next)) return;
|
||||
options.onOpen(next.contentType);
|
||||
item.set(next);
|
||||
seriesResume.set(nextSeriesResume ?? null);
|
||||
@@ -128,6 +133,38 @@ export function createCollectionDetailState(options: {
|
||||
});
|
||||
});
|
||||
|
||||
const syncFromHistory = (): void => {
|
||||
if (options.isLoading()) return;
|
||||
const detailState = getOpenCollectionDetailItemState(
|
||||
window.history.state
|
||||
);
|
||||
const savedItem = detailState?.item;
|
||||
const currentItem =
|
||||
savedItem &&
|
||||
options.items().find((row) => row.uid === savedItem.uid);
|
||||
// The catalog may have changed since this history entry was written.
|
||||
// Unrelated dashboard discoveries need not be a saved favorite.
|
||||
const detailItem =
|
||||
currentItem ??
|
||||
(savedItem?.favoriteId != null && options.mode() === 'favorites'
|
||||
? null
|
||||
: savedItem);
|
||||
|
||||
if (detailItem && canOpen(detailItem)) {
|
||||
open(detailItem, detailState?.seriesResume);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item()) {
|
||||
clear();
|
||||
}
|
||||
};
|
||||
|
||||
effect(() => {
|
||||
options.items();
|
||||
if (!options.isLoading()) untracked(syncFromHistory);
|
||||
});
|
||||
|
||||
return {
|
||||
item,
|
||||
seriesResume,
|
||||
@@ -147,20 +184,6 @@ export function createCollectionDetailState(options: {
|
||||
canOpen,
|
||||
open,
|
||||
clear,
|
||||
syncFromHistory(): void {
|
||||
const detailState = getOpenCollectionDetailItemState(
|
||||
window.history.state
|
||||
);
|
||||
const detailItem = detailState?.item;
|
||||
|
||||
if (detailItem && canOpen(detailItem)) {
|
||||
open(detailItem, detailState.seriesResume);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item()) {
|
||||
clear();
|
||||
}
|
||||
},
|
||||
syncFromHistory,
|
||||
};
|
||||
}
|
||||
+33
@@ -762,6 +762,39 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
expect(fixture.componentInstance.allItems()).toEqual(liveItems);
|
||||
});
|
||||
|
||||
it('does not open a stale history detail when the favorite is now missing', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::movie:77',
|
||||
name: 'ABC',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream',
|
||||
xtreamId: 77,
|
||||
favoriteId: 11,
|
||||
catalogStatus: 'present',
|
||||
};
|
||||
favoritesData.getFavorites.mockResolvedValue([
|
||||
{ ...item, catalogStatus: 'missing' },
|
||||
]);
|
||||
window.history.replaceState(
|
||||
{ [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item } },
|
||||
document.title
|
||||
);
|
||||
const hostFixture = TestBed.createComponent(
|
||||
HostUnifiedCollectionPageComponent
|
||||
);
|
||||
hostFixture.detectChanges();
|
||||
await hostFixture.whenStable();
|
||||
hostFixture.detectChanges();
|
||||
expect(
|
||||
hostFixture.nativeElement.querySelector('.detail-probe')
|
||||
).toBeNull();
|
||||
expect(
|
||||
hostFixture.componentInstance.pageComponent?.selectedDetailItem()
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('opens inline detail from history state when a detail template is projected', async () => {
|
||||
const item: UnifiedCollectionItem = {
|
||||
uid: 'xtream::xtream-1::77',
|
||||
|
||||
+4
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
AfterContentInit,
|
||||
@@ -228,6 +229,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
}),
|
||||
});
|
||||
private readonly detailState = createCollectionDetailState({
|
||||
items: this.allItems,
|
||||
isLoading: this.isLoading,
|
||||
mode: this.mode,
|
||||
portalType: this.portalType,
|
||||
detailTemplate: this.detailTemplate,
|
||||
@@ -309,6 +312,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
}
|
||||
|
||||
onGridItemSelected(item: UnifiedCollectionItem): void {
|
||||
if (isCatalogUnavailable(item)) return;
|
||||
this.viewStateSync.commitCurrent();
|
||||
|
||||
if (this.detailState.canOpen(item)) {
|
||||
|
||||
+10
-1
@@ -20,7 +20,16 @@
|
||||
<div class="grid-container">
|
||||
@for (item of filteredItems(); track trackByUid($index, item)) {
|
||||
<app-content-card
|
||||
[allowPostersOnly]="!hasActiveSearch()"
|
||||
[allowPostersOnly]="!hasActiveSearch() && !isUnavailable(item)"
|
||||
[disabled]="isUnavailable(item)"
|
||||
[statusLabel]="
|
||||
isUnavailable(item)
|
||||
? ((item.catalogStatus === 'missing'
|
||||
? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
: 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
|
||||
) | translate)
|
||||
: ''
|
||||
"
|
||||
[posterUrl]="item.posterUrl ?? ''"
|
||||
[title]="item.name"
|
||||
[type]="item.contentType"
|
||||
|
||||
+23
@@ -63,6 +63,29 @@ describe('UnifiedGridTabComponent posters-only wall', () => {
|
||||
fixture.componentRef.setInput('items', ITEMS);
|
||||
});
|
||||
|
||||
it('retains the title and removal action while blocking an unavailable favorite', () => {
|
||||
const item = {
|
||||
...ITEMS[0],
|
||||
catalogStatus: 'missing',
|
||||
} as UnifiedCollectionItem;
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
const selected = jest.fn();
|
||||
const removed = jest.fn();
|
||||
fixture.componentInstance.itemSelected.subscribe(selected);
|
||||
fixture.componentInstance.removeItem.subscribe(removed);
|
||||
|
||||
fixture.componentInstance.onCardClick(item);
|
||||
fixture.componentInstance.onRemove(item);
|
||||
|
||||
expect(selected).not.toHaveBeenCalled();
|
||||
expect(removed).toHaveBeenCalledWith(item);
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
|
||||
);
|
||||
expect(fixture.debugElement.query(By.css('.card-info'))).not.toBeNull();
|
||||
});
|
||||
|
||||
const cardInfos = () => fixture.debugElement.queryAll(By.css('.card-info'));
|
||||
const overlays = () =>
|
||||
fixture.debugElement.queryAll(By.css('.cover-title-overlay'));
|
||||
|
||||
+7
-1
@@ -12,7 +12,10 @@ import {
|
||||
UnifiedCollectionItem,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { ContentCardComponent } from '../content-card/content-card.component';
|
||||
import { foldSearchText } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
foldSearchText,
|
||||
isCatalogUnavailable,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unified-grid-tab',
|
||||
@@ -49,7 +52,10 @@ export class UnifiedGridTabComponent {
|
||||
: all;
|
||||
});
|
||||
|
||||
readonly isUnavailable = isCatalogUnavailable;
|
||||
|
||||
onCardClick(item: UnifiedCollectionItem): void {
|
||||
if (isCatalogUnavailable(item)) return;
|
||||
this.itemSelected.emit(item);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { computed, Signal } from '@angular/core';
|
||||
import {
|
||||
deriveVisibleFavoriteChannels,
|
||||
@@ -49,7 +50,10 @@ export function createUnifiedLiveChannelRows(options: {
|
||||
})
|
||||
),
|
||||
fullscreenPanel: computed(() =>
|
||||
all().filter((channel) => channel.radio !== 'true')
|
||||
all().filter(
|
||||
(channel) =>
|
||||
channel.radio !== 'true' && !isCatalogUnavailable(channel)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -92,5 +96,7 @@ export function toUnifiedFavoriteChannel(
|
||||
addedAt: item.addedAt ?? new Date(0).toISOString(),
|
||||
position: item.position ?? 0,
|
||||
contentId: item.contentId,
|
||||
favoriteId: item.favoriteId,
|
||||
catalogStatus: item.catalogStatus,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import { inject, WritableSignal } from '@angular/core';
|
||||
import {
|
||||
ResolvedLiveCollectionDetail,
|
||||
@@ -161,6 +162,7 @@ export function createUnifiedLiveSelection(options: {
|
||||
isAutoOpen = false,
|
||||
startPlayback = false
|
||||
): Promise<void> {
|
||||
if (isCatalogUnavailable(item)) return;
|
||||
const activeDetail = options.activeDetail();
|
||||
// The row is on screen only when it is both the highlight and the
|
||||
// resolved item: while a replacement resolves, `activeUid` already
|
||||
|
||||
+15
-4
@@ -1,3 +1,4 @@
|
||||
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@@ -258,7 +259,15 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
}
|
||||
|
||||
const activeUid = this.activeUid();
|
||||
if (activeUid && !items.some((item) => item.uid === activeUid)) {
|
||||
const selected = items.find((item) => item.uid === activeUid);
|
||||
// During a switch the highlight belongs to the pending selection,
|
||||
// while the mounted detail still belongs to the previous item.
|
||||
const retainedUid = this.activeItem()?.uid;
|
||||
const retained = items.find((item) => item.uid === retainedUid);
|
||||
if (
|
||||
(activeUid && (!selected || isCatalogUnavailable(selected))) ||
|
||||
(retainedUid && (!retained || isCatalogUnavailable(retained)))
|
||||
) {
|
||||
this.onClose();
|
||||
}
|
||||
});
|
||||
@@ -319,7 +328,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
|
||||
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
|
||||
const item = this.itemFor(channel);
|
||||
if (item) {
|
||||
if (item && !isCatalogUnavailable(item)) {
|
||||
await this.selection.activate(item);
|
||||
}
|
||||
}
|
||||
@@ -328,7 +337,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
channel: UnifiedFavoriteChannel
|
||||
): Promise<void> {
|
||||
const item = this.itemFor(channel);
|
||||
if (item) {
|
||||
if (item && !isCatalogUnavailable(item)) {
|
||||
await this.selection.activate(item, false, true);
|
||||
}
|
||||
}
|
||||
@@ -419,7 +428,9 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
}
|
||||
|
||||
private openInPlaylist(item: UnifiedCollectionItem): void {
|
||||
const target = getLiveCollectionPlaylistNavigation(item);
|
||||
const current = this.items().find((row) => row.uid === item.uid);
|
||||
if (!current || isCatalogUnavailable(current)) return;
|
||||
const target = getLiveCollectionPlaylistNavigation(current);
|
||||
if (target) {
|
||||
void this.router.navigate(target.link, { state: target.state });
|
||||
}
|
||||
|
||||
+87
@@ -22,6 +22,7 @@ import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-pl
|
||||
import {
|
||||
buildCurrentEpgItem,
|
||||
buildLiveItem,
|
||||
createDeferred,
|
||||
} from './unified-live-tab.spec-data';
|
||||
import {
|
||||
StubAudioPlayerComponent,
|
||||
@@ -212,6 +213,92 @@ describe('UnifiedLiveTabComponent open in playlist', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['missing', 'unknown'] as const)(
|
||||
'closes an active favorite when its catalog status becomes %s',
|
||||
async (catalogStatus) => {
|
||||
const item = await activate('xtream');
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ ...item, catalogStatus },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component.activeItem()).toBeNull();
|
||||
expect(component.activeDetail()).toBeNull();
|
||||
expect(component.activeUid()).toBeNull();
|
||||
expect(chip()).toBeNull();
|
||||
component.onOpenInPlaylistRequested(component.channelsForList()[0]);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing', 'unknown'] as const)(
|
||||
'discards a pending favorite selection when its catalog status becomes %s',
|
||||
async (catalogStatus) => {
|
||||
const item = buildLiveItem('xtream');
|
||||
const detail = {
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: item.name,
|
||||
},
|
||||
epgItems: [],
|
||||
};
|
||||
const pending = createDeferred<typeof detail>();
|
||||
streamResolver.resolveLiveDetail.mockReturnValue(pending.promise);
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
const activation = component.onChannelPlaybackRequested(
|
||||
component.channelsForList()[0]
|
||||
);
|
||||
expect(component.isSelecting()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('items', [
|
||||
{ ...item, catalogStatus },
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
pending.resolve(detail);
|
||||
await activation;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.activeItem()).toBeNull();
|
||||
expect(component.activeDetail()).toBeNull();
|
||||
expect(component.isSelecting()).toBe(false);
|
||||
expect(
|
||||
TestBed.inject(PORTAL_PLAYER).openResolvedPlayback
|
||||
).not.toHaveBeenCalled();
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['missing', 'removed'] as const)(
|
||||
'revokes a retained %s favorite while another selection is pending',
|
||||
async (status) => {
|
||||
const first = await activate('xtream');
|
||||
const second = { ...first, uid: 'xtream::pl-2::21', xtreamId: 21 };
|
||||
const pending = createDeferred<null>();
|
||||
streamResolver.resolveLiveDetail.mockReturnValue(pending.promise);
|
||||
fixture.componentRef.setInput('items', [first, second]);
|
||||
fixture.detectChanges();
|
||||
const activation = component.onChannelSelected(
|
||||
component.channelsForList()[1]
|
||||
);
|
||||
expect(component.isSelecting()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput(
|
||||
'items',
|
||||
status === 'removed'
|
||||
? [second]
|
||||
: [{ ...first, catalogStatus: 'missing' }, second]
|
||||
);
|
||||
fixture.detectChanges();
|
||||
expect(component.activeDetail()).toBeNull();
|
||||
expect(component.activeItem()).toBeNull();
|
||||
pending.resolve(null);
|
||||
await activation;
|
||||
expect(component.activeDetail()).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
it('keeps the chip out of the panel for a Stalker radio station', async () => {
|
||||
const item = { ...buildLiveItem('stalker'), radio: 'true' };
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
import { StalkerPortalItem } from '@iptvnator/shared/interfaces';
|
||||
import { CollectionContentType } from './unified-collection-item.interface';
|
||||
|
||||
export interface XtreamFavoriteRow {
|
||||
readonly id: number;
|
||||
readonly category_id: number;
|
||||
readonly id: number | null;
|
||||
readonly category_id: number | null;
|
||||
readonly favorite_id?: number;
|
||||
readonly catalog_status?: XtreamFavoriteCatalogStatus;
|
||||
readonly playlist_id: string;
|
||||
readonly playlist_name: string;
|
||||
readonly xtream_id: number;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
/**
|
||||
* Unified interface for displaying favorites or recently-viewed items
|
||||
* from any source (M3U, Xtream, Stalker) in one list.
|
||||
@@ -55,6 +56,9 @@ export interface UnifiedCollectionItem {
|
||||
|
||||
/** Xtream DB content id — used for reorder / remove IPC */
|
||||
contentId?: number;
|
||||
/** Durable favorite identity, usable even after its catalog row is gone. */
|
||||
favoriteId?: number;
|
||||
catalogStatus?: XtreamFavoriteCatalogStatus;
|
||||
|
||||
/** Whether the Xtream provider has timeshift / archive enabled for this stream */
|
||||
tvArchive?: number | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
/**
|
||||
* Unified interface for displaying live TV favorites from any source
|
||||
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
|
||||
@@ -69,6 +70,8 @@ export interface UnifiedFavoriteChannel {
|
||||
|
||||
/** Xtream DB content id — used for REORDER IPC call */
|
||||
contentId?: number;
|
||||
favoriteId?: number;
|
||||
catalogStatus?: XtreamFavoriteCatalogStatus;
|
||||
|
||||
/** Stalker portal credentials needed to resolve the live stream */
|
||||
stalkerPortalUrl?: string;
|
||||
|
||||
@@ -671,6 +671,8 @@ function buildDashboardCollectionDetailItem(
|
||||
posterUrl: item.poster_url ?? null,
|
||||
xtreamId: toOptionalNumber(item.xtream_id),
|
||||
contentId: toOptionalNumber(item.id),
|
||||
favoriteId: item.favorite_id,
|
||||
catalogStatus: item.catalogStatus,
|
||||
categoryId: item.category_id,
|
||||
addedAt: 'added_at' in item ? item.added_at : undefined,
|
||||
viewedAt: 'viewed_at' in item ? item.viewed_at : undefined,
|
||||
@@ -840,6 +842,13 @@ function normalizeCollectionDetailItem(
|
||||
logo: toOptionalPathSegment(record['logo']) ?? null,
|
||||
xtreamId: toOptionalNumber(record['xtreamId']),
|
||||
contentId: toOptionalNumber(record['contentId']),
|
||||
favoriteId: toOptionalNumber(record['favoriteId']),
|
||||
catalogStatus:
|
||||
record['catalogStatus'] === 'missing' ||
|
||||
record['catalogStatus'] === 'unknown' ||
|
||||
record['catalogStatus'] === 'present'
|
||||
? record['catalogStatus']
|
||||
: undefined,
|
||||
stalkerId: toOptionalPathSegment(record['stalkerId']),
|
||||
stalkerCmd: toOptionalPathSegment(record['stalkerCmd']),
|
||||
stalkerPortalUrl: toOptionalPathSegment(record['stalkerPortalUrl']),
|
||||
|
||||
+3
-1
@@ -39,7 +39,8 @@ export function createDbServiceMock() {
|
||||
saveXtreamContent: jest.fn().mockResolvedValue(0),
|
||||
searchXtreamContent: jest.fn().mockResolvedValue([]),
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
addToFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
addToFavorites: jest.fn().mockResolvedValue(true),
|
||||
addToFavoritesOrThrow: jest.fn().mockResolvedValue(undefined),
|
||||
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
isFavorite: jest.fn().mockResolvedValue(false),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
@@ -54,6 +55,7 @@ export function createDbServiceMock() {
|
||||
favorites: [],
|
||||
recentlyViewed: [],
|
||||
}),
|
||||
restoreXtreamFavoriteSnapshots: jest.fn().mockResolvedValue(undefined),
|
||||
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -468,7 +468,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
// Favorites Operations
|
||||
// =========================================================================
|
||||
|
||||
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
|
||||
async getFavorites(
|
||||
playlistId: string
|
||||
): Promise<import('./xtream-data-source.interface').XtreamFavoriteItem[]> {
|
||||
return this.dbService.getFavorites(playlistId);
|
||||
}
|
||||
|
||||
@@ -477,7 +479,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
): Promise<void> {
|
||||
await this.dbService.addToFavorites(
|
||||
await this.dbService.addToFavoritesOrThrow(
|
||||
xtreamCollectionId(contentId),
|
||||
playlistId,
|
||||
backdropUrl
|
||||
@@ -683,6 +685,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
};
|
||||
}
|
||||
|
||||
async restoreFavoriteSnapshots(
|
||||
playlistId: string,
|
||||
state: XtreamPendingRestoreState
|
||||
): Promise<void> {
|
||||
await this.dbService.restoreXtreamFavoriteSnapshots(
|
||||
playlistId,
|
||||
state.favorites,
|
||||
state.favoritesRestoreId
|
||||
);
|
||||
}
|
||||
|
||||
async restoreUserData(
|
||||
playlistId: string,
|
||||
restoreState: XtreamPendingRestoreState,
|
||||
@@ -731,9 +744,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
await this.restoreFavoriteSnapshots(playlistId, restoreState);
|
||||
await this.dbService.restoreXtreamUserData(
|
||||
playlistId,
|
||||
restoreState.favorites,
|
||||
[],
|
||||
restoreState.recentlyViewed,
|
||||
options
|
||||
);
|
||||
|
||||
+16
-7
@@ -43,11 +43,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
|
||||
playlistId,
|
||||
{ backdropUrl: 'backdrop.jpg' }
|
||||
);
|
||||
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
|
||||
202,
|
||||
playlistId,
|
||||
'backdrop.jpg'
|
||||
);
|
||||
expect(
|
||||
harness.dbService.addToFavoritesOrThrow
|
||||
).toHaveBeenCalledWith(202, playlistId, 'backdrop.jpg');
|
||||
expect(
|
||||
harness.dbService.removeFromFavorites
|
||||
).toHaveBeenCalledWith(202, playlistId);
|
||||
@@ -70,6 +68,15 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
|
||||
}
|
||||
);
|
||||
|
||||
it('propagates a failed favorite write instead of acknowledging it', async () => {
|
||||
harness.dbService.addToFavoritesOrThrow.mockRejectedValue(
|
||||
new Error('XTREAM_FAVORITE_IDENTITY_CONFLICT')
|
||||
);
|
||||
await expect(
|
||||
harness.dataSource.addFavorite(202, playlistId)
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('delegates favorites operations to the DB', async () => {
|
||||
harness.dbService.isFavorite.mockResolvedValue(true);
|
||||
|
||||
@@ -78,7 +85,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
|
||||
playlistId,
|
||||
'https://example.com/backdrop.png'
|
||||
);
|
||||
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
|
||||
expect(
|
||||
harness.dbService.addToFavoritesOrThrow
|
||||
).toHaveBeenCalledWith(
|
||||
202,
|
||||
playlistId,
|
||||
'https://example.com/backdrop.png'
|
||||
@@ -442,7 +451,7 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
|
||||
harness.dbService.restoreXtreamUserData
|
||||
).toHaveBeenCalledWith(
|
||||
playlistId,
|
||||
[{ xtreamId: 202, type: 'movie' }],
|
||||
[],
|
||||
[{ xtreamId: 101, type: 'live' }],
|
||||
options
|
||||
);
|
||||
|
||||
+17
-1
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import {
|
||||
ContentMetadataPatch,
|
||||
@@ -92,6 +93,16 @@ export interface XtreamContentItem {
|
||||
playlist_name?: string;
|
||||
}
|
||||
|
||||
export interface XtreamFavoriteItem extends Omit<
|
||||
XtreamContentItem,
|
||||
'id' | 'category_id'
|
||||
> {
|
||||
id: number | null;
|
||||
category_id: number | string | null;
|
||||
favorite_id?: number;
|
||||
catalog_status?: XtreamFavoriteCatalogStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category from the database with additional fields
|
||||
*/
|
||||
@@ -332,7 +343,7 @@ export interface IXtreamDataSource {
|
||||
/**
|
||||
* Get all favorites for a playlist
|
||||
*/
|
||||
getFavorites(playlistId: string): Promise<XtreamContentItem[]>;
|
||||
getFavorites(playlistId: string): Promise<XtreamFavoriteItem[]>;
|
||||
|
||||
/**
|
||||
* Add content to favorites.
|
||||
@@ -512,6 +523,11 @@ export interface IXtreamDataSource {
|
||||
/**
|
||||
* Restore user data after refresh
|
||||
*/
|
||||
restoreFavoriteSnapshots?(
|
||||
playlistId: string,
|
||||
restoreState: XtreamPendingRestoreState
|
||||
): Promise<void>;
|
||||
|
||||
restoreUserData(
|
||||
playlistId: string,
|
||||
restoreState: XtreamPendingRestoreState,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
|
||||
export interface FavoriteItem {
|
||||
content_id: number;
|
||||
content_id: number | null;
|
||||
favorite_id?: number;
|
||||
catalog_status?: XtreamFavoriteCatalogStatus;
|
||||
playlist_id: string;
|
||||
type: 'live' | 'movie' | 'vod' | 'series';
|
||||
title: string;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '../data-sources/xtream-data-source.interface';
|
||||
import { FavoriteItem } from './favorite-item.interface';
|
||||
|
||||
function normalizeCategoryId(categoryId: string | number): number {
|
||||
function normalizeCategoryId(categoryId: string | number | null): number {
|
||||
const numericCategoryId = Number(categoryId);
|
||||
return Number.isFinite(numericCategoryId) ? numericCategoryId : 0;
|
||||
}
|
||||
@@ -48,6 +48,8 @@ export class FavoritesService {
|
||||
map((items) =>
|
||||
items.map((item) => ({
|
||||
content_id: item.id,
|
||||
favorite_id: item.favorite_id,
|
||||
catalog_status: item.catalog_status,
|
||||
playlist_id: playlistId,
|
||||
type: item.type as FavoriteItem['type'],
|
||||
title: item.title,
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
createContentTestStore,
|
||||
createContentTestProviders,
|
||||
createPendingRestoreServiceMock,
|
||||
PENDING_RESTORE_STATE,
|
||||
TEST_PLAYLIST as PLAYLIST,
|
||||
} from './with-content.feature.spec-helpers';
|
||||
|
||||
jest.mock('@iptvnator/portal/shared/util', () => ({
|
||||
createLogger: () => ({
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
const TestStore = createContentTestStore(async () => 'active');
|
||||
describe('withContent durable favorite conversion', () => {
|
||||
let store: InstanceType<typeof TestStore>;
|
||||
let dataSource: {
|
||||
hasCategories: jest.Mock;
|
||||
getCategories: jest.Mock;
|
||||
getContent: jest.Mock;
|
||||
restoreFavoriteSnapshots: jest.Mock;
|
||||
};
|
||||
let pendingRestoreService: ReturnType<
|
||||
typeof createPendingRestoreServiceMock
|
||||
>;
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
dataSource = {
|
||||
hasCategories: jest.fn().mockResolvedValue(false),
|
||||
getCategories: jest.fn().mockResolvedValue([]),
|
||||
getContent: jest.fn().mockResolvedValue([]),
|
||||
restoreFavoriteSnapshots: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
pendingRestoreService = createPendingRestoreServiceMock();
|
||||
TestBed.configureTestingModule({
|
||||
providers: createContentTestProviders(TestStore, {
|
||||
dataSource,
|
||||
pendingRestoreService,
|
||||
dataService: { sendIpcEvent: jest.fn() },
|
||||
xtreamApiService: { cancelSession: jest.fn() },
|
||||
databaseService: {
|
||||
createOperationId: jest.fn(() => 'import-1'),
|
||||
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
|
||||
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
|
||||
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
}),
|
||||
});
|
||||
store = TestBed.inject(TestStore);
|
||||
});
|
||||
afterEach(() => localStorage.clear());
|
||||
it('converts legacy favorites before fetching a catalog even when the network fails', async () => {
|
||||
pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE);
|
||||
dataSource.hasCategories.mockResolvedValue(false);
|
||||
dataSource.getCategories.mockRejectedValue(new Error('Server offline'));
|
||||
await store.initializeContent();
|
||||
expect(dataSource.restoreFavoriteSnapshots).toHaveBeenCalledWith(
|
||||
PLAYLIST.id,
|
||||
PENDING_RESTORE_STATE
|
||||
);
|
||||
expect(
|
||||
dataSource.restoreFavoriteSnapshots.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(dataSource.getCategories.mock.invocationCallOrder[0]);
|
||||
expect(pendingRestoreService.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops before catalog requests when saved favorites cannot be persisted', async () => {
|
||||
pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE);
|
||||
dataSource.restoreFavoriteSnapshots.mockRejectedValue(
|
||||
new Error('SQLite unavailable')
|
||||
);
|
||||
await store.initializeContent();
|
||||
expect(dataSource.getCategories).not.toHaveBeenCalled();
|
||||
expect(dataSource.getContent).not.toHaveBeenCalled();
|
||||
expect(pendingRestoreService.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -959,6 +959,16 @@ export function withContent() {
|
||||
initialRestoreSnapshot !== null,
|
||||
});
|
||||
|
||||
// Legacy favorites become durable before any network request,
|
||||
// even if the provider stays offline through this import.
|
||||
if (initialRestoreSnapshot) {
|
||||
await dataSource.restoreFavoriteSnapshots?.(
|
||||
ctx.playlistId,
|
||||
initialRestoreSnapshot.state
|
||||
);
|
||||
throwIfImportCancelled(importSessionId);
|
||||
}
|
||||
|
||||
// Electron content persistence maps remote category IDs
|
||||
// to internal DB category rows, so categories must exist
|
||||
// before content import starts.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import {
|
||||
DataService,
|
||||
DatabaseService,
|
||||
@@ -75,6 +77,11 @@ function configureStore(
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
XtreamStore,
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
},
|
||||
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
|
||||
{ provide: XtreamApiService, useValue: xtreamApiService },
|
||||
{ provide: DatabaseService, useValue: {} },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { patchState, signalStore } from '@ngrx/signals';
|
||||
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
|
||||
@@ -20,6 +22,8 @@ const TestFavoritesStore = signalStore(
|
||||
|
||||
describe('withFavorites', () => {
|
||||
const originalElectron = window.electron;
|
||||
const snackBar = { open: jest.fn() };
|
||||
const translate = { instant: jest.fn((key: string) => key) };
|
||||
const runtime = { supportsXtreamSqliteDataSource: false };
|
||||
let store: InstanceType<typeof TestFavoritesStore>;
|
||||
let dataSource: {
|
||||
@@ -30,6 +34,7 @@ describe('withFavorites', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
snackBar.open.mockClear();
|
||||
runtime.supportsXtreamSqliteDataSource = false;
|
||||
dataSource = {
|
||||
addFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -41,6 +46,8 @@ describe('withFavorites', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TestFavoritesStore,
|
||||
{ provide: MatSnackBar, useValue: snackBar },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
{ provide: RuntimeCapabilitiesService, useValue: runtime },
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
@@ -60,6 +67,27 @@ describe('withFavorites', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'XTREAM_FAVORITE_IDENTITY_CONFLICT',
|
||||
'PORTALS.FAVORITE_IDENTITY_CONFLICT',
|
||||
],
|
||||
['write failed', 'PORTALS.FAVORITE_SAVE_FAILED'],
|
||||
])(
|
||||
'keeps the heart inactive and explains rejected saves: %s',
|
||||
async (message, key) => {
|
||||
dataSource.getContentByXtreamId.mockResolvedValue({ id: 42 });
|
||||
dataSource.addFavorite.mockRejectedValue(new Error(message));
|
||||
await expect(
|
||||
store.toggleFavorite(7, 'playlist-1', 'movie')
|
||||
).resolves.toBe(false);
|
||||
expect(store.isFavorite()).toBe(false);
|
||||
expect(snackBar.open).toHaveBeenCalledWith(key, undefined, {
|
||||
duration: 6000,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
'toggles the typed target using its persisted status %s rather than the previous detail state',
|
||||
async (persistedStatus) => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { inject } from '@angular/core';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
patchState,
|
||||
signalStoreFeature,
|
||||
@@ -19,7 +22,9 @@ export const withFavorites = function () {
|
||||
(
|
||||
store,
|
||||
dataSource = inject(XTREAM_DATA_SOURCE),
|
||||
runtime = inject(RuntimeCapabilitiesService)
|
||||
runtime = inject(RuntimeCapabilitiesService),
|
||||
snackBar = inject(MatSnackBar),
|
||||
translate = inject(TranslateService)
|
||||
) => ({
|
||||
async toggleFavorite(
|
||||
xtreamId: number | string,
|
||||
@@ -70,11 +75,25 @@ export const withFavorites = function () {
|
||||
return false;
|
||||
} else {
|
||||
// Add to favorites
|
||||
await dataSource.addFavorite(
|
||||
{ id: contentId, type: contentType },
|
||||
playlistId,
|
||||
backdropUrl
|
||||
);
|
||||
try {
|
||||
await dataSource.addFavorite(
|
||||
{ id: contentId, type: contentType },
|
||||
playlistId,
|
||||
backdropUrl
|
||||
);
|
||||
} catch (error) {
|
||||
patchState(store, { isFavorite: false });
|
||||
const key =
|
||||
error instanceof Error &&
|
||||
error.message ===
|
||||
XTREAM_FAVORITE_IDENTITY_CONFLICT
|
||||
? 'PORTALS.FAVORITE_IDENTITY_CONFLICT'
|
||||
: 'PORTALS.FAVORITE_SAVE_FAILED';
|
||||
snackBar.open(translate.instant(key), undefined, {
|
||||
duration: 6000,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
patchState(store, { isFavorite: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('DatabaseService browser guards', () => {
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
@@ -19,6 +20,7 @@ describe('DatabaseService browser guards', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
consoleErrorSpy.mockRestore();
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
@@ -27,6 +29,92 @@ describe('DatabaseService browser guards', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not report success when the favorites worker rejects a conflicting identity', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dbAddFavorite: jest.fn().mockResolvedValue({
|
||||
success: false,
|
||||
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
|
||||
}),
|
||||
},
|
||||
});
|
||||
await expect(service.addToFavorites(42, 'playlist-1')).resolves.toBe(
|
||||
false
|
||||
);
|
||||
await expect(
|
||||
service.addToFavoritesOrThrow(42, 'playlist-1')
|
||||
).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT');
|
||||
});
|
||||
|
||||
it('recovers legacy favorites on collection reads without a portal status or catalog request', async () => {
|
||||
const pending = {
|
||||
favorites: [{ contentType: 'movie', xtreamId: 7 }],
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
};
|
||||
localStorage.setItem(
|
||||
'xtream-restore-playlist-1',
|
||||
JSON.stringify(pending)
|
||||
);
|
||||
const bridge = {
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ success: true }),
|
||||
dbGetFavorites: jest.fn().mockResolvedValue([]),
|
||||
dbGetAllGlobalFavorites: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: bridge,
|
||||
});
|
||||
await service.getFavorites('playlist-1');
|
||||
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
|
||||
'playlist-1',
|
||||
pending.favorites,
|
||||
undefined
|
||||
);
|
||||
expect(
|
||||
bridge.dbRestoreXtreamFavorites.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(bridge.dbGetFavorites.mock.invocationCallOrder[0]);
|
||||
await service.getAllGlobalFavorites();
|
||||
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledTimes(2);
|
||||
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(
|
||||
JSON.stringify(pending)
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
|
||||
localStorage.setItem(
|
||||
'xtream-restore-orphan',
|
||||
JSON.stringify({
|
||||
favorites: [{ contentType: 'movie', xtreamId: 7 }],
|
||||
})
|
||||
);
|
||||
const bridge = {
|
||||
dbGetPlaylist: jest.fn().mockResolvedValue(null),
|
||||
dbRestoreXtreamFavorites: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('SQLite unavailable')),
|
||||
dbGetFavoritesForBackup: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: bridge,
|
||||
});
|
||||
await expect(service.getFavoritesOrThrow('orphan')).resolves.toEqual(
|
||||
[]
|
||||
);
|
||||
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
|
||||
bridge.dbGetPlaylist.mockResolvedValue({ type: 'xtream' });
|
||||
await expect(service.getFavoritesOrThrow('orphan')).rejects.toThrow(
|
||||
'SQLite unavailable'
|
||||
);
|
||||
expect(bridge.dbGetFavoritesForBackup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats app state persistence as unavailable without the Electron bridge', async () => {
|
||||
await expect(service.getAppState('xtream-key')).resolves.toBeNull();
|
||||
await expect(
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
|
||||
import type {
|
||||
ElectronBridgeXtreamFavorite,
|
||||
XtreamFavoriteRemovalRef,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
/**
|
||||
* Database service for Electron renderer process
|
||||
* Communicates with the main process database via IPC
|
||||
@@ -100,7 +105,9 @@ export interface GlobalRecentItem extends XtreamContent {
|
||||
viewed_at: string;
|
||||
}
|
||||
|
||||
export interface GlobalFavoriteItem extends XtreamContent {
|
||||
export type XtreamFavorite = ElectronBridgeXtreamFavorite;
|
||||
|
||||
export interface GlobalFavoriteItem extends XtreamFavorite {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
added_at: string;
|
||||
@@ -288,8 +295,23 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore favorites and recently viewed items after Xtream refresh
|
||||
* Materialize saved favorites without requiring the provider catalog.
|
||||
*/
|
||||
async restoreXtreamFavoriteSnapshots(
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
restoreId?: string
|
||||
): Promise<void> {
|
||||
if (favorites.length === 0) return;
|
||||
const result = await window.electron.dbRestoreXtreamFavorites(
|
||||
playlistId,
|
||||
favorites,
|
||||
restoreId
|
||||
);
|
||||
if (!result.success)
|
||||
throw new Error('Restoring saved favorites failed.');
|
||||
}
|
||||
|
||||
async restoreXtreamUserData(
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
@@ -719,6 +741,7 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
await materializePendingXtreamFavorites(window.electron);
|
||||
const items = await window.electron.dbGetGlobalFavorites();
|
||||
return items || [];
|
||||
} catch (error) {
|
||||
@@ -736,6 +759,7 @@ export class DatabaseService {
|
||||
}
|
||||
|
||||
try {
|
||||
await materializePendingXtreamFavorites(window.electron);
|
||||
const items = await window.electron.dbGetAllGlobalFavorites();
|
||||
return items || [];
|
||||
} catch (error) {
|
||||
@@ -795,9 +819,8 @@ export class DatabaseService {
|
||||
if (typeof window.electron?.dbAddFavorite !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.electron.dbAddFavorite(
|
||||
await this.addToFavoritesOrThrow(
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl
|
||||
@@ -809,11 +832,30 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/** User-triggered saves must expose a rejected write to the detail toggle. */
|
||||
async addToFavoritesOrThrow(
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
): Promise<void> {
|
||||
if (typeof window.electron?.dbAddFavorite !== 'function') {
|
||||
throw new Error('Favorite storage is unavailable');
|
||||
}
|
||||
const result = await window.electron.dbAddFavorite(
|
||||
contentId,
|
||||
playlistId,
|
||||
backdropUrl
|
||||
);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? 'Favorite could not be saved');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove content from favorites
|
||||
*/
|
||||
async removeFromFavorites(
|
||||
contentId: number,
|
||||
contentId: XtreamFavoriteRemovalRef,
|
||||
playlistId: string
|
||||
): Promise<boolean> {
|
||||
if (typeof window.electron?.dbRemoveFavorite !== 'function') {
|
||||
@@ -845,15 +887,22 @@ export class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all favorites for a playlist
|
||||
*/
|
||||
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
|
||||
/** Backup reads must fail rather than export an empty list after an IPC error. */
|
||||
async getFavoritesOrThrow(playlistId: string): Promise<XtreamFavorite[]> {
|
||||
await materializePendingXtreamFavorites(window.electron, playlistId);
|
||||
return window.electron.dbGetFavoritesForBackup(playlistId);
|
||||
}
|
||||
|
||||
async getFavorites(playlistId: string): Promise<XtreamFavorite[]> {
|
||||
if (typeof window.electron?.dbGetFavorites !== 'function') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
await materializePendingXtreamFavorites(
|
||||
window.electron,
|
||||
playlistId
|
||||
);
|
||||
return await window.electron.dbGetFavorites(playlistId);
|
||||
} catch (error) {
|
||||
console.error('Error getting favorites:', error);
|
||||
|
||||
@@ -209,12 +209,8 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
const snapshot =
|
||||
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1');
|
||||
expect(snapshot).not.toBeNull();
|
||||
// Pending state must remain exportable even while catalog/storage
|
||||
// reads are unavailable; it is already the complete restore input.
|
||||
jest.spyOn(
|
||||
collaborators.databaseService,
|
||||
'getFavorites'
|
||||
).mockRejectedValue(new Error('Catalog unavailable'));
|
||||
// Durable favorites remain readable independently of the catalog;
|
||||
// other pending state does not require a completed import.
|
||||
jest.spyOn(
|
||||
collaborators.playbackPositionService,
|
||||
'getAllPlaybackPositionsOrThrow'
|
||||
@@ -230,6 +226,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
'honors empty pending collections and optional source pins (present: %s)',
|
||||
async (pinsPresent) => {
|
||||
const state = seedState();
|
||||
state.xtreamFavorites = [];
|
||||
const collaborators = createStatefulBackupCollaborators(state);
|
||||
const pending: XtreamPendingRestoreState = {
|
||||
hiddenCategories: [],
|
||||
@@ -259,6 +256,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
|
||||
it('keeps absent pending pins absent when the pin store is unavailable', async () => {
|
||||
const state = seedState();
|
||||
state.xtreamFavorites = [];
|
||||
const collaborators = createStatefulBackupCollaborators(state);
|
||||
collaborators.vodSourcePinService.isAvailable = false;
|
||||
const pending: XtreamPendingRestoreState = {
|
||||
@@ -273,6 +271,91 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
expect(xtreamEntry(exported.manifest).userState).toEqual(pending);
|
||||
});
|
||||
|
||||
it('preserves older pending favorites before merging another archive', async () => {
|
||||
const state = seedState();
|
||||
const collaborators = createStatefulBackupCollaborators(state);
|
||||
const service = createPlaylistBackupService(collaborators);
|
||||
const archive = await service.exportBackup();
|
||||
state.xtreamFavorites = [];
|
||||
collaborators.pendingRestoreService.set('xtream-1', {
|
||||
favorites: [
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 999,
|
||||
snapshot: { title: 'Older saved ABC' },
|
||||
},
|
||||
],
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
});
|
||||
expect((await service.importBackup(archive.json)).failed).toBe(0);
|
||||
expect(
|
||||
state.xtreamFavorites.map((row) => row.xtream_id).sort()
|
||||
).toEqual([501, 999]);
|
||||
expect(
|
||||
state.xtreamFavorites.find((row) => row.xtream_id === 999)?.title
|
||||
).toBe('Older saved ABC');
|
||||
});
|
||||
|
||||
it('restores and re-exports saved titles while the catalog is offline', async () => {
|
||||
const state = seedState();
|
||||
state.xtreamFavorites = [
|
||||
{
|
||||
xtream_id: 501,
|
||||
type: 'movie',
|
||||
title: 'Saved ABC',
|
||||
poster_url: 'https://example.test/abc.jpg',
|
||||
position: 3,
|
||||
},
|
||||
];
|
||||
const collaborators = createStatefulBackupCollaborators(state);
|
||||
const service = createPlaylistBackupService(collaborators);
|
||||
const backup = await service.exportBackup();
|
||||
state.xtreamFavorites = [];
|
||||
state.xtreamCategories = [];
|
||||
collaborators.databaseService.getXtreamImportStatus = async () =>
|
||||
'idle';
|
||||
expect((await service.importBackup(backup.json)).failed).toBe(0);
|
||||
expect(state.xtreamFavorites).toEqual([
|
||||
{
|
||||
xtream_id: 501,
|
||||
type: 'movie',
|
||||
title: 'Saved ABC',
|
||||
poster_url: 'https://example.test/abc.jpg',
|
||||
position: 3,
|
||||
},
|
||||
]);
|
||||
const exported = await service.exportBackup();
|
||||
expect(xtreamEntry(exported.manifest).userState.favorites).toEqual(
|
||||
xtreamEntry(backup.manifest).userState.favorites
|
||||
);
|
||||
expect(xtreamEntry(exported.manifest).userState).not.toHaveProperty(
|
||||
'favoritesRestoreId'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to export an empty favorites list after a storage read failure, including during pending import', async () => {
|
||||
const collaborators = createStatefulBackupCollaborators(seedState());
|
||||
const service = createPlaylistBackupService(collaborators);
|
||||
jest.spyOn(
|
||||
collaborators.databaseService,
|
||||
'getFavoritesOrThrow'
|
||||
).mockRejectedValue(new Error('Favorites read failed'));
|
||||
await expect(service.exportBackup()).rejects.toThrow(
|
||||
'Favorites read failed'
|
||||
);
|
||||
collaborators.pendingRestoreService.set('xtream-1', {
|
||||
favorites: [],
|
||||
hiddenCategories: [],
|
||||
recentlyViewed: [],
|
||||
playbackPositions: [],
|
||||
});
|
||||
await expect(service.exportBackup()).rejects.toThrow(
|
||||
'Favorites read failed'
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to export if pending storage cannot be read', async () => {
|
||||
const collaborators = createStatefulBackupCollaborators(seedState());
|
||||
jest.spyOn(
|
||||
@@ -347,7 +430,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
expect(pins).not.toHaveBeenCalled();
|
||||
expect(
|
||||
collaborators.pendingRestoreService.getOrThrow('xtream-1')
|
||||
).toEqual(userState);
|
||||
).toEqual({ ...userState, favoritesRestoreId: expect.any(String) });
|
||||
const parkedExport = await service.exportBackup();
|
||||
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
|
||||
userState
|
||||
|
||||
@@ -119,11 +119,14 @@ export function createPlaylistBackupService(
|
||||
},
|
||||
databaseService: {
|
||||
getAllXtreamCategories: jest.fn().mockResolvedValue([]),
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
|
||||
hasXtreamCategories: jest.fn().mockResolvedValue(false),
|
||||
hasXtreamContent: jest.fn().mockResolvedValue(false),
|
||||
restoreXtreamFavoriteSnapshots: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
|
||||
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
@@ -178,6 +181,8 @@ export interface FakeXtreamContentRow {
|
||||
added_at?: string;
|
||||
position?: number | null;
|
||||
viewed_at?: string;
|
||||
title?: string;
|
||||
poster_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +211,7 @@ export function createStatefulBackupCollaborators(
|
||||
state: FakeBackupBackendState
|
||||
) {
|
||||
let lastParsedRawM3u: string | null = null;
|
||||
const favoriteReceipts = new Set<string>();
|
||||
|
||||
return {
|
||||
playlistsService: {
|
||||
@@ -270,7 +276,7 @@ export function createStatefulBackupCollaborators(
|
||||
row.playlist_id === playlistId && row.type === type
|
||||
)
|
||||
.map((row) => ({ ...row })),
|
||||
getFavorites: async () =>
|
||||
getFavoritesOrThrow: async () =>
|
||||
state.xtreamFavorites.map((row) => ({ ...row })),
|
||||
getRecentItems: async () =>
|
||||
state.xtreamRecent.map((row) => ({ ...row })),
|
||||
@@ -289,21 +295,47 @@ export function createStatefulBackupCollaborators(
|
||||
|
||||
return true;
|
||||
},
|
||||
restoreXtreamUserData: async (
|
||||
restoreXtreamFavoriteSnapshots: async (
|
||||
_playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
restoreId?: string
|
||||
) => {
|
||||
const receipt = restoreId ?? JSON.stringify(favorites);
|
||||
if (!favorites.length || favoriteReceipts.has(receipt)) return;
|
||||
favoriteReceipts.add(receipt);
|
||||
const additions = favorites.filter(
|
||||
(item) =>
|
||||
!state.xtreamFavorites.some(
|
||||
(row) =>
|
||||
row.xtream_id === item.xtreamId &&
|
||||
row.type === item.contentType
|
||||
)
|
||||
);
|
||||
state.xtreamFavorites.push(
|
||||
...additions.map((item) => ({
|
||||
xtream_id: item.xtreamId,
|
||||
type: item.contentType,
|
||||
...(item.snapshot
|
||||
? {
|
||||
title: item.snapshot.title,
|
||||
poster_url:
|
||||
item.snapshot.posterUrl ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
...(item.addedAt !== undefined
|
||||
? { added_at: item.addedAt }
|
||||
: {}),
|
||||
...(item.position !== undefined
|
||||
? { position: item.position }
|
||||
: {}),
|
||||
}))
|
||||
);
|
||||
},
|
||||
restoreXtreamUserData: async (
|
||||
_playlistId: string,
|
||||
_favorites: XtreamBackupFavoriteItem[],
|
||||
recentlyViewed: XtreamBackupRecentlyViewedItem[]
|
||||
) => {
|
||||
state.xtreamFavorites = favorites.map((item) => ({
|
||||
xtream_id: item.xtreamId,
|
||||
type: item.contentType,
|
||||
...(item.addedAt !== undefined
|
||||
? { added_at: item.addedAt }
|
||||
: {}),
|
||||
...(item.position !== undefined
|
||||
? { position: item.position }
|
||||
: {}),
|
||||
}));
|
||||
state.xtreamRecent = recentlyViewed.map((item) => ({
|
||||
xtream_id: item.xtreamId,
|
||||
type: item.contentType,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { PlaylistsService } from './playlists.service';
|
||||
import { SettingsStore } from './settings-store.service';
|
||||
import { toXtreamBackupFavorite } from './xtream-favorite-backup';
|
||||
import { DatabaseService } from './database-electron.service';
|
||||
import { VodSourcePinService } from './vod-source-pin.service';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
@@ -318,15 +319,30 @@ export class PlaylistBackupService {
|
||||
};
|
||||
const pending = this.pendingRestoreService.getOrThrow(playlist._id);
|
||||
if (pending) {
|
||||
// A parked import is authoritative, including empty collections.
|
||||
// Reading the incomplete catalog instead would silently lose it.
|
||||
// Other parked user state is authoritative during an import. Favorites
|
||||
// already have a durable owner: materialize old parked identities once,
|
||||
// then export the current saved records (including explicit removals).
|
||||
let favorites = pending.favorites;
|
||||
if (this.runtime.supportsXtreamSqliteDataSource) {
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
playlist._id,
|
||||
pending.favorites,
|
||||
pending.favoritesRestoreId
|
||||
);
|
||||
favorites = (
|
||||
await this.databaseService.getFavoritesOrThrow(playlist._id)
|
||||
).map(toXtreamBackupFavorite);
|
||||
}
|
||||
const sourcePins =
|
||||
pending.sourcePins ??
|
||||
(await this.readSourcePinsForExport(playlist._id));
|
||||
return {
|
||||
...entry,
|
||||
userState: {
|
||||
...pending,
|
||||
hiddenCategories: pending.hiddenCategories,
|
||||
favorites,
|
||||
recentlyViewed: pending.recentlyViewed,
|
||||
playbackPositions: pending.playbackPositions,
|
||||
...this.optionalLockedXtreamCategories(playlist._id),
|
||||
...(sourcePins ? { sourcePins } : {}),
|
||||
},
|
||||
@@ -349,7 +365,7 @@ export class PlaylistBackupService {
|
||||
this.databaseService.getAllXtreamCategories(playlist._id, 'live'),
|
||||
this.databaseService.getAllXtreamCategories(playlist._id, 'movies'),
|
||||
this.databaseService.getAllXtreamCategories(playlist._id, 'series'),
|
||||
this.databaseService.getFavorites(playlist._id),
|
||||
this.databaseService.getFavoritesOrThrow(playlist._id),
|
||||
this.databaseService.getRecentItems(playlist._id),
|
||||
this.playbackPositionService.getAllPlaybackPositionsOrThrow(
|
||||
playlist._id
|
||||
@@ -371,14 +387,7 @@ export class PlaylistBackupService {
|
||||
...this.mapHiddenCategories(seriesCategories, 'series'),
|
||||
],
|
||||
...this.optionalLockedXtreamCategories(playlist._id),
|
||||
favorites: favorites.map((item) => ({
|
||||
xtreamId: item.xtream_id,
|
||||
contentType: item.type as XtreamBackupContentType,
|
||||
...(item.added_at ? { addedAt: item.added_at } : {}),
|
||||
...(item.position !== undefined
|
||||
? { position: item.position }
|
||||
: {}),
|
||||
})),
|
||||
favorites: favorites.map(toXtreamBackupFavorite),
|
||||
recentlyViewed: recent.map((item) => ({
|
||||
xtreamId: item.xtream_id,
|
||||
contentType: item.type as XtreamBackupContentType,
|
||||
@@ -925,7 +934,20 @@ export class PlaylistBackupService {
|
||||
// arbitrary categories during restore.
|
||||
const restoreState: XtreamPendingRestoreState =
|
||||
normalizeXtreamPendingRestoreState(entry.userState);
|
||||
// A deliberate re-import is a fresh user action. Do not accept an archive's
|
||||
// internal receipt or confuse it with a retry of an older parked import.
|
||||
restoreState.favoritesRestoreId = createRandomId();
|
||||
|
||||
// Preserve an older unfinished import before parking the incoming backup.
|
||||
// Canonical favorites are additive; legacy-only ones need the same protection.
|
||||
const previous = this.pendingRestoreService.getOrThrow(playlistId);
|
||||
if (previous && this.runtime.supportsXtreamSqliteDataSource) {
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
playlistId,
|
||||
previous.favorites,
|
||||
previous.favoritesRestoreId
|
||||
);
|
||||
}
|
||||
const restoreSnapshot = this.pendingRestoreService.set(
|
||||
playlistId,
|
||||
restoreState
|
||||
@@ -939,6 +961,11 @@ export class PlaylistBackupService {
|
||||
// Browser storage has no catalog cache to wait for: apply at once.
|
||||
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
|
||||
if (sqlite) {
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
playlistId,
|
||||
restoreState.favorites,
|
||||
restoreState.favoritesRestoreId
|
||||
);
|
||||
this.playbackPositionService.assertSupportsAtomicReplacement();
|
||||
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
|
||||
}
|
||||
@@ -1007,9 +1034,14 @@ export class PlaylistBackupService {
|
||||
): Promise<void> {
|
||||
this.playbackPositionService.assertSupportsAtomicReplacement();
|
||||
await this.restoreXtreamCategoryVisibility(playlistId, state);
|
||||
await this.databaseService.restoreXtreamUserData(
|
||||
await this.databaseService.restoreXtreamFavoriteSnapshots(
|
||||
playlistId,
|
||||
state.favorites,
|
||||
state.favoritesRestoreId
|
||||
);
|
||||
await this.databaseService.restoreXtreamUserData(
|
||||
playlistId,
|
||||
[],
|
||||
state.recentlyViewed
|
||||
);
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ export function createRestoreCollaborators() {
|
||||
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
|
||||
let lastConsumedRevision: number | null = null;
|
||||
const pendingRestoreService = {
|
||||
getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null),
|
||||
set: jest.fn(
|
||||
(
|
||||
playlistId: string,
|
||||
@@ -125,18 +126,13 @@ export function createRestoreCollaborators() {
|
||||
? 'consumed'
|
||||
: 'superseded';
|
||||
}
|
||||
if (
|
||||
pendingSnapshot.revision !== expectedSnapshot.revision
|
||||
) {
|
||||
if (pendingSnapshot.revision !== expectedSnapshot.revision) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
await apply(pendingSnapshot.state);
|
||||
if (
|
||||
!pendingRestoreService.clear(
|
||||
playlistId,
|
||||
pendingSnapshot.state
|
||||
)
|
||||
!pendingRestoreService.clear(playlistId, pendingSnapshot.state)
|
||||
) {
|
||||
return 'consume-failed';
|
||||
}
|
||||
@@ -158,11 +154,14 @@ export function createRestoreCollaborators() {
|
||||
(_playlistId: string, type: string) =>
|
||||
Promise.resolve(categoryRowsByType[type] ?? [])
|
||||
),
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
|
||||
hasXtreamCategories: jest.fn().mockResolvedValue(true),
|
||||
hasXtreamContent: jest.fn().mockResolvedValue(true),
|
||||
restoreXtreamFavoriteSnapshots: jest
|
||||
.fn()
|
||||
.mockResolvedValue(undefined),
|
||||
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
|
||||
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
onPlaybackPositionUpdate: jest.fn(),
|
||||
dbDeleteXtreamContent: jest.fn(),
|
||||
dbRestoreXtreamUserData: jest.fn(),
|
||||
dbRestoreXtreamFavorites: jest.fn(),
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
downloadsStart: jest.fn(),
|
||||
downloadsCancel: jest.fn(),
|
||||
@@ -196,6 +197,13 @@ describe('RuntimeCapabilitiesService', () => {
|
||||
expect(service.supportsEpgDataManagement).toBe(true);
|
||||
expect(service.supportsEpgGuide).toBe(true);
|
||||
expect(service.supportsEpgProgramSearch).toBe(true);
|
||||
for (const method of ['dbGetPlaylist', 'dbRestoreXtreamFavorites']) {
|
||||
const implementation = testWindow.electron[method];
|
||||
delete testWindow.electron[method];
|
||||
expect(service.supportsPortalActivityStorage).toBe(false);
|
||||
testWindow.electron[method] = implementation;
|
||||
expect(service.supportsPortalActivityStorage).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
|
||||
@@ -661,6 +669,7 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
|
||||
dbRemoveFavorite: jest.fn(),
|
||||
dbRemoveRecentItem: jest.fn(),
|
||||
dbRestoreXtreamUserData: jest.fn(),
|
||||
dbRestoreXtreamFavorites: jest.fn(),
|
||||
dbReplaceAllPlaybackPositions: jest.fn(),
|
||||
dbSaveCategories: jest.fn(),
|
||||
dbSaveContent: jest.fn(),
|
||||
|
||||
@@ -223,6 +223,7 @@ export class RuntimeCapabilitiesService {
|
||||
...playbackPositionStorageMethods,
|
||||
'dbDeleteXtreamContent',
|
||||
'dbRestoreXtreamUserData',
|
||||
'dbRestoreXtreamFavorites',
|
||||
].every((methodName) => this.hasElectronMethod(methodName));
|
||||
}
|
||||
|
||||
@@ -274,6 +275,8 @@ export class RuntimeCapabilitiesService {
|
||||
|
||||
get supportsPortalActivityStorage(): boolean {
|
||||
return [
|
||||
'dbGetPlaylist',
|
||||
'dbRestoreXtreamFavorites',
|
||||
'dbGetRecentlyViewed',
|
||||
'dbClearRecentlyViewed',
|
||||
'dbGetAllGlobalFavorites',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
ElectronBridgeXtreamFavorite,
|
||||
XtreamBackupFavoriteItem,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Export the saved display snapshot, including records without catalog rows. */
|
||||
export function toXtreamBackupFavorite(
|
||||
item: ElectronBridgeXtreamFavorite
|
||||
): XtreamBackupFavoriteItem {
|
||||
return {
|
||||
xtreamId: item.xtream_id,
|
||||
contentType: item.type as XtreamBackupFavoriteItem['contentType'],
|
||||
...(item.added_at ? { addedAt: item.added_at } : {}),
|
||||
...(item.position !== undefined ? { position: item.position } : {}),
|
||||
...(item.title
|
||||
? {
|
||||
snapshot: {
|
||||
title: item.title,
|
||||
...(item.poster_url != null
|
||||
? { posterUrl: item.poster_url }
|
||||
: {}),
|
||||
...(item.backdrop_url != null
|
||||
? { backdropUrl: item.backdrop_url }
|
||||
: {}),
|
||||
...(item.tmdb_id != null ? { tmdbId: item.tmdb_id } : {}),
|
||||
...(item.release_year != null
|
||||
? { releaseYear: item.release_year }
|
||||
: {}),
|
||||
...(item.original_title != null
|
||||
? { originalTitle: item.original_title }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ElectronBridgeApi,
|
||||
getXtreamPendingRestoreStorageKey,
|
||||
normalizeXtreamPendingRestoreState,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/** Reads must recover older parked favorites without entering a network bootstrap. */
|
||||
export async function materializePendingXtreamFavorites(
|
||||
bridge: Pick<
|
||||
ElectronBridgeApi,
|
||||
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
|
||||
>,
|
||||
playlistId?: string
|
||||
): Promise<void> {
|
||||
const prefix = getXtreamPendingRestoreStorageKey('');
|
||||
const ids = playlistId
|
||||
? [playlistId]
|
||||
: Array.from({ length: localStorage.length }, (_, index) =>
|
||||
localStorage.key(index)
|
||||
)
|
||||
.filter(
|
||||
(key): key is string => key !== null && key.startsWith(prefix)
|
||||
)
|
||||
.map((key) => key.slice(prefix.length));
|
||||
for (const id of ids) {
|
||||
const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id));
|
||||
if (!raw) continue;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const state = normalizeXtreamPendingRestoreState(parsed);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
@@ -46,13 +46,30 @@ root is resolved in this order:
|
||||
Users may skip releases. The application must apply every required migration in
|
||||
dependency order when opening an older database, preserving user data without
|
||||
requiring intermediate application installations or a database reset. This is
|
||||
the repository policy mirrored in `AGENTS.md` and `CLAUDE.md`.
|
||||
the repository policy in `AGENTS.md`, which `CLAUDE.md` imports.
|
||||
|
||||
`src/lib/connection.ts` owns initialization: `createTables()` creates missing
|
||||
schema objects, then `runMigrations()` applies column/index migrations and
|
||||
dedicated schema/data upgrades. One-off data migrations can record completion
|
||||
in `app_state`. Keep existing migration paths when adding new ones.
|
||||
|
||||
Xtream favorites are migrated by `src/lib/favorites-table.ts` before legacy
|
||||
content/category deduplication. The transactional table rebuild backfills typed
|
||||
provider identity and saved display metadata while retaining favorite IDs,
|
||||
dates and positions. Duplicate favorites of the same playlist/type/provider ID
|
||||
collapse to the earliest favorite record. `content_id` becomes nullable with
|
||||
`ON DELETE SET NULL`; owning playlist deletion still cascades. The legacy
|
||||
content deduplicator updates references without replacing favorite IDs.
|
||||
Favorites whose owning playlist was already missing remain stored. The rebuild
|
||||
temporarily disables foreign-key enforcement outside its transaction, permits
|
||||
only those pre-existing playlist violations by favorite ID, and rolls back if
|
||||
it introduces another violation. The original enforcement setting is restored
|
||||
on both success and failure.
|
||||
Historical upgrade tests include tagged 0.19–0.24 schemas, duplicate categories,
|
||||
orphaned favorites, repeated startup and forced rollbacks. Keep snapshot
|
||||
migration before content deduplication so the originally favorited row's
|
||||
metadata survives.
|
||||
|
||||
When changing initialization:
|
||||
|
||||
- Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS`
|
||||
@@ -102,7 +119,7 @@ playlists) are guarded in `runMigrations()`:
|
||||
The #1580 index-ordering fix is included in 0.24 through PR #1550.
|
||||
`src/lib/connection-upgrades.spec.ts` exercises `initDatabase()` with real
|
||||
SQLite under the Electron runtime, using fresh-install schema snapshots from
|
||||
tags 0.19–0.23 and a fresh current database. The `epg_channel_id` column is absent
|
||||
tags 0.19–0.24 and a fresh current database. The `epg_channel_id` column is absent
|
||||
in 0.19, present from 0.20, and indexed from 0.23. Each case checks current Drizzle
|
||||
tables, columns/types, and named indexes/uniqueness, as well as preserved user
|
||||
rows, foreign keys, database integrity, index availability, and repeated startup;
|
||||
@@ -115,3 +132,6 @@ the index ahead of its column migration makes the 0.19 case fail again.
|
||||
Run this coverage with `pnpm nx test database --runInBand`. The Electron UI and
|
||||
worker upgrade flow is also covered by
|
||||
`pnpm nx run electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`.
|
||||
For an orphaned 0.24 favorites table, initial startup and restart through the
|
||||
real Electron UI and worker are covered by
|
||||
`pnpm nx run electron-backend-e2e:e2e-ci--src/favorites-legacy.e2e.ts`.
|
||||
@@ -40,7 +40,11 @@ function createSqliteMock(
|
||||
const rule = rules.find(([pattern]) => compact.includes(pattern));
|
||||
|
||||
return {
|
||||
all: jest.fn(() => []),
|
||||
all: jest.fn(() =>
|
||||
compact === "PRAGMA table_info('favorites')"
|
||||
? [{ name: 'xtream_id' }]
|
||||
: []
|
||||
),
|
||||
get: jest.fn(),
|
||||
run: jest.fn(),
|
||||
...(rule?.[1] ?? {}),
|
||||
@@ -299,7 +303,7 @@ describe('runMigrations Xtream cache deduplication', () => {
|
||||
'SELECT id FROM content WHERE category_id = ?',
|
||||
{ all: () => [{ id: 10 }, { id: 11 }] },
|
||||
],
|
||||
['INSERT INTO favorites', { run: moveFavoritesRun }],
|
||||
['UPDATE OR IGNORE favorites', { run: moveFavoritesRun }],
|
||||
[
|
||||
'DELETE FROM favorites WHERE content_id = ?',
|
||||
{ run: deleteFavoritesRun },
|
||||
|
||||
@@ -5,7 +5,19 @@ import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
describe('database initialization across skipped releases (#1580)', () => {
|
||||
it.each(['0.19.0', '0.20.0', '0.21.0', '0.22.0', '0.23.0', 'fresh'])(
|
||||
it.each([
|
||||
'0.19.0',
|
||||
'0.20.0',
|
||||
'0.21.0',
|
||||
'0.22.0',
|
||||
'0.23.0',
|
||||
'0.24.0',
|
||||
'0.19.0-duplicates',
|
||||
'0.19.0-orphans',
|
||||
'0.24.0-orphans',
|
||||
'fresh',
|
||||
'rollback',
|
||||
])(
|
||||
'preserves %s data and indexes across upgrade and repeated startup',
|
||||
(version) => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-'));
|
||||
@@ -13,12 +25,12 @@ describe('database initialization across skipped releases (#1580)', () => {
|
||||
'electron'
|
||||
) as string;
|
||||
const fixture =
|
||||
version === 'fresh'
|
||||
? 'fresh'
|
||||
version === 'fresh' || version === 'rollback'
|
||||
? version
|
||||
: resolve(
|
||||
__dirname,
|
||||
'testing/fixtures',
|
||||
`v${version}-schema.sql`
|
||||
`v${version.replace(/-(duplicates|orphans)$/, '')}-schema.sql`
|
||||
);
|
||||
try {
|
||||
const output = execFileSync(
|
||||
@@ -28,6 +40,11 @@ describe('database initialization across skipped releases (#1580)', () => {
|
||||
'tsx',
|
||||
resolve(__dirname, 'testing/connection-upgrade.ts'),
|
||||
fixture,
|
||||
version.endsWith('-duplicates')
|
||||
? 'deduplicate'
|
||||
: version.endsWith('-orphans')
|
||||
? 'orphans'
|
||||
: '',
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
|
||||
@@ -486,6 +486,9 @@ describe('database schema statements', () => {
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
exec: jest.fn(),
|
||||
pragma: jest.fn((statement: string) =>
|
||||
statement === 'foreign_keys' ? 1 : []
|
||||
),
|
||||
prepare: jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return {
|
||||
@@ -556,6 +559,9 @@ describe('database schema statements', () => {
|
||||
}
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
pragma: jest.fn((statement: string) =>
|
||||
statement === 'foreign_keys' ? 1 : []
|
||||
),
|
||||
exec: jest.fn((statement: string) => {
|
||||
if (statement.includes('idx_epg_programs_source')) {
|
||||
callOrder.push('epg-source-index');
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
ensurePlaylistsPayloadLast,
|
||||
playlistsTableSql,
|
||||
} from './playlists-table';
|
||||
import {
|
||||
ensureDurableFavorites,
|
||||
favoritesTableSql,
|
||||
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
|
||||
} from './favorites-table';
|
||||
import { xtreamCacheUniqueIndexesExist } from './xtream-cache-unique-indexes';
|
||||
/**
|
||||
* Database connection and initialization for IPTVnator
|
||||
@@ -183,15 +188,8 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER NOT NULL,
|
||||
playlist_id TEXT NOT NULL,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)`,
|
||||
favoritesTableSql(),
|
||||
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
|
||||
@@ -407,6 +405,7 @@ const INDEX_MIGRATION_STATEMENTS = [
|
||||
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_playlist_type_xtream_unique ON favorites(playlist_id, type, xtream_id)`,
|
||||
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
|
||||
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
|
||||
@@ -550,11 +549,7 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
const moveFavorites = sqliteDb.prepare(
|
||||
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
|
||||
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
|
||||
FROM favorites
|
||||
WHERE content_id = ?
|
||||
ON CONFLICT(content_id, playlist_id) DO NOTHING`
|
||||
`UPDATE OR IGNORE favorites SET content_id = ? WHERE content_id = ?`
|
||||
);
|
||||
const deleteFavorites = sqliteDb.prepare(
|
||||
`DELETE FROM favorites WHERE content_id = ?`
|
||||
@@ -1183,6 +1178,8 @@ function runMigrations(sqliteDb: Database.Database): void {
|
||||
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
|
||||
ensureContentTitleFts(sqliteDb);
|
||||
}
|
||||
// Capture the favorited row's metadata before duplicate cache rows disappear.
|
||||
ensureDurableFavorites(sqliteDb);
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
normalizeXtreamContentAddedEpochs(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
export function favoritesTableSql(name = 'favorites'): string {
|
||||
return `CREATE TABLE IF NOT EXISTS ${name} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content_id INTEGER,
|
||||
playlist_id TEXT NOT NULL,
|
||||
xtream_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
|
||||
title TEXT NOT NULL,
|
||||
poster_url TEXT,
|
||||
backdrop_url TEXT,
|
||||
tmdb_id INTEGER,
|
||||
release_year INTEGER,
|
||||
original_title TEXT,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
position INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
|
||||
)`;
|
||||
}
|
||||
|
||||
/** Runs after column upgrades, before cache deduplication and dependent indexes. */
|
||||
export function ensureDurableFavorites(sqlite: Database.Database): void {
|
||||
const columns = sqlite.prepare("PRAGMA table_info('favorites')").all() as {
|
||||
name: string;
|
||||
}[];
|
||||
if (columns.some((column) => column.name === 'xtream_id')) return;
|
||||
// Historical profiles may already contain favorites with a missing owner.
|
||||
// Retain those exact records, but never let this rebuild introduce new ones.
|
||||
const orphanIds = new Set(
|
||||
(
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT f.id FROM favorites f
|
||||
LEFT JOIN playlists p ON p.id = f.playlist_id
|
||||
WHERE p.id IS NULL`
|
||||
)
|
||||
.all() as { id: number }[]
|
||||
).map(({ id }) => id)
|
||||
);
|
||||
const foreignKeys = sqlite.pragma('foreign_keys', { simple: true });
|
||||
// This must happen outside the transaction; SQLite ignores it inside one.
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
try {
|
||||
sqlite.transaction(() => {
|
||||
sqlite.exec(favoritesTableSql('favorites_durable'));
|
||||
// Multiple categories may contain the same provider item. Keep the first
|
||||
// saved favorite's primary key, timestamp and position deterministically.
|
||||
sqlite.exec(`INSERT INTO favorites_durable
|
||||
(id, content_id, playlist_id, xtream_id, type, title, poster_url,
|
||||
backdrop_url, tmdb_id, release_year, original_title, added_at, position)
|
||||
SELECT id, content_id, playlist_id, xtream_id, type, title, poster_url,
|
||||
backdrop_url, tmdb_id, release_year, original_title, added_at, position
|
||||
FROM (
|
||||
SELECT f.id, c.id AS content_id, f.playlist_id,
|
||||
COALESCE(c.xtream_id, -f.id) AS xtream_id,
|
||||
COALESCE(c.type, 'movie') AS type,
|
||||
COALESCE(c.title, 'Saved favorite') AS title,
|
||||
c.poster_url, c.backdrop_url, c.tmdb_id, c.release_year, c.original_title,
|
||||
f.added_at, f.position,
|
||||
ROW_NUMBER() OVER (PARTITION BY f.playlist_id, c.type,
|
||||
COALESCE(c.xtream_id, -f.id) ORDER BY f.id) AS identity_rank
|
||||
FROM favorites f LEFT JOIN content c ON c.id = f.content_id
|
||||
) WHERE identity_rank = 1`);
|
||||
sqlite.exec('DROP TABLE favorites');
|
||||
sqlite.exec('ALTER TABLE favorites_durable RENAME TO favorites');
|
||||
sqlite.exec(
|
||||
'CREATE UNIQUE INDEX favorites_content_playlist_unique ON favorites(content_id, playlist_id)'
|
||||
);
|
||||
sqlite.exec(
|
||||
'CREATE INDEX favorites_playlist_idx ON favorites(playlist_id)'
|
||||
);
|
||||
sqlite.exec(
|
||||
'CREATE INDEX favorites_content_idx ON favorites(content_id)'
|
||||
);
|
||||
const violations = sqlite.pragma(
|
||||
'foreign_key_check(favorites)'
|
||||
) as {
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[];
|
||||
if (
|
||||
violations.some(
|
||||
({ rowid, parent }) =>
|
||||
parent !== 'playlists' || !orphanIds.has(rowid)
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Favorites migration introduced a foreign key violation'
|
||||
);
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Receipts are playlist-owned even though app_state is a generic key/value table. */
|
||||
export const FAVORITES_RECEIPT_CLEANUP_TRIGGER = `CREATE TRIGGER IF NOT EXISTS favorites_receipts_playlist_delete
|
||||
AFTER DELETE ON playlists BEGIN
|
||||
DELETE FROM app_state WHERE substr(key, 1, length('xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':')) =
|
||||
'xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':'
|
||||
OR key IN ('xtream-import-status:' || OLD.id || ':live',
|
||||
'xtream-import-status:' || OLD.id || ':movie', 'xtream-import-status:' || OLD.id || ':series');
|
||||
END`;
|
||||
@@ -170,17 +170,30 @@ export const favorites = sqliteTable(
|
||||
'favorites',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
contentId: integer('content_id')
|
||||
.notNull()
|
||||
.references(() => content.id, { onDelete: 'cascade' }),
|
||||
contentId: integer('content_id').references(() => content.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
playlistId: text('playlist_id')
|
||||
.notNull()
|
||||
.references(() => playlists.id, { onDelete: 'cascade' }),
|
||||
xtreamId: integer('xtream_id').notNull(),
|
||||
type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(),
|
||||
title: text('title').notNull(),
|
||||
posterUrl: text('poster_url'),
|
||||
backdropUrl: text('backdrop_url'),
|
||||
tmdbId: integer('tmdb_id'),
|
||||
releaseYear: integer('release_year'),
|
||||
originalTitle: text('original_title'),
|
||||
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
|
||||
/** Display order position in the global favorites list (lower = first) */
|
||||
position: integer('position').default(0),
|
||||
},
|
||||
(table) => ({
|
||||
identityUnique: uniqueIndex('favorites_playlist_type_xtream_unique').on(
|
||||
table.playlistId,
|
||||
table.type,
|
||||
table.xtreamId
|
||||
),
|
||||
contentPlaylistUnique: uniqueIndex(
|
||||
'favorites_content_playlist_unique'
|
||||
).on(table.contentId, table.playlistId),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { verifyFavoriteMigrationRollback } from './favorites-migration-rollback';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -25,7 +26,6 @@ function seed(sqlite: Database.Database) {
|
||||
VALUES (1, 'saved', 'Movies', 'movies', 42, 1);
|
||||
INSERT INTO content (id, category_id, title, xtream_id, type, added)
|
||||
VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000');
|
||||
INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved');
|
||||
INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved');
|
||||
INSERT INTO playback_positions
|
||||
(playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds)
|
||||
@@ -33,6 +33,18 @@ function seed(sqlite: Database.Database) {
|
||||
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
|
||||
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
|
||||
`);
|
||||
const favoriteColumns = sqlite.pragma('table_info(favorites)') as {
|
||||
name: string;
|
||||
}[];
|
||||
const durable = favoriteColumns.some(({ name }) => name === 'xtream_id');
|
||||
sqlite.exec(
|
||||
durable
|
||||
? `INSERT INTO favorites (id, content_id, playlist_id, added_at, position, xtream_id, type, title) VALUES (42, 1, 'saved', '2025-01-01', 9, 7, 'movie', 'Saved movie')`
|
||||
: `INSERT INTO favorites (id, content_id, playlist_id, added_at) VALUES (42, 1, 'saved', '2025-01-01')`
|
||||
);
|
||||
if (favoriteColumns.some(({ name }) => name === 'position')) {
|
||||
sqlite.exec('UPDATE favorites SET position = 9');
|
||||
}
|
||||
const columns = sqlite.pragma('table_info(content)') as { name: string }[];
|
||||
if (columns.some(({ name }) => name === 'epg_channel_id')) {
|
||||
sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'");
|
||||
@@ -139,6 +151,18 @@ function epgIndex(sqlite: Database.Database) {
|
||||
.get();
|
||||
}
|
||||
|
||||
function foreignKeyViolations(sqlite: Database.Database) {
|
||||
// Rebuilding a table can reorder its FK definitions without changing the
|
||||
// affected row or parent, so compare identities rather than FK ordinals.
|
||||
return (
|
||||
sqlite.pragma('foreign_key_check') as {
|
||||
table: string;
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[]
|
||||
).map(({ table, rowid, parent }) => ({ table, rowid, parent }));
|
||||
}
|
||||
|
||||
function verifyCurrentSchema(sqlite: Database.Database) {
|
||||
for (const table of Object.values(currentSchema)) {
|
||||
const { name, columns, indexes } = getTableConfig(table);
|
||||
@@ -173,6 +197,11 @@ function verifyCurrentSchema(sqlite: Database.Database) {
|
||||
|
||||
async function main() {
|
||||
const fixture = process.argv[2];
|
||||
if (fixture === 'rollback') {
|
||||
verifyFavoriteMigrationRollback();
|
||||
process.stdout.write('upgrade verified');
|
||||
return;
|
||||
}
|
||||
assert(
|
||||
process.env['IPTVNATOR_E2E_DATA_DIR'],
|
||||
'Never open the user database'
|
||||
@@ -191,7 +220,21 @@ async function main() {
|
||||
legacy.pragma('foreign_keys = ON');
|
||||
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
|
||||
seed(legacy);
|
||||
if (process.argv[3] === 'orphans') {
|
||||
legacy.pragma('foreign_keys = OFF');
|
||||
legacy.exec(`INSERT INTO favorites (id, content_id, playlist_id, added_at)
|
||||
VALUES (43, 1, 'deleted-playlist', '2024-01-01')`);
|
||||
legacy.pragma('foreign_keys = ON');
|
||||
}
|
||||
const before = snapshot(legacy);
|
||||
const originalViolations = foreignKeyViolations(legacy);
|
||||
if (process.argv[3] === 'deduplicate') {
|
||||
legacy.exec(`INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
|
||||
VALUES (2, 'saved', 'Movies', 'movies', 42, 1);
|
||||
INSERT INTO content (id, category_id, title, xtream_id, type, added)
|
||||
VALUES (2, 2, 'Original favorite title', 7, 'movie', '1700000000');
|
||||
UPDATE favorites SET content_id = 2;`);
|
||||
}
|
||||
const originalIndex = epgIndex(legacy);
|
||||
legacy.close();
|
||||
|
||||
@@ -214,7 +257,37 @@ async function main() {
|
||||
for (const { query, rows } of before) {
|
||||
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
|
||||
}
|
||||
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, content_id, playlist_id, xtream_id, type, title, added_at FROM favorites'
|
||||
)
|
||||
.get(),
|
||||
{
|
||||
id: 42,
|
||||
content_id: 1,
|
||||
playlist_id: 'saved',
|
||||
xtream_id: 7,
|
||||
type: 'movie',
|
||||
title:
|
||||
process.argv[3] === 'deduplicate'
|
||||
? 'Original favorite title'
|
||||
: 'Saved movie',
|
||||
added_at: '2025-01-01',
|
||||
}
|
||||
);
|
||||
const favoriteForeignKeys = sqlite.pragma(
|
||||
'foreign_key_list(favorites)'
|
||||
) as { from: string; on_delete: string }[];
|
||||
assert.equal(
|
||||
favoriteForeignKeys.find((key) => key.from === 'content_id')
|
||||
?.on_delete,
|
||||
'SET NULL'
|
||||
);
|
||||
assert.deepEqual(
|
||||
foreignKeyViolations(sqlite),
|
||||
originalViolations
|
||||
);
|
||||
assert.deepEqual(sqlite.pragma('integrity_check'), [
|
||||
{ integrity_check: 'ok' },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import Database from 'better-sqlite3';
|
||||
import { ensureDurableFavorites } from '../favorites-table';
|
||||
|
||||
export function verifyFavoriteMigrationRollback() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
try {
|
||||
sqlite.exec(`CREATE TABLE playlists (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE content (id INTEGER PRIMARY KEY, xtream_id INTEGER, type TEXT,
|
||||
title TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER,
|
||||
release_year INTEGER, original_title TEXT);
|
||||
CREATE TABLE favorites (id INTEGER PRIMARY KEY, content_id INTEGER NOT NULL
|
||||
REFERENCES content(id) ON DELETE CASCADE, playlist_id TEXT REFERENCES playlists(id),
|
||||
added_at TEXT, position INTEGER);
|
||||
INSERT INTO playlists VALUES ('p');
|
||||
INSERT INTO content (id, xtream_id, type, title) VALUES (1, 7, 'movie', 'ABC');
|
||||
INSERT INTO favorites VALUES (42, 1, 'p', '2025-01-01', 9);
|
||||
CREATE VIEW favorites_dependency AS SELECT id FROM favorites;`);
|
||||
const before = sqlite.prepare('SELECT * FROM favorites').all();
|
||||
assert.throws(
|
||||
() => ensureDurableFavorites(sqlite),
|
||||
/favorites_dependency/
|
||||
);
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare('SELECT * FROM favorites').all(),
|
||||
before
|
||||
);
|
||||
assert.equal(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
sqlite.exec('DROP VIEW favorites_dependency');
|
||||
sqlite.pragma('foreign_keys = OFF');
|
||||
sqlite.exec(`INSERT INTO favorites VALUES (43, 1, 'deleted', '2024-01-01', 10);
|
||||
INSERT INTO favorites VALUES (44, 999, 'p', '2024-01-02', 11);`);
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
const withOrphans = sqlite.prepare('SELECT * FROM favorites').all();
|
||||
const exec = sqlite.exec;
|
||||
sqlite.exec = function injectInvalidOwner(statement) {
|
||||
const result = exec.call(this, statement);
|
||||
if (
|
||||
statement ===
|
||||
'ALTER TABLE favorites_durable RENAME TO favorites'
|
||||
) {
|
||||
exec.call(
|
||||
this,
|
||||
"UPDATE favorites SET playlist_id = 'new-orphan' WHERE id = 42"
|
||||
);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// The missing content link is repaired during the rebuild. A mere
|
||||
// before/after violation count would conceal a new orphan in its place.
|
||||
try {
|
||||
assert.throws(
|
||||
() => ensureDurableFavorites(sqlite),
|
||||
/introduced a foreign key violation/
|
||||
);
|
||||
} finally {
|
||||
sqlite.exec = exec;
|
||||
}
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
assert.deepEqual(
|
||||
sqlite.prepare('SELECT * FROM favorites').all(),
|
||||
withOrphans
|
||||
);
|
||||
assert.equal(
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE name = 'favorites_durable'"
|
||||
)
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
ensureDurableFavorites(sqlite);
|
||||
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
|
||||
ensureDurableFavorites(sqlite);
|
||||
sqlite.exec('DELETE FROM content');
|
||||
assert.deepEqual(
|
||||
sqlite
|
||||
.prepare(
|
||||
'SELECT id, content_id, title, added_at, position FROM favorites'
|
||||
)
|
||||
.get(),
|
||||
{
|
||||
id: 42,
|
||||
content_id: null,
|
||||
title: 'ABC',
|
||||
added_at: '2025-01-01',
|
||||
position: 9,
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
(
|
||||
sqlite.pragma('foreign_key_check') as {
|
||||
rowid: number;
|
||||
parent: string;
|
||||
}[]
|
||||
).map(({ rowid, parent }) => ({ rowid, parent })),
|
||||
[{ rowid: 43, parent: 'playlists' }]
|
||||
);
|
||||
assert.equal(
|
||||
sqlite.prepare('SELECT id FROM favorites').all().length,
|
||||
3
|
||||
);
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Fresh-install schema from tag v0.24.0, libs/shared/database/src/lib/connection.ts.
|
||||
-- Whitespace compacted; statements and referenced constants retain their original order.
|
||||
CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, serverUrl TEXT, username TEXT, password TEXT, date_created TEXT DEFAULT (datetime('now')), last_updated TEXT, type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')), userAgent TEXT, origin TEXT, referrer TEXT, filePath TEXT, epg_urls TEXT, detected_epg_urls TEXT, manual_epg_urls TEXT, disabled_epg_urls TEXT, autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, portal_url TEXT, count INTEGER, import_date TEXT, update_date INTEGER, position INTEGER, favorites TEXT, recently_viewed TEXT, payload TEXT, last_usage TEXT );
|
||||
CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')), xtream_id INTEGER NOT NULL, hidden INTEGER DEFAULT 0, UNIQUE(playlist_id, type, xtream_id), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS content ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, rating TEXT, added TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER, release_year INTEGER, original_title TEXT, epg_channel_id TEXT, tv_archive INTEGER, tv_archive_duration INTEGER, direct_source TEXT, xtream_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), UNIQUE(category_id, type, xtream_id), FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS recently_viewed ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, viewed_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, added_at TEXT DEFAULT (datetime('now')), position INTEGER DEFAULT 0, FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type ON content(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_title ON content(title);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0;
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( title, content='content', content_rowid='id', tokenize='trigram' );
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); END;
|
||||
CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at);
|
||||
CREATE INDEX IF NOT EXISTS recently_viewed_playlist_viewed_idx ON recently_viewed(playlist_id, viewed_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS epg_channels ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, source_url TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE TABLE IF NOT EXISTS epg_channel_sources ( channel_id TEXT NOT NULL, source_url TEXT NOT NULL, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, updated_at TEXT DEFAULT (datetime('now')), write_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, source_url), FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channel_sources_source ON epg_channel_sources(source_url);
|
||||
CREATE TABLE IF NOT EXISTS epg_programs ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, start TEXT NOT NULL, stop TEXT NOT NULL, title TEXT NOT NULL, description TEXT, category TEXT, icon_url TEXT, rating TEXT, episode_num TEXT, source_url TEXT, FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_stop ON epg_programs(stop);
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( title, description, category, content='epg_programs', content_rowid='id' );
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); END;
|
||||
CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END;
|
||||
CREATE TABLE IF NOT EXISTS epg_channel_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_key TEXT NOT NULL UNIQUE, epg_channel_id TEXT NOT NULL, playlist_id TEXT, updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS vod_source_pins ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_key TEXT NOT NULL UNIQUE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, content_id INTEGER NOT NULL, portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, content_xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')), series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, position_seconds INTEGER NOT NULL DEFAULT 0, duration_seconds INTEGER, updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode', 'catchup')), programme_start INTEGER NOT NULL DEFAULT 0, catchup TEXT, series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, episode_identity_scope TEXT, title TEXT NOT NULL, url TEXT NOT NULL, file_name TEXT, file_path TEXT, poster_url TEXT, request_headers TEXT, resume_validator TEXT, metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')), bytes_downloaded INTEGER DEFAULT 0, total_bytes INTEGER, error_message TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup';
|
||||
CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id);
|
||||
CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status);
|
||||
CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, owner_pid INTEGER, status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')), file_path TEXT NOT NULL, file_size_bytes INTEGER, channel_name TEXT NOT NULL, channel_logo_url TEXT, playlist_id TEXT, playlist_name TEXT, source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')), epg_channel_id TEXT, program_title TEXT, program_description TEXT, program_start TEXT, program_stop TEXT, programs_json TEXT, error_message TEXT, started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status);
|
||||
CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path);
|
||||
CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id);
|
||||
CREATE TABLE IF NOT EXISTS tmdb_metadata ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now')) );
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language);
|
||||
@@ -81,6 +81,7 @@ export * from './lib/xtream-live-stream.interface';
|
||||
export * from './lib/xtream-response.interface';
|
||||
export * from './lib/xtream-portal.utils';
|
||||
export * from './lib/xtream-restore-state.util';
|
||||
export * from './lib/xtream-favorite.interface';
|
||||
export * from './lib/xtream-server-timezone.util';
|
||||
export * from './lib/xtream-recently-added.utils';
|
||||
export * from './lib/xtream-performance-marker.interface';
|
||||
|
||||
@@ -564,7 +564,20 @@ export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamCont
|
||||
viewed_at: string;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent {
|
||||
export interface ElectronBridgeXtreamFavorite extends Omit<
|
||||
ElectronBridgeXtreamContent,
|
||||
'id' | 'category_id'
|
||||
> {
|
||||
id: number | null;
|
||||
category_id: number | null;
|
||||
favorite_id?: number;
|
||||
catalog_status?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
|
||||
tmdb_id?: number | null;
|
||||
release_year?: number | null;
|
||||
original_title?: string | null;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamFavorite {
|
||||
playlist_id: string;
|
||||
playlist_name: string;
|
||||
added_at: string;
|
||||
@@ -577,7 +590,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr
|
||||
}
|
||||
|
||||
export interface ElectronBridgeFavoriteReorderUpdate {
|
||||
content_id: number;
|
||||
content_id?: number;
|
||||
favorite_id?: number;
|
||||
/** Favorites are playlist-scoped — scope the position write per playlist */
|
||||
playlist_id: string;
|
||||
position: number;
|
||||
@@ -1033,6 +1047,11 @@ export interface ElectronBridgeApi {
|
||||
recentlyViewed: XtreamBackupRecentlyViewedItem[];
|
||||
hiddenCategories: XtreamBackupHiddenCategory[];
|
||||
}>;
|
||||
dbRestoreXtreamFavorites: (
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
restoreId?: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbRestoreXtreamUserData: (
|
||||
playlistId: string,
|
||||
favorites: XtreamBackupFavoriteItem[],
|
||||
@@ -1109,15 +1128,18 @@ export interface ElectronBridgeApi {
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
) => Promise<ElectronBridgeErrorResult>;
|
||||
dbRemoveFavorite: (
|
||||
contentId: number,
|
||||
contentId: import('./xtream-favorite.interface').XtreamFavoriteRemovalRef,
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeResult>;
|
||||
dbIsFavorite: (contentId: number, playlistId: string) => Promise<boolean>;
|
||||
dbGetFavoritesForBackup: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeXtreamFavorite[]>;
|
||||
dbGetFavorites: (
|
||||
playlistId: string
|
||||
) => Promise<ElectronBridgeXtreamContent[]>;
|
||||
) => Promise<ElectronBridgeXtreamFavorite[]>;
|
||||
dbGetGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
|
||||
dbGetAllGlobalFavorites: () => Promise<ElectronBridgeGlobalFavoriteItem[]>;
|
||||
dbReorderGlobalFavorites: (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface';
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
@@ -56,6 +57,7 @@ export interface XtreamBackupFavoriteItem {
|
||||
xtreamId: number;
|
||||
addedAt?: string;
|
||||
position?: number | null;
|
||||
snapshot?: XtreamFavoriteSnapshot;
|
||||
}
|
||||
|
||||
export interface XtreamBackupRecentlyViewedItem {
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface PortalActivityItem {
|
||||
/** The year the PROVIDER stated, never one read out of the title */
|
||||
release_year?: number;
|
||||
original_title?: string;
|
||||
favorite_id?: number;
|
||||
catalogStatus?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus;
|
||||
source?: 'xtream' | 'stalker' | 'm3u';
|
||||
/** Original stalker item for navigation state; undefined for xtream. */
|
||||
stalker_item?: StalkerPortalItem;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Catalog membership is separate from whether a stream can currently play. */
|
||||
export const XTREAM_FAVORITE_CATALOG_STATUS = {
|
||||
PRESENT: 'present',
|
||||
MISSING: 'missing',
|
||||
UNKNOWN: 'unknown',
|
||||
} as const;
|
||||
|
||||
export type XtreamFavoriteCatalogStatus =
|
||||
(typeof XTREAM_FAVORITE_CATALOG_STATUS)[keyof typeof XTREAM_FAVORITE_CATALOG_STATUS];
|
||||
|
||||
export interface XtreamFavoriteSnapshot {
|
||||
title: string;
|
||||
posterUrl?: string | null;
|
||||
backdropUrl?: string | null;
|
||||
tmdbId?: number | null;
|
||||
releaseYear?: number | null;
|
||||
originalTitle?: string | null;
|
||||
}
|
||||
|
||||
/** Numeric references are legacy catalog row IDs, never provider IDs. */
|
||||
export type XtreamFavoriteRemovalRef = number | { favoriteId: number };
|
||||
|
||||
export const XTREAM_FAVORITE_IDENTITY_CONFLICT =
|
||||
'XTREAM_FAVORITE_IDENTITY_CONFLICT';
|
||||
|
||||
export function isCatalogUnavailable(item: {
|
||||
catalogStatus?: XtreamFavoriteCatalogStatus;
|
||||
}): boolean {
|
||||
return item.catalogStatus === 'missing' || item.catalogStatus === 'unknown';
|
||||
}
|
||||
@@ -1,6 +1,37 @@
|
||||
import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util';
|
||||
|
||||
describe('normalizeXtreamPendingRestoreState', () => {
|
||||
it('retains only display metadata in a durable favorite snapshot', () => {
|
||||
const state = normalizeXtreamPendingRestoreState({
|
||||
favorites: [
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 7,
|
||||
position: 2,
|
||||
snapshot: {
|
||||
title: 'ABC',
|
||||
posterUrl: 'https://example.test/poster.jpg',
|
||||
tmdbId: 42,
|
||||
password: 'private',
|
||||
streamUrl: 'https://example.test/secret',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(state.favorites).toEqual([
|
||||
{
|
||||
contentType: 'movie',
|
||||
xtreamId: 7,
|
||||
position: 2,
|
||||
snapshot: {
|
||||
title: 'ABC',
|
||||
posterUrl: 'https://example.test/poster.jpg',
|
||||
tmdbId: 42,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// No `sourcePins`: absent has to stay absent, or an older archive would
|
||||
// read as "there are no pins" and restore would clear the user's.
|
||||
const emptyState = {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
XtreamBackupRecentlyViewedItem,
|
||||
XtreamBackupSourcePin,
|
||||
} from './playlist-backup.interface';
|
||||
import { XtreamFavoriteSnapshot } from './xtream-favorite.interface';
|
||||
import { PlaybackPositionData } from './playback-position.interface';
|
||||
|
||||
export interface XtreamPendingRestoreState {
|
||||
@@ -18,6 +19,8 @@ export interface XtreamPendingRestoreState {
|
||||
* can distinguish "no opinion" from an authoritative empty collection.
|
||||
*/
|
||||
sourcePins?: XtreamBackupSourcePin[];
|
||||
/** Internal replay receipt; never exported in an archive. */
|
||||
favoritesRestoreId?: string;
|
||||
}
|
||||
|
||||
export function getXtreamPendingRestoreStorageKey(playlistId: string): string {
|
||||
@@ -36,6 +39,7 @@ interface RestoreStateCandidate {
|
||||
recentlyViewed?: unknown;
|
||||
playbackPositions?: unknown;
|
||||
sourcePins?: unknown;
|
||||
favoritesRestoreId?: unknown;
|
||||
}
|
||||
|
||||
interface RestoreEntryCandidate {
|
||||
@@ -135,9 +139,12 @@ export function normalizeXtreamPendingRestoreState(
|
||||
|
||||
return {
|
||||
hiddenCategories,
|
||||
favorites: withNumericXtreamId<XtreamBackupFavoriteItem>(
|
||||
toArray(candidate.favorites)
|
||||
),
|
||||
favorites: normalizeFavorites(candidate.favorites),
|
||||
...(typeof candidate.favoritesRestoreId === 'string' &&
|
||||
candidate.favoritesRestoreId.length > 0 &&
|
||||
candidate.favoritesRestoreId.length <= 200
|
||||
? { favoritesRestoreId: candidate.favoritesRestoreId }
|
||||
: {}),
|
||||
recentlyViewed: withNumericXtreamId<XtreamBackupRecentlyViewedItem>(
|
||||
toArray(candidate.recentlyViewed)
|
||||
),
|
||||
@@ -184,3 +191,51 @@ function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] {
|
||||
|
||||
return pins;
|
||||
}
|
||||
|
||||
/** Snapshot fields are display metadata, never a raw provider response. */
|
||||
function normalizeFavorites(value: unknown): XtreamBackupFavoriteItem[] {
|
||||
return withNumericXtreamId<XtreamBackupFavoriteItem>(toArray(value))
|
||||
.filter((item) =>
|
||||
['live', 'movie', 'series'].includes(item.contentType)
|
||||
)
|
||||
.map((item) => {
|
||||
const snapshot = normalizeFavoriteSnapshot(item.snapshot);
|
||||
return {
|
||||
contentType: item.contentType,
|
||||
xtreamId: item.xtreamId,
|
||||
...(typeof item.addedAt === 'string'
|
||||
? { addedAt: item.addedAt }
|
||||
: {}),
|
||||
...(item.position === null ||
|
||||
(typeof item.position === 'number' &&
|
||||
Number.isFinite(item.position))
|
||||
? { position: item.position }
|
||||
: {}),
|
||||
...(snapshot ? { snapshot } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeFavoriteSnapshot(
|
||||
value: unknown
|
||||
): XtreamFavoriteSnapshot | undefined {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value['title'] !== 'string' ||
|
||||
!value['title'].trim()
|
||||
)
|
||||
return undefined;
|
||||
const snapshot: XtreamFavoriteSnapshot = { title: value['title'].trim() };
|
||||
for (const key of ['posterUrl', 'backdropUrl', 'originalTitle'] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === 'string') snapshot[key] = field;
|
||||
else if (field === null) snapshot[key] = null;
|
||||
}
|
||||
for (const key of ['tmdbId', 'releaseYear'] as const) {
|
||||
const field = value[key];
|
||||
if (typeof field === 'number' && Number.isInteger(field) && field > 0)
|
||||
snapshot[key] = field;
|
||||
else if (field === null) snapshot[key] = null;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
+4
-1
@@ -17,6 +17,7 @@
|
||||
type="button"
|
||||
class="channel-content"
|
||||
[attr.aria-label]="displayName()"
|
||||
[attr.aria-disabled]="disabled() || null"
|
||||
>
|
||||
<div class="channel-logo-shell" aria-hidden="true">
|
||||
@if (showLogoFallback()) {
|
||||
@@ -50,7 +51,9 @@
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
@if (showEpg()) {
|
||||
@if (statusLabel()) {
|
||||
<div class="epg-title">{{ statusLabel() }}</div>
|
||||
} @else if (showEpg()) {
|
||||
@if (epgProgram(); as program) {
|
||||
<div class="epg-title">
|
||||
{{ program.title }}
|
||||
|
||||
+4
@@ -43,6 +43,8 @@ export class ChannelListItemComponent {
|
||||
readonly isDraggable = input(false);
|
||||
readonly logo = input<string | null | undefined>('');
|
||||
readonly name = input('');
|
||||
readonly disabled = input(false);
|
||||
readonly statusLabel = input('');
|
||||
readonly displayName = computed(() =>
|
||||
applyChannelNameStrip(
|
||||
this.name(),
|
||||
@@ -125,6 +127,7 @@ export class ChannelListItemComponent {
|
||||
}
|
||||
|
||||
onClick(event?: MouseEvent): void {
|
||||
if (this.disabled()) return;
|
||||
if ((event?.detail ?? 1) > 1) {
|
||||
return;
|
||||
}
|
||||
@@ -137,6 +140,7 @@ export class ChannelListItemComponent {
|
||||
}
|
||||
|
||||
onDoubleClick(): void {
|
||||
if (this.disabled()) return;
|
||||
this.activated.emit();
|
||||
}
|
||||
|
||||
|
||||
@@ -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