mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
fix(xtream): persist PWA activity from catalog content
This commit is contained in:
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[] {
|
||||
|
||||
Reference in new issue
Block a user