fix(xtream): preserve favorites across catalog refreshes

This commit is contained in:
4gray committed 2026-10-13 23:31:25 +02:00
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();
@@ -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;
}
@@ -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([
{
@@ -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 {
@@ -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)"
/>
@@ -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;
}
@@ -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,
};
}
@@ -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',
@@ -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)) {
@@ -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"
@@ -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'));
@@ -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);
}
@@ -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
@@ -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 });
}
@@ -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']),
@@ -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
);
@@ -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
);
@@ -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,
@@ -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.');
}
}
+22 -2
View File
@@ -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');
+11 -14
View File
@@ -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`;
+16 -3
View File
@@ -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);
+1
View File
@@ -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;
}
@@ -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 }}
@@ -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),