fix(xtream): persist PWA activity from catalog content

This commit is contained in:
4gray committed 2026-05-15 17:33:14 +02:00
1 parent 0878905da0
commit 162a7902ae
10 files changed
+851 -220

No files matched your search

@@ -0,0 +1,40 @@
import { TestBed } from '@angular/core/testing';
import { XtreamApiService } from '../services/xtream-api.service';
import {
provideXtreamDataSource,
PwaXtreamDataSource,
XTREAM_DATA_SOURCE,
} from './index';
describe('provideXtreamDataSource', () => {
let originalElectron: typeof window.electron;
beforeEach(() => {
originalElectron = window.electron;
});
afterEach(() => {
(window as unknown as { electron?: typeof window.electron }).electron =
originalElectron;
TestBed.resetTestingModule();
});
it('uses the PWA data source when the web shim exposes an empty electron object', () => {
(window as unknown as { electron?: typeof window.electron }).electron =
{} as typeof window.electron;
TestBed.configureTestingModule({
providers: [
...provideXtreamDataSource(),
{
provide: XtreamApiService,
useValue: {},
},
],
});
expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBeInstanceOf(
PwaXtreamDataSource
);
});
});
@@ -1,13 +1,24 @@
import { inject, Provider } from '@angular/core';
import { ElectronXtreamDataSource } from './electron-xtream-data-source';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
import { IXtreamDataSource, XTREAM_DATA_SOURCE } from './xtream-data-source.interface';
import {
IXtreamDataSource,
XTREAM_DATA_SOURCE,
} from './xtream-data-source.interface';
// Re-export all types and interfaces
export * from './xtream-data-source.interface';
export { ElectronXtreamDataSource } from './electron-xtream-data-source';
export { PwaXtreamDataSource } from './pwa-xtream-data-source';
function hasElectronDatabasePreload(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbGetPlaylist === 'function' &&
typeof window.electron?.dbGetContent === 'function'
);
}
/**
* Factory function that returns the appropriate data source based on environment.
* - Electron: Uses DatabaseService for DB-first caching
@@ -15,7 +26,7 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source';
*/
export function xtreamDataSourceFactory(): IXtreamDataSource {
// Check if we're in Electron environment
if (typeof window !== 'undefined' && window.electron) {
if (hasElectronDatabasePreload()) {
return inject(ElectronXtreamDataSource);
}
@@ -0,0 +1,106 @@
import { TestBed } from '@angular/core/testing';
import { XtreamApiService } from '../services/xtream-api.service';
import { PwaXtreamDataSource } from './pwa-xtream-data-source';
describe('PwaXtreamDataSource', () => {
let dataSource: PwaXtreamDataSource;
let apiService: {
getStreams: jest.Mock;
};
const credentials = {
serverUrl: 'http://localhost:3211',
username: 'user1',
password: 'pass1',
};
beforeEach(() => {
localStorage.clear();
apiService = {
getStreams: jest.fn().mockResolvedValue([
{
stream_id: 20203,
category_id: '206',
name: 'Movie',
title: 'Movie',
stream_type: 'movie',
type: 'movie',
},
]),
};
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
dataSource = TestBed.inject(PwaXtreamDataSource);
});
it('finds VOD cached under vod when callers request movie content', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
await expect(
dataSource.getContentByXtreamId(20203, 'playlist-1', 'movie')
).resolves.toMatchObject({
stream_id: 20203,
title: 'Movie',
});
});
it('matches PWA favorites and recent items against VOD cache entries', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
await dataSource.addFavorite(20203, 'playlist-1');
await dataSource.addRecentItem(20203, 'playlist-1');
await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([
expect.objectContaining({ stream_id: 20203 }),
]);
await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([
expect.objectContaining({ stream_id: 20203 }),
]);
});
it('restores PWA favorites and recent items from persisted content snapshots after reload', async () => {
await dataSource.getContent('playlist-1', credentials, 'vod');
await dataSource.addFavorite(20203, 'playlist-1');
await dataSource.addRecentItem(20203, 'playlist-1');
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{
provide: XtreamApiService,
useValue: apiService,
},
],
});
const freshDataSource = TestBed.inject(PwaXtreamDataSource);
await expect(
freshDataSource.getFavorites('playlist-1')
).resolves.toEqual([
expect.objectContaining({
stream_id: 20203,
title: 'Movie',
}),
]);
await expect(
freshDataSource.getRecentItems('playlist-1')
).resolves.toEqual([
expect.objectContaining({
stream_id: 20203,
title: 'Movie',
viewed_at: expect.any(String),
}),
]);
});
});
@@ -35,8 +35,11 @@ const STORAGE_KEYS = {
};
interface XtreamCachedContentItem {
readonly added?: string;
readonly added?: string | number;
readonly added_at?: string | number;
readonly backdrop_url?: string | null;
readonly category_id?: string | number;
readonly cover?: string;
readonly id?: number;
readonly name?: string;
readonly poster_url?: string;
@@ -49,11 +52,24 @@ interface XtreamCachedContentItem {
readonly viewed_at?: string;
}
interface StoredFavoriteItem {
readonly id: number;
readonly addedAt: string;
readonly backdropUrl?: string;
readonly content?: XtreamCachedContentItem;
}
type StoredFavoriteEntry = number | StoredFavoriteItem;
interface StoredRecentItem {
readonly id: number;
readonly viewedAt: string;
readonly backdropUrl?: string;
readonly content?: XtreamCachedContentItem;
}
type PwaContentCacheType = 'live' | 'movie' | 'vod' | 'series';
/**
* PWA implementation of the Xtream data source.
* Uses API-only strategy: always fetch from API, no database caching.
@@ -68,6 +84,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private categoryCache = new Map<string, XtreamCategory[]>();
private contentCache = new Map<string, XtreamCachedContentItem[]>();
private getContentCacheLookupTypes(
contentType?: 'live' | 'movie' | 'series'
): PwaContentCacheType[] {
if (contentType === 'movie') {
return ['movie', 'vod'];
}
if (contentType) {
return [contentType];
}
return ['live', 'movie', 'vod', 'series'];
}
// =========================================================================
// Playlist Operations (localStorage)
// =========================================================================
@@ -319,17 +349,27 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const allFavorites = this.getFavoritesFromStorage();
const playlistFavorites = allFavorites[playlistId] || [];
// Match favorites with cached content
const results: XtreamCachedContentItem[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const seenIds = new Set<number>();
for (const item of content) {
const itemId = item.stream_id || item.series_id || item.id;
if (playlistFavorites.includes(itemId)) {
results.push(item);
}
for (const favorite of playlistFavorites) {
const favoriteId = this.getStoredFavoriteId(favorite);
if (seenIds.has(favoriteId)) {
continue;
}
const content =
this.getStoredFavoriteContent(favorite) ??
this.findCachedContentById(playlistId, favoriteId);
if (content) {
results.push({
...content,
added_at: this.getStoredFavoriteAddedAt(favorite),
backdrop_url:
this.getStoredFavoriteBackdropUrl(favorite) ??
content.backdrop_url,
});
seenIds.add(favoriteId);
}
}
@@ -347,8 +387,35 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
}
if (!allFavorites[playlistId].includes(contentId)) {
allFavorites[playlistId].push(contentId);
const existingIndex = allFavorites[playlistId].findIndex(
(entry) => this.getStoredFavoriteId(entry) === contentId
);
const existing =
existingIndex >= 0 ? allFavorites[playlistId][existingIndex] : null;
const cachedContent = this.findCachedContentById(playlistId, contentId);
const nextFavorite: StoredFavoriteItem = {
id: contentId,
addedAt:
existing && typeof existing !== 'number'
? existing.addedAt
: new Date().toISOString(),
backdropUrl:
_backdropUrl ??
(existing && typeof existing !== 'number'
? existing.backdropUrl
: undefined),
content:
cachedContent ??
(existing && typeof existing !== 'number'
? existing.content
: undefined),
};
if (existingIndex >= 0) {
allFavorites[playlistId][existingIndex] = nextFavorite;
} else {
allFavorites[playlistId].push(nextFavorite);
}
this.saveFavoritesToStorage(allFavorites);
}
@@ -357,7 +424,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const allFavorites = this.getFavoritesFromStorage();
if (allFavorites[playlistId]) {
allFavorites[playlistId] = allFavorites[playlistId].filter(
(id: number) => id !== contentId
(entry) => this.getStoredFavoriteId(entry) !== contentId
);
}
this.saveFavoritesToStorage(allFavorites);
@@ -365,10 +432,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
async isFavorite(contentId: number, playlistId: string): Promise<boolean> {
const allFavorites = this.getFavoritesFromStorage();
return (allFavorites[playlistId] || []).includes(contentId);
return (allFavorites[playlistId] || []).some(
(entry) => this.getStoredFavoriteId(entry) === contentId
);
}
private getFavoritesFromStorage(): Record<string, number[]> {
private getFavoritesFromStorage(): Record<string, StoredFavoriteEntry[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
@@ -377,10 +446,34 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
}
private saveFavoritesToStorage(favorites: Record<string, number[]>): void {
private saveFavoritesToStorage(
favorites: Record<string, StoredFavoriteEntry[]>
): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
private getStoredFavoriteId(favorite: StoredFavoriteEntry): number {
return typeof favorite === 'number' ? favorite : favorite.id;
}
private getStoredFavoriteContent(
favorite: StoredFavoriteEntry
): XtreamCachedContentItem | undefined {
return typeof favorite === 'number' ? undefined : favorite.content;
}
private getStoredFavoriteAddedAt(favorite: StoredFavoriteEntry): string {
return typeof favorite === 'number'
? new Date(0).toISOString()
: favorite.addedAt;
}
private getStoredFavoriteBackdropUrl(
favorite: StoredFavoriteEntry
): string | undefined {
return typeof favorite === 'number' ? undefined : favorite.backdropUrl;
}
private clearFavoritesForPlaylist(playlistId: string): void {
const allFavorites = this.getFavoritesFromStorage();
delete allFavorites[playlistId];
@@ -526,21 +619,25 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const allRecent = this.getRecentItemsFromStorage();
const playlistRecent = allRecent[playlistId] || [];
// Match recent items with cached content
const results: (XtreamCachedContentItem & { viewed_at: string })[] = [];
for (const type of ['live', 'movie', 'series']) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const seenIds = new Set<number>();
for (const item of content) {
const itemId = item.stream_id || item.series_id || item.id;
const recentEntry = playlistRecent.find((r) => r.id === itemId);
if (recentEntry) {
results.push({
...item,
viewed_at: recentEntry.viewedAt,
});
}
for (const recentEntry of playlistRecent) {
if (seenIds.has(recentEntry.id)) {
continue;
}
const content =
recentEntry.content ??
this.findCachedContentById(playlistId, recentEntry.id);
if (content) {
results.push({
...content,
viewed_at: recentEntry.viewedAt,
backdrop_url:
recentEntry.backdropUrl ?? content.backdrop_url,
});
seenIds.add(recentEntry.id);
}
}
@@ -573,6 +670,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
allRecent[playlistId].unshift({
id: contentId,
viewedAt: new Date().toISOString(),
backdropUrl: _backdropUrl,
content: this.findCachedContentById(playlistId, contentId),
});
// Keep only last 50 items
@@ -631,19 +730,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
playlistId: string,
contentType?: 'live' | 'movie' | 'series'
): Promise<XtreamContentItem | null> {
const types = contentType
? [contentType]
: (['live', 'movie', 'series'] as const);
for (const type of types) {
for (const type of this.getContentCacheLookupTypes(contentType)) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = content.find((item) => {
const itemXtreamId =
item.stream_id || item.series_id || item.id;
return itemXtreamId === xtreamId;
});
const found = content.find(
(item) => this.resolveContentId(item) === xtreamId
);
if (found) {
return found as XtreamContentItem;
@@ -658,23 +751,17 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
xtreamId: number,
contentType?: 'live' | 'movie' | 'series'
): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null {
const types = contentType
? [contentType]
: (['live', 'movie', 'series'] as const);
for (const type of types) {
for (const type of this.getContentCacheLookupTypes(contentType)) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = content.find((item) => {
const itemXtreamId =
item.stream_id || item.series_id || item.id;
return itemXtreamId === xtreamId;
});
const found = content.find(
(item) => this.resolveContentId(item) === xtreamId
);
if (found) {
return {
contentType: type,
contentType: type === 'vod' ? 'movie' : type,
xtreamId,
};
}
@@ -683,6 +770,30 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return null;
}
private findCachedContentById(
playlistId: string,
contentId: number
): XtreamCachedContentItem | undefined {
for (const type of this.getContentCacheLookupTypes()) {
const cacheKey = `${playlistId}-${type}-content`;
const content = this.contentCache.get(cacheKey) || [];
const found = content.find(
(item) => this.resolveContentId(item) === contentId
);
if (found) {
return found;
}
}
return undefined;
}
private resolveContentId(item: XtreamCachedContentItem): number | null {
const id = Number(item.stream_id ?? item.series_id ?? item.id);
return Number.isFinite(id) && id > 0 ? id : null;
}
// =========================================================================
// Cleanup Operations
// =========================================================================
@@ -697,7 +808,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
await this.getAllPlaybackPositions(playlistId);
const typedFavorites = (favorites[playlistId] || [])
.map((xtreamId) => this.findContentIdentity(playlistId, xtreamId))
.map((favorite) =>
this.findContentIdentity(
playlistId,
this.getStoredFavoriteId(favorite)
)
)
.filter(
(
value
@@ -1,7 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { patchState, signalStore, withState } from '@ngrx/signals';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withFavorites } from './with-favorites.feature';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -14,38 +13,42 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
}));
const TestFavoritesStore = signalStore(withFavorites());
const TestLoadedContentFavoritesStore = signalStore(
withState({
vodStreams: [
{
stream_id: 20203,
title: 'Movie',
type: 'movie',
},
],
}),
withFavorites()
);
describe('withFavorites', () => {
let store: InstanceType<typeof TestFavoritesStore>;
let databaseService: {
let dataSource: {
addFavorite: jest.Mock;
getContentByXtreamId: jest.Mock;
};
let favoritesService: {
addToFavorites: jest.Mock;
isFavorite: jest.Mock;
removeFromFavorites: jest.Mock;
removeFavorite: jest.Mock;
};
beforeEach(() => {
databaseService = {
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
};
favoritesService = {
addToFavorites: jest.fn().mockResolvedValue(undefined),
isFavorite: jest.fn().mockResolvedValue(false),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
removeFavorite: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: FavoritesService,
useValue: favoritesService,
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
@@ -54,7 +57,7 @@ describe('withFavorites', () => {
});
it('looks favorites up with the requested content type before adding one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -63,21 +66,122 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(favoritesService.addToFavorites).toHaveBeenCalledWith({
content_id: 3941697,
playlist_id: 'playlist-1',
});
expect(dataSource.addFavorite).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
);
expect(result).toBe(true);
expect(store.isFavorite()).toBe(true);
});
it('uses stream_id as the PWA favorite id when no database id exists', async () => {
dataSource.getContentByXtreamId.mockResolvedValue({
stream_id: 20203,
title: 'Movie',
type: 'movie',
});
const result = await store.toggleFavorite(20203, 'playlist-1', 'movie');
expect(dataSource.addFavorite).toHaveBeenCalledWith(
20203,
'playlist-1',
undefined
);
expect(result).toBe(true);
});
it('normalizes route string ids before looking up PWA content', async () => {
dataSource.getContentByXtreamId.mockImplementation(
async (xtreamId: number) =>
xtreamId === 20203
? {
stream_id: 20203,
title: 'Movie',
type: 'movie',
}
: null
);
const result = await store.toggleFavorite(
'20203',
'playlist-1',
'movie'
);
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
20203,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
20203,
'playlist-1',
undefined
);
expect(result).toBe(true);
});
it('normalizes route string ids before checking PWA favorite status', async () => {
dataSource.getContentByXtreamId.mockImplementation(
async (xtreamId: number) =>
xtreamId === 20203
? {
stream_id: 20203,
title: 'Movie',
type: 'movie',
}
: null
);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus('20203', 'playlist-1', 'movie');
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
20203,
'playlist-1',
'movie'
);
expect(dataSource.isFavorite).toHaveBeenCalledWith(20203, 'playlist-1');
expect(store.isFavorite()).toBe(true);
});
it('falls back to loaded store content when the PWA source cache misses', async () => {
dataSource.getContentByXtreamId.mockResolvedValue(null);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
TestLoadedContentFavoritesStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
],
});
const loadedStore = TestBed.inject(TestLoadedContentFavoritesStore);
const result = await loadedStore.toggleFavorite(
20203,
'playlist-1',
'movie'
);
expect(dataSource.addFavorite).toHaveBeenCalledWith(
20203,
'playlist-1',
undefined
);
expect(result).toBe(true);
});
it('looks favorites up with the requested content type before removing one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3867578,
title: 'SE: V Film Premiere FHD',
type: 'live',
@@ -87,12 +191,12 @@ describe('withFavorites', () => {
const result = await store.toggleFavorite(290, 'playlist-1', 'live');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'live'
);
expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith(
expect(dataSource.removeFavorite).toHaveBeenCalledWith(
3867578,
'playlist-1'
);
@@ -101,22 +205,22 @@ describe('withFavorites', () => {
});
it('checks favorite state against the matching content type', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3829429,
title: 'Dragon Ball Heroes',
type: 'series',
xtream_id: 31,
});
favoritesService.isFavorite.mockResolvedValue(true);
dataSource.isFavorite.mockResolvedValue(true);
await store.checkFavoriteStatus(31, 'playlist-1', 'series');
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
31,
'playlist-1',
'series'
);
expect(favoritesService.isFavorite).toHaveBeenCalledWith(
expect(dataSource.isFavorite).toHaveBeenCalledWith(
3829429,
'playlist-1'
);
@@ -5,9 +5,43 @@ import {
withMethods,
withState,
} from '@ngrx/signals';
import { DatabaseService } from '@iptvnator/services';
import { FavoritesService } from './services/favorites.service';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
function resolveContentId(content: Record<string, unknown>): number | null {
const rawId =
content['id'] ??
content['stream_id'] ??
content['series_id'] ??
content['xtream_id'];
const id = Number(rawId);
return Number.isFinite(id) && id > 0 ? id : null;
}
function findLoadedContent(
store: unknown,
xtreamId: number,
contentType: 'live' | 'movie' | 'series'
): Record<string, unknown> | null {
const storeWithContent = store as {
liveStreams?: () => Record<string, unknown>[];
vodStreams?: () => Record<string, unknown>[];
serialStreams?: () => Record<string, unknown>[];
};
const items =
contentType === 'live'
? storeWithContent.liveStreams?.()
: contentType === 'movie'
? storeWithContent.vodStreams?.()
: storeWithContent.serialStreams?.();
return (
items?.find((item) => {
const itemId = resolveContentId(item);
return itemId === xtreamId;
}) ?? null
);
}
export const withFavorites = function () {
const logger = createLogger('withFavorites');
@@ -15,85 +49,104 @@ export const withFavorites = function () {
withState({
isFavorite: false,
}),
withMethods(
(
store,
dbService = inject(DatabaseService),
favoritesService = inject(FavoritesService)
) => ({
async toggleFavorite(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
async toggleFavorite(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series',
backdropUrl?: string
) {
const numericXtreamId = Number(xtreamId);
if (
!Number.isFinite(numericXtreamId) ||
numericXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
return false;
}
return false;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
const content =
(await dataSource.getContentByXtreamId(
numericXtreamId,
playlistId,
contentType
)) ??
findLoadedContent(store, numericXtreamId, contentType);
if (!content) {
logger.error(
'Content not found for xtream ID',
numericXtreamId
);
if (!content) {
logger.error(
'Content not found for xtream ID',
xtreamId
);
return false;
}
return false;
}
const contentId = resolveContentId(
content as unknown as Record<string, unknown>
);
if (!contentId) {
logger.error('Content has no favorite id', content);
return false;
}
const currentStatus = store.isFavorite();
const currentStatus = store.isFavorite();
if (currentStatus) {
// Remove from favorites
await favoritesService.removeFromFavorites(
content.id,
playlistId
);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await favoritesService.addToFavorites({
content_id: content.id,
playlist_id: playlistId,
backdrop_url: backdropUrl,
});
patchState(store, { isFavorite: true });
return true;
}
},
if (currentStatus) {
// Remove from favorites
await dataSource.removeFavorite(contentId, playlistId);
patchState(store, { isFavorite: false });
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
contentId,
playlistId,
backdropUrl
);
patchState(store, { isFavorite: true });
return true;
}
},
async checkFavoriteStatus(
xtreamId: number,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
async checkFavoriteStatus(
xtreamId: number | string,
playlistId: string,
contentType: 'live' | 'movie' | 'series'
) {
const numericXtreamId = Number(xtreamId);
if (
!Number.isFinite(numericXtreamId) ||
numericXtreamId <= 0 ||
!playlistId
) {
if (!xtreamId || !playlistId) {
patchState(store, { isFavorite: false });
return;
}
patchState(store, { isFavorite: false });
return;
}
const content = await dbService.getContentByXtreamId(
xtreamId,
const content =
(await dataSource.getContentByXtreamId(
numericXtreamId,
playlistId,
contentType
);
if (!content) {
patchState(store, { isFavorite: false });
return;
}
)) ??
findLoadedContent(store, numericXtreamId, contentType);
if (!content) {
patchState(store, { isFavorite: false });
return;
}
const contentId = resolveContentId(
content as unknown as Record<string, unknown>
);
if (!contentId) {
patchState(store, { isFavorite: false });
return;
}
const isFavorite = await favoritesService.isFavorite(
content.id,
playlistId
);
const isFavorite = await dataSource.isFavorite(
contentId,
playlistId
);
patchState(store, { isFavorite });
},
})
)
patchState(store, { isFavorite });
},
}))
);
};
@@ -1,8 +1,9 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { signalStore } from '@ngrx/signals';
import { signalStore, withState } from '@ngrx/signals';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
import { withRecentItems } from './with-recent-items';
jest.mock('@iptvnator/portal/shared/util', () => ({
@@ -15,11 +16,30 @@ jest.mock('@iptvnator/portal/shared/util', () => ({
}));
const TestRecentItemsStore = signalStore(withRecentItems());
const TestLoadedContentRecentItemsStore = signalStore(
withState({
vodStreams: [
{
stream_id: 20203,
title: 'Movie',
type: 'movie',
category_id: 206,
},
],
}),
withRecentItems()
);
describe('withRecentItems', () => {
let store: InstanceType<typeof TestRecentItemsStore>;
let databaseService: {
let dataSource: {
addRecentItem: jest.Mock;
clearRecentItems: jest.Mock;
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
};
let databaseService: {
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
@@ -31,8 +51,9 @@ describe('withRecentItems', () => {
configurable: true,
});
databaseService = {
dataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
clearRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([
{
@@ -46,12 +67,22 @@ describe('withRecentItems', () => {
category_id: 17,
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
};
databaseService = {
getContentByXtreamId: jest.fn(),
getRecentItems: jest.fn().mockResolvedValue([]),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
};
TestBed.configureTestingModule({
providers: [
TestRecentItemsStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: DatabaseService,
useValue: databaseService,
@@ -59,9 +90,9 @@ describe('withRecentItems', () => {
{
provide: PlaylistsService,
useValue: {
clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(
of(undefined)
),
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
},
},
@@ -72,7 +103,7 @@ describe('withRecentItems', () => {
});
it('looks recent items up with the requested content type before saving one', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -86,12 +117,12 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
290,
'playlist-1',
'series'
);
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
undefined
@@ -107,8 +138,71 @@ describe('withRecentItems', () => {
]);
});
it('uses stream_id as the PWA recent id when no database id exists', async () => {
dataSource.getContentByXtreamId.mockResolvedValue({
stream_id: 20203,
title: 'Movie',
type: 'movie',
category_id: 206,
});
store.addRecentItem({
xtreamId: 20203,
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
20203,
'playlist-1',
undefined
);
});
it('falls back to loaded store content when the PWA source cache misses', async () => {
dataSource.getContentByXtreamId.mockResolvedValue(null);
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
TestLoadedContentRecentItemsStore,
{
provide: XTREAM_DATA_SOURCE,
useValue: dataSource,
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistsService,
useValue: {
clearPlaylistRecentlyViewed: jest
.fn()
.mockReturnValue(of(undefined)),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
},
},
],
});
const loadedStore = TestBed.inject(TestLoadedContentRecentItemsStore);
loadedStore.addRecentItem({
xtreamId: 20203,
contentType: 'movie',
playlist: signal({ id: 'playlist-1' }),
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
20203,
'playlist-1',
undefined
);
});
it('forwards backdrop urls on recent-item saves', async () => {
databaseService.getContentByXtreamId.mockResolvedValue({
dataSource.getContentByXtreamId.mockResolvedValue({
id: 3941697,
title: 'Krypton',
type: 'series',
@@ -123,7 +217,7 @@ describe('withRecentItems', () => {
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(databaseService.addRecentItem).toHaveBeenCalledWith(
expect(dataSource.addRecentItem).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
@@ -156,6 +250,6 @@ describe('withRecentItems', () => {
3941697,
'https://example.com/krypton-backdrop.png'
);
expect(databaseService.addRecentItem).not.toHaveBeenCalled();
expect(dataSource.addRecentItem).not.toHaveBeenCalled();
});
});
@@ -14,35 +14,80 @@ import {
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
export interface RecentlyViewedItem extends PortalRecentItem {
/** @deprecated Redundant — always equals `id`. Retained for compat. */
content_id: number | string;
}
function resolveContentId(item: Record<string, unknown>): number | null {
const rawId =
item['id'] ??
item['stream_id'] ??
item['series_id'] ??
item['xtream_id'];
const id = Number(rawId);
return Number.isFinite(id) && id > 0 ? id : null;
}
function findLoadedContent(
store: unknown,
xtreamId: number,
contentType: 'live' | 'movie' | 'series'
): Record<string, unknown> | null {
const storeWithContent = store as {
liveStreams?: () => Record<string, unknown>[];
vodStreams?: () => Record<string, unknown>[];
serialStreams?: () => Record<string, unknown>[];
};
const items =
contentType === 'live'
? storeWithContent.liveStreams?.()
: contentType === 'movie'
? storeWithContent.vodStreams?.()
: storeWithContent.serialStreams?.();
return (
items?.find((item) => {
const itemId = resolveContentId(item);
return itemId === xtreamId;
}) ?? null
);
}
function mapDbRecentItem(
item: {
id: number;
title: string;
id?: number | string;
title?: string;
name?: string;
type: string;
poster_url: string;
poster_url?: string;
stream_icon?: string;
cover?: string;
backdrop_url?: string | null;
viewed_at?: string;
xtream_id: number;
category_id: number;
xtream_id?: number | string;
stream_id?: number | string;
series_id?: number | string;
category_id: number | string;
},
playlistId: string
): RecentlyViewedItem {
const id = resolveContentId(item) ?? 0;
return {
id: item.id,
title: item.title,
id,
title: item.title ?? item.name ?? 'Unknown',
type: item.type as 'live' | 'movie' | 'series',
poster_url: item.poster_url,
poster_url: item.poster_url ?? item.stream_icon ?? item.cover ?? '',
backdrop_url: item.backdrop_url ?? undefined,
content_id: item.id,
content_id: id,
playlist_id: playlistId,
viewed_at: item.viewed_at || '',
xtream_id: item.xtream_id,
xtream_id: Number(
item.xtream_id ?? item.stream_id ?? item.series_id ?? id
),
category_id: item.category_id,
};
}
@@ -53,11 +98,11 @@ export const withRecentItems = function () {
withState({
recentItems: [],
}),
withMethods((store, dbService = inject(DatabaseService)) => ({
withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({
loadRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
const items = await dbService.getRecentItems(
const items = await dataSource.getRecentItems(
playlist.id
);
return items.map((item) =>
@@ -73,6 +118,7 @@ export const withRecentItems = function () {
withMethods(
(
store,
dataSource = inject(XTREAM_DATA_SOURCE),
dbService = inject(DatabaseService),
playlistsService = inject(PlaylistsService)
) => ({
@@ -90,30 +136,47 @@ export const withRecentItems = function () {
playlist,
backdropUrl,
}) => {
const playlistId = playlist().id;
const content = await dbService.getContentByXtreamId(
xtreamId,
playlistId,
contentType
);
if (content) {
await dbService.addRecentItem(
content.id,
playlistId,
backdropUrl
);
const playlistId = playlist().id;
const content =
(await dataSource.getContentByXtreamId(
xtreamId,
playlistId,
contentType
)) ??
findLoadedContent(
store,
xtreamId,
contentType
);
const contentId = content
? resolveContentId(
content as unknown as Record<
string,
unknown
>
)
: null;
if (contentId) {
await dataSource.addRecentItem(
contentId,
playlistId,
backdropUrl
);
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dbService.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
// Reload after add/update so re-watched items
// immediately move to the top in recently-viewed.
const items =
await dataSource.getRecentItems(
playlistId
);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
),
});
}
}
})
)
)
),
async backfillContentBackdrop({
@@ -154,9 +217,7 @@ export const withRecentItems = function () {
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
await dbService.clearPlaylistRecentItems(
playlist.id
);
await dataSource.clearRecentItems(playlist.id);
patchState(store, { recentItems: [] });
})
)
@@ -167,13 +228,13 @@ export const withRecentItems = function () {
}>(
pipe(
switchMap(async ({ itemId, playlistId }) => {
await dbService.removeRecentItem(
await dataSource.removeRecentItem(
itemId,
playlistId
);
// Reload recent items to update UI
const items =
await dbService.getRecentItems(playlistId);
await dataSource.getRecentItems(playlistId);
patchState(store, {
recentItems: items.map((item) =>
mapDbRecentItem(item, playlistId)
@@ -189,14 +250,16 @@ export const withRecentItems = function () {
const playlists = (await firstValueFrom(
playlistsService.getAllPlaylists()
)) as Playlist[];
const playlistBackedItems =
buildPlaylistRecentItems(playlists, {
const playlistBackedItems = buildPlaylistRecentItems(
playlists,
{
stalker: 'Stalker Portal',
m3u: 'M3U',
}).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
}
).map((item) => ({
...item,
content_id: item.id,
})) as RecentlyViewedItem[];
const normalizedXtream: RecentlyViewedItem[] = (
xtreamItems || []
@@ -1,8 +1,6 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
PortalCatalogSortMode,
} from '@iptvnator/portal/shared/util';
import { PortalCatalogSortMode } from '@iptvnator/portal/shared/util';
import {
XtreamPlaylistData,
XtreamStore,
@@ -180,6 +178,43 @@ describe('XtreamCatalogFacadeService', () => {
);
});
it('routes raw Xtream VOD stream items by stream_id inside a category', () => {
selectedCategoryId.set(201);
expect(
service.selectItem({
stream_id: 650020,
category_id: '201',
title: 'Movie',
})
).toEqual(['650020']);
});
it('routes raw Xtream VOD stream items from the all-items view with their category', () => {
selectedCategoryId.set(null);
expect(
service.selectItem({
stream_id: 650020,
category_id: '201',
title: 'Movie',
})
).toEqual(['201', '650020']);
});
it('routes raw Xtream series items by series_id', () => {
contentType.set('series');
selectedCategoryId.set(301);
expect(
service.selectItem({
series_id: 103,
category_id: '301',
title: 'Series',
})
).toEqual(['103']);
});
it('persists sort mode changes and delegates them to the store', () => {
service.setContentSortMode('name-desc');
@@ -98,14 +98,14 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
}
selectItem(item: Record<string, unknown>): string[] | null {
const xtreamId = item['xtream_id'];
if (xtreamId === undefined || xtreamId === null) {
const itemId = this.getCatalogItemId(item);
if (itemId === undefined || itemId === null) {
return null;
}
const selectedCategoryId = this.xtreamStore.selectedCategoryId();
if (selectedCategoryId !== null && selectedCategoryId !== undefined) {
return [String(xtreamId)];
return [String(itemId)];
}
const categoryId = item['category_id'];
@@ -113,14 +113,12 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
return null;
}
return [String(categoryId), String(xtreamId)];
return [String(categoryId), String(itemId)];
}
getItemProgress(item: Record<string, unknown>): PortalCatalogItemProgress {
const isSeries = this.contentType() === 'series';
const itemId = Number(
item['xtream_id'] ?? item['series_id'] ?? item['stream_id']
);
const itemId = Number(this.getCatalogItemId(item));
if (Number.isNaN(itemId)) {
return {};
}
@@ -136,6 +134,17 @@ export class XtreamCatalogFacadeService implements PortalCatalogFacade<
isWatched: this.xtreamStore.isWatched(itemId, 'vod'),
};
}
private getCatalogItemId(
item: Record<string, unknown>
): string | number | null | undefined {
const itemId =
item['xtream_id'] ?? item['series_id'] ?? item['stream_id'];
return typeof itemId === 'string' || typeof itemId === 'number'
? itemId
: null;
}
}
export function provideXtreamCatalogFacade(): Provider[] {