mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
fix(workspace): surface PWA Xtream collections
This commit is contained in:
1 parent
162a7902ae
commit
fcfdeae9dc
9 files changed
+875
-53
No files matched your search
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
ApplicationConfig,
|
||||
importProvidersFrom,
|
||||
inject,
|
||||
provideZoneChangeDetection,
|
||||
} from '@angular/core';
|
||||
import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
|
||||
@@ -24,9 +25,13 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import {
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
PORTAL_PLAYER,
|
||||
XTREAM_COLLECTION_DATA_SOURCE,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util';
|
||||
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
provideXtreamDataSource,
|
||||
XTREAM_DATA_SOURCE,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { DataService } from '@iptvnator/services';
|
||||
import { dbConfig } from '@iptvnator/shared/interfaces';
|
||||
import { AppConfig } from '../environments/environment';
|
||||
@@ -153,6 +158,10 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
...provideWorkspaceShellActions(),
|
||||
...provideXtreamDataSource(),
|
||||
{
|
||||
provide: XTREAM_COLLECTION_DATA_SOURCE,
|
||||
useFactory: () => inject(XTREAM_DATA_SOURCE),
|
||||
},
|
||||
{
|
||||
provide: MAT_FORM_FIELD_DEFAULT_OPTIONS,
|
||||
useValue: { appearance: 'outline', subscriptSizing: 'dynamic' },
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from './scope-toggle.service';
|
||||
export * from './unified-favorites-data.service';
|
||||
export * from './unified-recent-data.service';
|
||||
export * from './stream-resolver.service';
|
||||
export * from './xtream-collection-data-source.token';
|
||||
@@ -11,12 +11,17 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
|
||||
import {
|
||||
XTREAM_COLLECTION_DATA_SOURCE,
|
||||
XtreamCollectionDataSourceItem,
|
||||
} from './xtream-collection-data-source.token';
|
||||
|
||||
describe('UnifiedFavoritesDataService', () => {
|
||||
let service: UnifiedFavoritesDataService;
|
||||
let electronApi: {
|
||||
dbAddFavorite: jest.Mock;
|
||||
dbGetAllGlobalFavorites: jest.Mock;
|
||||
dbGetFavorites: jest.Mock;
|
||||
dbRemoveFavorite: jest.Mock;
|
||||
};
|
||||
let databaseService: {
|
||||
@@ -34,6 +39,11 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
setFavorites: jest.Mock;
|
||||
setPortalFavorites: jest.Mock;
|
||||
};
|
||||
let xtreamDataSource: {
|
||||
addFavorite: jest.Mock;
|
||||
getFavorites: jest.Mock;
|
||||
removeFavorite: jest.Mock;
|
||||
};
|
||||
|
||||
const m3uChannels: Channel[] = [
|
||||
{
|
||||
@@ -93,6 +103,7 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
electronApi = {
|
||||
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
|
||||
dbGetAllGlobalFavorites: jest.fn(),
|
||||
dbGetFavorites: jest.fn(),
|
||||
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
Object.defineProperty(window, 'electron', {
|
||||
@@ -126,9 +137,21 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
macAddress: '00:11:22:33:44:55',
|
||||
favorites: stalkerFavorites,
|
||||
},
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream One',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
},
|
||||
] satisfies PlaylistMeta[])
|
||||
),
|
||||
};
|
||||
xtreamDataSource = {
|
||||
addFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
removeFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -145,6 +168,10 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsService,
|
||||
},
|
||||
{
|
||||
provide: XTREAM_COLLECTION_DATA_SOURCE,
|
||||
useValue: xtreamDataSource,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -400,6 +427,80 @@ describe('UnifiedFavoritesDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('adds Xtream favorites through the PWA data source when Electron favorites APIs are absent', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
await service.addFavorite({
|
||||
uid: 'xtream::xtream-1::movie:20203',
|
||||
name: 'PWA Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
posterUrl: 'movie.png',
|
||||
xtreamId: 20203,
|
||||
} satisfies UnifiedCollectionItem);
|
||||
|
||||
expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith(
|
||||
20203,
|
||||
'xtream-1',
|
||||
'movie.png'
|
||||
);
|
||||
});
|
||||
|
||||
it('maps PWA Xtream favorites from the shared data source without Electron DB APIs', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
store.select.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
_id: 'xtream-1',
|
||||
title: 'Xtream One',
|
||||
serverUrl: 'https://xtream.example.com',
|
||||
} satisfies Partial<PlaylistMeta>,
|
||||
])
|
||||
);
|
||||
xtreamDataSource.getFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 20203,
|
||||
stream_id: 20203,
|
||||
name: 'PWA Movie',
|
||||
stream_type: 'movie',
|
||||
stream_icon: 'movie.png',
|
||||
category_id: '2',
|
||||
rating: '8.1',
|
||||
added_at: '2026-03-26T08:00:00.000Z',
|
||||
} satisfies XtreamCollectionDataSourceItem,
|
||||
]);
|
||||
|
||||
const items = await service.getFavorites('all');
|
||||
|
||||
expect(databaseService.getAllGlobalFavorites).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith(
|
||||
'xtream-1'
|
||||
);
|
||||
expect(items).toEqual([
|
||||
expect.objectContaining({
|
||||
uid: 'xtream::xtream-1::movie:20203',
|
||||
name: 'PWA Movie',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream One',
|
||||
posterUrl: 'movie.png',
|
||||
xtreamId: 20203,
|
||||
contentId: 20203,
|
||||
rating: '8.1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds Stalker favorites through portal favorites', async () => {
|
||||
await service.addFavorite({
|
||||
uid: 'stalker::stalker-1::101',
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
xtreamContentType,
|
||||
XtreamFavoriteRow,
|
||||
} from './collection-helpers';
|
||||
import {
|
||||
XTREAM_COLLECTION_DATA_SOURCE,
|
||||
XtreamCollectionDataSourceItem,
|
||||
} from './xtream-collection-data-source.token';
|
||||
|
||||
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
|
||||
|
||||
@@ -44,6 +48,7 @@ export class UnifiedFavoritesDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
async getFavorites(
|
||||
@@ -88,11 +93,19 @@ export class UnifiedFavoritesDataService {
|
||||
break;
|
||||
}
|
||||
case 'xtream':
|
||||
if (window.electron && item.contentId != null) {
|
||||
if (this.hasElectronFavoritesApi() && item.contentId != null) {
|
||||
await window.electron.dbRemoveFavorite(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
);
|
||||
} else {
|
||||
const contentId = this.getXtreamItemId(item);
|
||||
if (contentId != null) {
|
||||
await this.xtreamDataSource.removeFavorite(
|
||||
contentId,
|
||||
item.playlistId
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'stalker': {
|
||||
@@ -136,31 +149,36 @@ export class UnifiedFavoritesDataService {
|
||||
private async addXtreamFavorite(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentId =
|
||||
item.contentId ??
|
||||
(item.xtreamId != null
|
||||
? (
|
||||
await this.dbService.getContentByXtreamId(
|
||||
item.xtreamId,
|
||||
item.playlistId,
|
||||
item.contentType
|
||||
)
|
||||
)?.id
|
||||
: null);
|
||||
const contentId = this.hasElectronFavoritesApi()
|
||||
? (item.contentId ??
|
||||
(item.xtreamId != null
|
||||
? (
|
||||
await this.dbService.getContentByXtreamId(
|
||||
item.xtreamId,
|
||||
item.playlistId,
|
||||
item.contentType
|
||||
)
|
||||
)?.id
|
||||
: null))
|
||||
: this.getXtreamItemId(item);
|
||||
|
||||
if (contentId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.electron.dbAddFavorite(
|
||||
contentId,
|
||||
item.playlistId,
|
||||
item.posterUrl ?? item.logo ?? undefined
|
||||
);
|
||||
if (this.hasElectronFavoritesApi()) {
|
||||
await window.electron.dbAddFavorite(
|
||||
contentId,
|
||||
item.playlistId,
|
||||
item.posterUrl ?? item.logo ?? undefined
|
||||
);
|
||||
} else {
|
||||
await this.xtreamDataSource.addFavorite(
|
||||
contentId,
|
||||
item.playlistId,
|
||||
item.posterUrl ?? item.logo ?? undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async addStalkerFavorite(
|
||||
@@ -283,14 +301,14 @@ export class UnifiedFavoritesDataService {
|
||||
options.playlistId &&
|
||||
options.portalType === 'xtream'
|
||||
) {
|
||||
if (xtreamUpdates.length > 0 && window.electron) {
|
||||
if (xtreamUpdates.length > 0 && this.hasElectronReorderApi()) {
|
||||
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
xtreamUpdates.length > 0 && window.electron
|
||||
xtreamUpdates.length > 0 && this.hasElectronReorderApi()
|
||||
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
|
||||
: Promise.resolve(),
|
||||
this.saveOrder(items.map((item) => item.uid)),
|
||||
@@ -346,16 +364,35 @@ export class UnifiedFavoritesDataService {
|
||||
private async clearXtreamFavorites(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<void> {
|
||||
if (!window.electron) {
|
||||
if (this.hasElectronFavoritesApi()) {
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((item) => item.contentId != null)
|
||||
.map((item) =>
|
||||
window.electron!.dbRemoveFavorite(
|
||||
item.contentId!,
|
||||
item.playlistId
|
||||
)
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((item) => item.contentId != null)
|
||||
.map((item) => ({
|
||||
playlistId: item.playlistId,
|
||||
contentId: this.getXtreamItemId(item),
|
||||
}))
|
||||
.filter(
|
||||
(
|
||||
item
|
||||
): item is { playlistId: string; contentId: number } =>
|
||||
item.contentId != null
|
||||
)
|
||||
.map((item) =>
|
||||
window.electron!.dbRemoveFavorite(
|
||||
item.contentId!,
|
||||
this.xtreamDataSource.removeFavorite(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
)
|
||||
)
|
||||
@@ -484,7 +521,19 @@ export class UnifiedFavoritesDataService {
|
||||
}
|
||||
|
||||
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron?.dbGetAllGlobalFavorites) return [];
|
||||
if (!this.hasElectronFavoritesApi()) {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
|
||||
for (const meta of allMeta.filter((playlist) =>
|
||||
Boolean(playlist.serverUrl)
|
||||
)) {
|
||||
results.push(...(await this.getPwaXtreamFavorites(meta)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows =
|
||||
(await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
|
||||
@@ -497,7 +546,11 @@ export class UnifiedFavoritesDataService {
|
||||
private async getXtreamPlaylistFavorites(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron) return [];
|
||||
if (!this.hasElectronPlaylistFavoritesApi()) {
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return meta ? this.getPwaXtreamFavorites(meta) : [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await this.dbService.getFavorites(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
@@ -511,6 +564,122 @@ export class UnifiedFavoritesDataService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPwaXtreamFavorites(
|
||||
meta: PlaylistMeta
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.xtreamDataSource.getFavorites(meta._id);
|
||||
return rows.map((row, index) =>
|
||||
this.mapPwaXtreamFavoriteItem(row, meta, index)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapPwaXtreamFavoriteItem(
|
||||
row: XtreamCollectionDataSourceItem,
|
||||
meta: PlaylistMeta,
|
||||
index: number
|
||||
): UnifiedCollectionItem {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
const contentType = this.getPwaXtreamContentType(record);
|
||||
const xtreamId = this.getXtreamNumericValue(record, [
|
||||
'xtream_id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'id',
|
||||
]);
|
||||
const contentId = this.getXtreamNumericValue(record, [
|
||||
'id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'xtream_id',
|
||||
]);
|
||||
const title =
|
||||
this.getXtreamString(record['title']) ??
|
||||
this.getXtreamString(record['name']) ??
|
||||
this.getXtreamString(record['stream_display_name']) ??
|
||||
'Unknown';
|
||||
const image =
|
||||
this.getXtreamString(record['poster_url']) ??
|
||||
this.getXtreamString(record['stream_icon']) ??
|
||||
this.getXtreamString(record['cover']) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId),
|
||||
name: title,
|
||||
contentType,
|
||||
sourceType: 'xtream',
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'Xtream',
|
||||
logo: contentType === 'live' ? image : null,
|
||||
posterUrl: contentType !== 'live' ? image : null,
|
||||
xtreamId,
|
||||
categoryId: record['category_id'] as string | number,
|
||||
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
|
||||
rating: this.getXtreamString(record['rating']),
|
||||
addedAt:
|
||||
normalizeStalkerDate(
|
||||
this.getXtreamString(record['added_at']) ??
|
||||
this.getXtreamString(record['added']) ??
|
||||
''
|
||||
) || new Date(0).toISOString(),
|
||||
position: index,
|
||||
contentId,
|
||||
};
|
||||
}
|
||||
|
||||
private hasElectronFavoritesApi(): boolean {
|
||||
return typeof window.electron?.dbGetAllGlobalFavorites === 'function';
|
||||
}
|
||||
|
||||
private hasElectronPlaylistFavoritesApi(): boolean {
|
||||
return typeof window.electron?.dbGetFavorites === 'function';
|
||||
}
|
||||
|
||||
private hasElectronReorderApi(): boolean {
|
||||
return typeof window.electron?.dbReorderGlobalFavorites === 'function';
|
||||
}
|
||||
|
||||
private getXtreamItemId(item: UnifiedCollectionItem): number | null {
|
||||
const value = Number(item.contentId ?? item.xtreamId);
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
private getPwaXtreamContentType(
|
||||
item: Record<string, unknown>
|
||||
): UnifiedCollectionItem['contentType'] {
|
||||
if (item['series_id'] != null) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
return xtreamContentType(
|
||||
String(item['type'] ?? item['stream_type'] ?? 'movie')
|
||||
);
|
||||
}
|
||||
|
||||
private getXtreamString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private getXtreamNumericValue(
|
||||
item: Record<string, unknown>,
|
||||
keys: string[]
|
||||
): number {
|
||||
for (const key of keys) {
|
||||
const value = Number(item[key]);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
|
||||
const ct = xtreamContentType(row.type);
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
import { UnifiedRecentDataService } from './unified-recent-data.service';
|
||||
import { XTREAM_COLLECTION_DATA_SOURCE } from './xtream-collection-data-source.token';
|
||||
|
||||
describe('UnifiedRecentDataService', () => {
|
||||
let service: UnifiedRecentDataService;
|
||||
@@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => {
|
||||
addRecentItem: jest.Mock;
|
||||
getContentByXtreamId: jest.Mock;
|
||||
};
|
||||
let xtreamDataSource: {
|
||||
addRecentItem: jest.Mock;
|
||||
clearRecentItems: jest.Mock;
|
||||
getRecentItems: jest.Mock;
|
||||
removeRecentItem: jest.Mock;
|
||||
};
|
||||
|
||||
const playlistMeta = {
|
||||
_id: 'm3u-1',
|
||||
@@ -87,6 +94,12 @@ describe('UnifiedRecentDataService', () => {
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {
|
||||
dbGetRecentlyViewed: jest.fn(),
|
||||
} as unknown as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
store = {
|
||||
select: jest.fn(() => of([playlistMeta])),
|
||||
dispatch: jest.fn(),
|
||||
@@ -136,6 +149,12 @@ describe('UnifiedRecentDataService', () => {
|
||||
addRecentItem: jest.fn().mockResolvedValue(true),
|
||||
getContentByXtreamId: jest.fn().mockResolvedValue(null),
|
||||
};
|
||||
xtreamDataSource = {
|
||||
addRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
clearRecentItems: jest.fn().mockResolvedValue(undefined),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -143,6 +162,10 @@ describe('UnifiedRecentDataService', () => {
|
||||
{ provide: Store, useValue: store },
|
||||
{ provide: PlaylistsService, useValue: playlistsService },
|
||||
{ provide: DatabaseService, useValue: dbService },
|
||||
{
|
||||
provide: XTREAM_COLLECTION_DATA_SOURCE,
|
||||
useValue: xtreamDataSource,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
} from './unified-collection-item.interface';
|
||||
import { CollectionScope } from './scope-toggle.service';
|
||||
import { xtreamContentType } from './collection-helpers';
|
||||
import {
|
||||
XTREAM_COLLECTION_DATA_SOURCE,
|
||||
XtreamCollectionDataSourceItem,
|
||||
} from './xtream-collection-data-source.token';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
@@ -35,6 +39,7 @@ type PlaylistWithChannels = Playlist & {
|
||||
export class UnifiedRecentDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
async getRecentItems(
|
||||
@@ -55,10 +60,17 @@ export class UnifiedRecentDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dbService.removeRecentItem(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
);
|
||||
if (this.hasElectronRecentApi()) {
|
||||
await this.dbService.removeRecentItem(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
);
|
||||
} else {
|
||||
await this.xtreamDataSource.removeRecentItem(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,7 +138,20 @@ export class UnifiedRecentDataService {
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (xtreamBatch.length > 0) {
|
||||
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
|
||||
if (this.hasElectronRecentApi()) {
|
||||
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
|
||||
} else {
|
||||
tasks.push(
|
||||
Promise.all(
|
||||
xtreamBatch.map((item) =>
|
||||
this.xtreamDataSource.removeRecentItem(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [playlistId, identities] of groupedByPlaylist) {
|
||||
@@ -153,7 +178,11 @@ export class UnifiedRecentDataService {
|
||||
playlistId?: string
|
||||
): Promise<void> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
await this.dbService.clearPlaylistRecentItems(playlistId);
|
||||
if (this.hasElectronRecentApi()) {
|
||||
await this.dbService.clearPlaylistRecentItems(playlistId);
|
||||
} else {
|
||||
await this.xtreamDataSource.clearRecentItems(playlistId);
|
||||
}
|
||||
const updatedPlaylist = await firstValueFrom(
|
||||
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
|
||||
);
|
||||
@@ -161,7 +190,18 @@ export class UnifiedRecentDataService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.dbService.clearGlobalRecentlyViewed();
|
||||
if (this.hasElectronRecentApi()) {
|
||||
await this.dbService.clearGlobalRecentlyViewed();
|
||||
} else {
|
||||
const allMeta = await this.getAllMeta();
|
||||
await Promise.all(
|
||||
allMeta
|
||||
.filter((playlist) => Boolean(playlist.serverUrl))
|
||||
.map((playlist) =>
|
||||
this.xtreamDataSource.clearRecentItems(playlist._id)
|
||||
)
|
||||
);
|
||||
}
|
||||
const playlists = (await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
)) as Playlist[];
|
||||
@@ -223,17 +263,29 @@ export class UnifiedRecentDataService {
|
||||
const contentId =
|
||||
item.contentId ??
|
||||
(item.xtreamId != null
|
||||
? (
|
||||
await this.dbService.getContentByXtreamId(
|
||||
item.xtreamId,
|
||||
item.playlistId,
|
||||
item.contentType
|
||||
)
|
||||
)?.id
|
||||
? this.hasElectronRecentApi()
|
||||
? (
|
||||
await this.dbService.getContentByXtreamId(
|
||||
item.xtreamId,
|
||||
item.playlistId,
|
||||
item.contentType
|
||||
)
|
||||
)?.id
|
||||
: item.xtreamId
|
||||
: null);
|
||||
|
||||
if (contentId != null) {
|
||||
await this.dbService.addRecentItem(contentId, item.playlistId);
|
||||
if (this.hasElectronRecentApi()) {
|
||||
await this.dbService.addRecentItem(
|
||||
contentId,
|
||||
item.playlistId
|
||||
);
|
||||
} else {
|
||||
await this.xtreamDataSource.addRecentItem(
|
||||
contentId,
|
||||
item.playlistId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -309,6 +361,19 @@ export class UnifiedRecentDataService {
|
||||
}
|
||||
|
||||
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
if (!this.hasElectronRecentApi()) {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
|
||||
for (const meta of allMeta.filter((playlist) =>
|
||||
Boolean(playlist.serverUrl)
|
||||
)) {
|
||||
results.push(...(await this.getPwaXtreamRecent(meta)));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await this.dbService.getGlobalRecentlyViewed();
|
||||
return (rows || []).map((row) => ({
|
||||
@@ -339,6 +404,11 @@ export class UnifiedRecentDataService {
|
||||
private async getXtreamPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (!this.hasElectronRecentApi()) {
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return meta ? this.getPwaXtreamRecent(meta) : [];
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await this.dbService.getRecentItems(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
@@ -368,6 +438,101 @@ export class UnifiedRecentDataService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getPwaXtreamRecent(
|
||||
meta: PlaylistMeta
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.xtreamDataSource.getRecentItems(meta._id);
|
||||
return rows.map((row) => this.mapPwaXtreamRecentItem(row, meta));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapPwaXtreamRecentItem(
|
||||
row: XtreamCollectionDataSourceItem,
|
||||
meta: PlaylistMeta
|
||||
): UnifiedCollectionItem {
|
||||
const record = row as unknown as Record<string, unknown>;
|
||||
const contentType = this.getPwaXtreamContentType(record);
|
||||
const xtreamId = this.getXtreamNumericValue(record, [
|
||||
'xtream_id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'id',
|
||||
]);
|
||||
const contentId = this.getXtreamNumericValue(record, [
|
||||
'id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'xtream_id',
|
||||
]);
|
||||
const title =
|
||||
this.getXtreamString(record['title']) ??
|
||||
this.getXtreamString(record['name']) ??
|
||||
this.getXtreamString(record['stream_display_name']) ??
|
||||
'Unknown';
|
||||
const image =
|
||||
this.getXtreamString(record['poster_url']) ??
|
||||
this.getXtreamString(record['stream_icon']) ??
|
||||
this.getXtreamString(record['cover']) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId),
|
||||
name: title,
|
||||
contentType,
|
||||
sourceType: 'xtream',
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'Xtream',
|
||||
logo: contentType === 'live' ? image : null,
|
||||
posterUrl: contentType !== 'live' ? image : null,
|
||||
xtreamId,
|
||||
categoryId: record['category_id'] as string | number,
|
||||
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
|
||||
contentId,
|
||||
viewedAt: normalizeStalkerDate(
|
||||
this.getXtreamString(record['viewed_at']) ?? ''
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private hasElectronRecentApi(): boolean {
|
||||
return typeof window.electron?.dbGetRecentlyViewed === 'function';
|
||||
}
|
||||
|
||||
private getPwaXtreamContentType(
|
||||
item: Record<string, unknown>
|
||||
): UnifiedCollectionItem['contentType'] {
|
||||
if (item['series_id'] != null) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
return xtreamContentType(
|
||||
String(item['type'] ?? item['stream_type'] ?? 'movie')
|
||||
);
|
||||
}
|
||||
|
||||
private getXtreamString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private getXtreamNumericValue(
|
||||
item: Record<string, unknown>,
|
||||
keys: string[]
|
||||
): number {
|
||||
for (const key of keys) {
|
||||
const value = Number(item[key]);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export interface XtreamCollectionDataSourceItem {
|
||||
readonly id?: number;
|
||||
readonly category_id?: number | string;
|
||||
readonly title?: string;
|
||||
readonly name?: string;
|
||||
readonly rating?: string;
|
||||
readonly added?: string;
|
||||
readonly added_at?: string;
|
||||
readonly viewed_at?: string;
|
||||
readonly poster_url?: string;
|
||||
readonly backdrop_url?: string | null;
|
||||
readonly xtream_id?: number;
|
||||
readonly type?: string;
|
||||
readonly stream_type?: 'live' | 'movie';
|
||||
readonly stream_id?: number;
|
||||
readonly stream_icon?: string;
|
||||
readonly stream_display_name?: string;
|
||||
readonly cover?: string;
|
||||
readonly series_id?: number;
|
||||
}
|
||||
|
||||
export interface XtreamCollectionDataSource {
|
||||
getFavorites(playlistId: string): Promise<XtreamCollectionDataSourceItem[]>;
|
||||
addFavorite(
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
): Promise<void>;
|
||||
removeFavorite(contentId: number, playlistId: string): Promise<void>;
|
||||
getRecentItems(
|
||||
playlistId: string
|
||||
): Promise<XtreamCollectionDataSourceItem[]>;
|
||||
addRecentItem(
|
||||
contentId: number,
|
||||
playlistId: string,
|
||||
backdropUrl?: string
|
||||
): Promise<void>;
|
||||
removeRecentItem(contentId: number, playlistId: string): Promise<void>;
|
||||
clearRecentItems(playlistId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const noopXtreamCollectionDataSource: XtreamCollectionDataSource = {
|
||||
getFavorites: async () => [],
|
||||
addFavorite: async () => undefined,
|
||||
removeFavorite: async () => undefined,
|
||||
getRecentItems: async () => [],
|
||||
addRecentItem: async () => undefined,
|
||||
removeRecentItem: async () => undefined,
|
||||
clearRecentItems: async () => undefined,
|
||||
};
|
||||
|
||||
export const XTREAM_COLLECTION_DATA_SOURCE =
|
||||
new InjectionToken<XtreamCollectionDataSource>(
|
||||
'XtreamCollectionDataSource',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => noopXtreamCollectionDataSource,
|
||||
}
|
||||
);
|
||||
@@ -2,7 +2,11 @@ import { signal } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
|
||||
import { of } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
|
||||
import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
@@ -68,6 +72,12 @@ describe('DashboardDataService', () => {
|
||||
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const xtreamDataSourceMock = {
|
||||
getFavorites: jest.fn().mockResolvedValue([]),
|
||||
getRecentItems: jest.fn().mockResolvedValue([]),
|
||||
removeFavorite: jest.fn().mockResolvedValue(undefined),
|
||||
removeRecentItem: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const playlistMock: Playlist = {
|
||||
_id: 'm3u-1',
|
||||
title: 'M3U Playlist',
|
||||
@@ -114,7 +124,11 @@ describe('DashboardDataService', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
value: {
|
||||
dbGetAllGlobalFavorites: jest.fn(),
|
||||
dbGetGlobalRecentlyAdded: jest.fn(),
|
||||
dbGetRecentlyViewed: jest.fn(),
|
||||
} as unknown as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
playlistsLoadedSignal.set(true);
|
||||
@@ -138,6 +152,14 @@ describe('DashboardDataService', () => {
|
||||
dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]);
|
||||
dbServiceMock.removeFromFavorites.mockClear();
|
||||
dbServiceMock.removeRecentItem.mockClear();
|
||||
xtreamDataSourceMock.getFavorites.mockClear();
|
||||
xtreamDataSourceMock.getFavorites.mockResolvedValue([]);
|
||||
xtreamDataSourceMock.getRecentItems.mockClear();
|
||||
xtreamDataSourceMock.getRecentItems.mockResolvedValue([]);
|
||||
xtreamDataSourceMock.removeFavorite.mockClear();
|
||||
xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined);
|
||||
xtreamDataSourceMock.removeRecentItem.mockClear();
|
||||
xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined);
|
||||
storeMock.dispatch.mockClear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
@@ -149,6 +171,10 @@ describe('DashboardDataService', () => {
|
||||
provide: PlaylistsService,
|
||||
useValue: playlistsServiceMock,
|
||||
},
|
||||
{
|
||||
provide: XTREAM_DATA_SOURCE,
|
||||
useValue: xtreamDataSourceMock,
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
@@ -324,6 +350,48 @@ describe('DashboardDataService', () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('includes PWA Xtream favorites from the data source when Electron DB APIs are unavailable', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
xtreamDataSourceMock.getFavorites.mockResolvedValue([
|
||||
{
|
||||
id: 20203,
|
||||
stream_id: 20203,
|
||||
category_id: 206,
|
||||
title: 'PWA Movie',
|
||||
type: 'movie',
|
||||
poster_url: 'https://example.com/pwa-movie.png',
|
||||
backdrop_url: 'https://example.com/pwa-movie-backdrop.png',
|
||||
xtream_id: 20203,
|
||||
added: '1774298340',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalFavorites();
|
||||
|
||||
expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith(
|
||||
'xtream-1'
|
||||
);
|
||||
expect(service.globalFavoriteItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 20203,
|
||||
title: 'PWA Movie',
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
poster_url: 'https://example.com/pwa-movie.png',
|
||||
backdrop_url: 'https://example.com/pwa-movie-backdrop.png',
|
||||
source: 'xtream',
|
||||
xtream_id: 20203,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('builds the M3U favorites route', async () => {
|
||||
await service.reloadGlobalFavorites();
|
||||
const m3uItem = service
|
||||
@@ -419,6 +487,47 @@ describe('DashboardDataService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('includes PWA Xtream recent items from the data source when Electron DB APIs are unavailable', async () => {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
value: {} as Window['electron'],
|
||||
configurable: true,
|
||||
});
|
||||
xtreamDataSourceMock.getRecentItems.mockResolvedValue([
|
||||
{
|
||||
id: 20203,
|
||||
stream_id: 20203,
|
||||
category_id: 206,
|
||||
title: 'PWA Recent Movie',
|
||||
type: 'movie',
|
||||
poster_url: 'https://example.com/pwa-recent.png',
|
||||
backdrop_url: 'https://example.com/pwa-recent-backdrop.png',
|
||||
xtream_id: 20203,
|
||||
viewed_at: '2026-05-15T15:04:54.176Z',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.reloadGlobalRecentItems();
|
||||
|
||||
expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled();
|
||||
expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith(
|
||||
'xtream-1'
|
||||
);
|
||||
expect(service.globalRecentItems()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 20203,
|
||||
title: 'PWA Recent Movie',
|
||||
type: 'movie',
|
||||
playlist_id: 'xtream-1',
|
||||
playlist_name: 'Xtream Playlist',
|
||||
viewed_at: '2026-05-15T15:04:54.176Z',
|
||||
source: 'xtream',
|
||||
xtream_id: 20203,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => {
|
||||
playlistsSignal.set([
|
||||
{
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
GlobalRecentlyAddedKind,
|
||||
PlaylistsService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
XTREAM_DATA_SOURCE,
|
||||
XtreamContentItem,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
buildPlaylistRecentItems,
|
||||
Channel,
|
||||
@@ -38,6 +42,7 @@ import {
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentlyAddedToItem,
|
||||
mapDbRecentToItem,
|
||||
normalizeActivityType,
|
||||
toDateTimestamp,
|
||||
toTimestamp,
|
||||
} from './dashboard-mappers';
|
||||
@@ -64,6 +69,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind;
|
||||
export class DashboardDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly translate = inject(TranslateService);
|
||||
@@ -297,8 +303,8 @@ export class DashboardDataService {
|
||||
this.globalRecentLoadingState.set(true);
|
||||
}
|
||||
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalRecentItems.set([]);
|
||||
if (!this.hasElectronGlobalRecentApi()) {
|
||||
await this.reloadPwaXtreamGlobalRecentItems();
|
||||
this.globalRecentDbLoadedState.set(true);
|
||||
this.finishInitialGlobalRecentLoadIfReady();
|
||||
return;
|
||||
@@ -399,8 +405,9 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
private async reloadXtreamGlobalFavorites(): Promise<void> {
|
||||
if (!window.electron) {
|
||||
this.xtreamGlobalFavorites.set([]);
|
||||
if (!this.hasElectronGlobalFavoritesApi()) {
|
||||
const favorites = await this.getPwaXtreamGlobalFavorites();
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites));
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
return;
|
||||
}
|
||||
@@ -427,6 +434,184 @@ export class DashboardDataService {
|
||||
this.finishInitialGlobalFavoritesLoadIfReady();
|
||||
}
|
||||
|
||||
private async reloadPwaXtreamGlobalRecentItems(): Promise<void> {
|
||||
const items = await this.getPwaXtreamGlobalRecentItems();
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(items));
|
||||
}
|
||||
|
||||
private async getPwaXtreamGlobalFavorites(): Promise<
|
||||
DashboardFavoriteItem[]
|
||||
> {
|
||||
const results: DashboardFavoriteItem[] = [];
|
||||
|
||||
for (const playlist of this.getXtreamPlaylists()) {
|
||||
try {
|
||||
const favorites = await this.xtreamDataSource.getFavorites(
|
||||
playlist._id
|
||||
);
|
||||
results.push(
|
||||
...favorites.map((item) =>
|
||||
this.mapXtreamDataSourceFavorite(item, playlist)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Keep the dashboard usable if one PWA playlist cache is stale.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getPwaXtreamGlobalRecentItems(): Promise<GlobalRecentItem[]> {
|
||||
const results: GlobalRecentItem[] = [];
|
||||
|
||||
for (const playlist of this.getXtreamPlaylists()) {
|
||||
try {
|
||||
const recentItems = await this.xtreamDataSource.getRecentItems(
|
||||
playlist._id
|
||||
);
|
||||
results.push(
|
||||
...recentItems.map((item) =>
|
||||
this.mapXtreamDataSourceRecent(item, playlist)
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Keep the dashboard usable if one PWA playlist cache is stale.
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private getXtreamPlaylists(): PlaylistMeta[] {
|
||||
return this.playlists().filter((playlist) => !!playlist.serverUrl);
|
||||
}
|
||||
|
||||
private mapXtreamDataSourceFavorite(
|
||||
item: XtreamContentItem,
|
||||
playlist: PlaylistMeta
|
||||
): DashboardFavoriteItem {
|
||||
return {
|
||||
...this.mapXtreamDataSourceActivity(item, playlist),
|
||||
added_at: this.getXtreamDate(item, 'added_at'),
|
||||
};
|
||||
}
|
||||
|
||||
private mapXtreamDataSourceRecent(
|
||||
item: XtreamContentItem,
|
||||
playlist: PlaylistMeta
|
||||
): GlobalRecentItem {
|
||||
return {
|
||||
...this.mapXtreamDataSourceActivity(item, playlist),
|
||||
viewed_at: this.getXtreamDate(item, 'viewed_at'),
|
||||
};
|
||||
}
|
||||
|
||||
private mapXtreamDataSourceActivity(
|
||||
item: XtreamContentItem,
|
||||
playlist: PlaylistMeta
|
||||
): Omit<DashboardFavoriteItem, 'added_at'> {
|
||||
const record = item as unknown as Record<string, unknown>;
|
||||
const id = this.getXtreamNumericValue(record, [
|
||||
'id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'xtream_id',
|
||||
]);
|
||||
const xtreamId =
|
||||
this.getXtreamNumericValue(record, [
|
||||
'xtream_id',
|
||||
'stream_id',
|
||||
'series_id',
|
||||
'id',
|
||||
]) ?? id;
|
||||
|
||||
return {
|
||||
id: id ?? String(record['stream_id'] ?? record['series_id'] ?? ''),
|
||||
title: this.getXtreamTitle(record),
|
||||
type: normalizeActivityType(this.getPwaXtreamActivityType(record)),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name:
|
||||
playlist.title ||
|
||||
playlist.filename ||
|
||||
this.translateText('WORKSPACE.DASHBOARD.XTREAM'),
|
||||
category_id: (record['category_id'] as string | number) ?? '',
|
||||
xtream_id:
|
||||
xtreamId ??
|
||||
String(record['stream_id'] ?? record['series_id'] ?? ''),
|
||||
poster_url: this.getXtreamImage(record),
|
||||
backdrop_url: this.getXtreamString(record['backdrop_url']),
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
private getPwaXtreamActivityType(item: Record<string, unknown>): string {
|
||||
if (item['series_id'] != null) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
return String(item['type'] ?? item['stream_type'] ?? 'movie');
|
||||
}
|
||||
|
||||
private getXtreamTitle(item: Record<string, unknown>): string {
|
||||
return (
|
||||
this.getXtreamString(item['title']) ??
|
||||
this.getXtreamString(item['name']) ??
|
||||
this.getXtreamString(item['stream_display_name']) ??
|
||||
this.translateText('WORKSPACE.DASHBOARD.UNKNOWN_TITLE')
|
||||
);
|
||||
}
|
||||
|
||||
private getXtreamImage(item: Record<string, unknown>): string {
|
||||
return (
|
||||
this.getXtreamString(item['poster_url']) ??
|
||||
this.getXtreamString(item['stream_icon']) ??
|
||||
this.getXtreamString(item['cover']) ??
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getXtreamDate(
|
||||
item: XtreamContentItem,
|
||||
key: 'added_at' | 'viewed_at'
|
||||
): string {
|
||||
const record = item as unknown as Record<string, unknown>;
|
||||
const value =
|
||||
record[key] ??
|
||||
(key === 'added_at' ? record['added'] : undefined) ??
|
||||
new Date(0).toISOString();
|
||||
const timestamp = toTimestamp(value as string | number);
|
||||
return timestamp ? new Date(timestamp).toISOString() : String(value);
|
||||
}
|
||||
|
||||
private getXtreamString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private getXtreamNumericValue(
|
||||
item: Record<string, unknown>,
|
||||
keys: string[]
|
||||
): number | null {
|
||||
for (const key of keys) {
|
||||
const value = Number(item[key]);
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private hasElectronGlobalRecentApi(): boolean {
|
||||
return typeof window.electron?.dbGetRecentlyViewed === 'function';
|
||||
}
|
||||
|
||||
private hasElectronGlobalFavoritesApi(): boolean {
|
||||
return typeof window.electron?.dbGetAllGlobalFavorites === 'function';
|
||||
}
|
||||
|
||||
private async reloadM3uGlobalFavorites(): Promise<void> {
|
||||
const m3uPlaylists = this.playlists().filter(
|
||||
(playlist) =>
|
||||
@@ -875,8 +1060,7 @@ export class DashboardDataService {
|
||||
const matchedFavoriteId =
|
||||
channelIdFavoritePosition !== undefined &&
|
||||
(channelUrlFavoritePosition === undefined ||
|
||||
channelIdFavoritePosition <=
|
||||
channelUrlFavoritePosition)
|
||||
channelIdFavoritePosition <= channelUrlFavoritePosition)
|
||||
? channelId
|
||||
: channelUrlFavoritePosition !== undefined
|
||||
? channelUrl
|
||||
|
||||
Reference in new issue
Block a user