fix(pwa): filter invalid Xtream collection rows

This commit is contained in:
4gray committed 2026-05-16 11:54:06 +02:00
1 parent a18baa35f2
commit 588c089bf3
8 files changed
+162 -142

No files matched your search

@@ -34,3 +34,35 @@ export function xtreamContentType(type: string): CollectionContentType {
if (type === 'series') return 'series';
return 'live';
}
export function getPwaXtreamContentType(
item: Record<string, unknown>
): CollectionContentType {
if (item['series_id'] != null) {
return 'series';
}
return xtreamContentType(
String(item['type'] ?? item['stream_type'] ?? 'movie')
);
}
export function getXtreamString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0
? value
: undefined;
}
export function getXtreamNumericValue(
item: Record<string, unknown>,
keys: readonly string[]
): number | null {
for (const key of keys) {
const value = Number(item[key]);
if (Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
@@ -5,3 +5,8 @@ export * from './unified-favorites-data.service';
export * from './unified-recent-data.service';
export * from './stream-resolver.service';
export * from './xtream-collection-data-source.token';
export {
getPwaXtreamContentType,
getXtreamNumericValue,
getXtreamString,
} from './collection-helpers';
@@ -477,14 +477,19 @@ describe('UnifiedFavoritesDataService', () => {
rating: '8.1',
added_at: '2026-03-26T08:00:00.000Z',
} satisfies XtreamCollectionDataSourceItem,
{
name: 'Broken PWA Movie',
stream_type: 'movie',
stream_icon: 'broken.png',
added_at: '2026-03-26T09:00:00.000Z',
} satisfies XtreamCollectionDataSourceItem,
]);
const items = await service.getFavorites('all');
expect(databaseService.getAllGlobalFavorites).not.toHaveBeenCalled();
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith(
'xtream-1'
);
expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1');
expect(items).toHaveLength(1);
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:20203',
@@ -23,6 +23,9 @@ import {
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import {
getPwaXtreamContentType,
getXtreamNumericValue,
getXtreamString,
isStalkerItem,
xtreamContentType,
XtreamFavoriteRow,
@@ -385,9 +388,7 @@ export class UnifiedFavoritesDataService {
contentId: this.getXtreamItemId(item),
}))
.filter(
(
item
): item is { playlistId: string; contentId: number } =>
(item): item is { playlistId: string; contentId: number } =>
item.contentId != null
)
.map((item) =>
@@ -569,9 +570,11 @@ export class UnifiedFavoritesDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.xtreamDataSource.getFavorites(meta._id);
return rows.map((row, index) =>
this.mapPwaXtreamFavoriteItem(row, meta, index)
);
return rows
.map((row, index) =>
this.mapPwaXtreamFavoriteItem(row, meta, index)
)
.filter((item): item is UnifiedCollectionItem => item !== null);
} catch {
return [];
}
@@ -581,30 +584,34 @@ export class UnifiedFavoritesDataService {
row: XtreamCollectionDataSourceItem,
meta: PlaylistMeta,
index: number
): UnifiedCollectionItem {
): UnifiedCollectionItem | null {
const record = row as unknown as Record<string, unknown>;
const contentType = this.getPwaXtreamContentType(record);
const xtreamId = this.getXtreamNumericValue(record, [
const contentType = getPwaXtreamContentType(record);
const xtreamId = getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
'id',
]);
const contentId = this.getXtreamNumericValue(record, [
if (xtreamId == null) {
return null;
}
const contentId = 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']) ??
getXtreamString(record['title']) ??
getXtreamString(record['name']) ??
getXtreamString(record['stream_display_name']) ??
'Unknown';
const image =
this.getXtreamString(record['poster_url']) ??
this.getXtreamString(record['stream_icon']) ??
this.getXtreamString(record['cover']) ??
getXtreamString(record['poster_url']) ??
getXtreamString(record['stream_icon']) ??
getXtreamString(record['cover']) ??
null;
return {
@@ -619,15 +626,15 @@ export class UnifiedFavoritesDataService {
xtreamId,
categoryId: record['category_id'] as string | number,
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
rating: this.getXtreamString(record['rating']),
rating: getXtreamString(record['rating']),
addedAt:
normalizeStalkerDate(
this.getXtreamString(record['added_at']) ??
this.getXtreamString(record['added']) ??
getXtreamString(record['added_at']) ??
getXtreamString(record['added']) ??
''
) || new Date(0).toISOString(),
position: index,
contentId,
contentId: contentId ?? xtreamId,
};
}
@@ -648,38 +655,6 @@ export class UnifiedFavoritesDataService {
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,7 +10,10 @@ 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';
import {
XTREAM_COLLECTION_DATA_SOURCE,
XtreamCollectionDataSourceItem,
} from './xtream-collection-data-source.token';
describe('UnifiedRecentDataService', () => {
let service: UnifiedRecentDataService;
@@ -311,6 +314,52 @@ describe('UnifiedRecentDataService', () => {
);
});
it('filters PWA Xtream recent rows that have no positive Xtream identity', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
xtreamDataSource.getRecentItems.mockResolvedValue([
{
id: 20203,
stream_id: 20203,
title: 'PWA Recent Movie',
type: 'movie',
poster_url: 'https://example.com/movie.png',
viewed_at: '2026-04-21T20:42:27.000Z',
} satisfies XtreamCollectionDataSourceItem,
{
title: 'Broken PWA Recent',
type: 'movie',
poster_url: 'https://example.com/broken.png',
viewed_at: '2026-04-21T20:43:27.000Z',
} satisfies XtreamCollectionDataSourceItem,
]);
const items = await service.getRecentItems(
'playlist',
'xtream-1',
'xtream'
);
expect(items).toEqual([
expect.objectContaining({
uid: 'xtream::xtream-1::movie:20203',
contentId: 20203,
xtreamId: 20203,
}),
]);
});
it('keeps Stalker radio recent items in the live collection with radio metadata', async () => {
store.select.mockReturnValue(
of([
@@ -25,7 +25,12 @@ import {
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import { xtreamContentType } from './collection-helpers';
import {
getPwaXtreamContentType,
getXtreamNumericValue,
getXtreamString,
xtreamContentType,
} from './collection-helpers';
import {
XTREAM_COLLECTION_DATA_SOURCE,
XtreamCollectionDataSourceItem,
@@ -443,7 +448,9 @@ export class UnifiedRecentDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const rows = await this.xtreamDataSource.getRecentItems(meta._id);
return rows.map((row) => this.mapPwaXtreamRecentItem(row, meta));
return rows
.map((row) => this.mapPwaXtreamRecentItem(row, meta))
.filter((item): item is UnifiedCollectionItem => item !== null);
} catch {
return [];
}
@@ -452,30 +459,34 @@ export class UnifiedRecentDataService {
private mapPwaXtreamRecentItem(
row: XtreamCollectionDataSourceItem,
meta: PlaylistMeta
): UnifiedCollectionItem {
): UnifiedCollectionItem | null {
const record = row as unknown as Record<string, unknown>;
const contentType = this.getPwaXtreamContentType(record);
const xtreamId = this.getXtreamNumericValue(record, [
const contentType = getPwaXtreamContentType(record);
const xtreamId = getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
'id',
]);
const contentId = this.getXtreamNumericValue(record, [
if (xtreamId == null) {
return null;
}
const contentId = 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']) ??
getXtreamString(record['title']) ??
getXtreamString(record['name']) ??
getXtreamString(record['stream_display_name']) ??
'Unknown';
const image =
this.getXtreamString(record['poster_url']) ??
this.getXtreamString(record['stream_icon']) ??
this.getXtreamString(record['cover']) ??
getXtreamString(record['poster_url']) ??
getXtreamString(record['stream_icon']) ??
getXtreamString(record['cover']) ??
null;
return {
@@ -490,9 +501,9 @@ export class UnifiedRecentDataService {
xtreamId,
categoryId: record['category_id'] as string | number,
tvgId: contentType === 'live' ? String(xtreamId) : undefined,
contentId,
contentId: contentId ?? xtreamId,
viewedAt: normalizeStalkerDate(
this.getXtreamString(record['viewed_at']) ?? ''
getXtreamString(record['viewed_at']) ?? ''
),
};
}
@@ -501,38 +512,6 @@ export class UnifiedRecentDataService {
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[] = [];
@@ -894,7 +894,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return {
...identity,
viewedAt: item.viewedAt,
backdropUrl: item.backdropUrl ?? content?.backdrop_url,
backdropUrl:
item.backdropUrl ?? content?.backdrop_url ?? undefined,
content,
};
})
@@ -42,7 +42,6 @@ import {
mapDbFavoriteToItem,
mapDbRecentlyAddedToItem,
mapDbRecentToItem,
normalizeActivityType,
toDateTimestamp,
toTimestamp,
} from './dashboard-mappers';
@@ -50,8 +49,11 @@ import {
buildStalkerDetailNavigationTarget,
buildStalkerStateItem,
buildXtreamNavigationTarget,
getPwaXtreamContentType,
getGlobalFavoriteNavigation,
getRecentItemNavigation,
getXtreamNumericValue,
getXtreamString,
WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
@@ -512,14 +514,14 @@ export class DashboardDataService {
playlist: PlaylistMeta
): Omit<DashboardFavoriteItem, 'added_at'> {
const record = item as unknown as Record<string, unknown>;
const id = this.getXtreamNumericValue(record, [
const id = getXtreamNumericValue(record, [
'id',
'stream_id',
'series_id',
'xtream_id',
]);
const xtreamId =
this.getXtreamNumericValue(record, [
getXtreamNumericValue(record, [
'xtream_id',
'stream_id',
'series_id',
@@ -529,7 +531,7 @@ export class DashboardDataService {
return {
id: id ?? String(record['stream_id'] ?? record['series_id'] ?? ''),
title: this.getXtreamTitle(record),
type: normalizeActivityType(this.getPwaXtreamActivityType(record)),
type: getPwaXtreamContentType(record),
playlist_id: playlist._id,
playlist_name:
playlist.title ||
@@ -540,33 +542,25 @@ export class DashboardDataService {
xtreamId ??
String(record['stream_id'] ?? record['series_id'] ?? ''),
poster_url: this.getXtreamImage(record),
backdrop_url: this.getXtreamString(record['backdrop_url']),
backdrop_url: 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']) ??
getXtreamString(item['title']) ??
getXtreamString(item['name']) ??
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']) ??
getXtreamString(item['poster_url']) ??
getXtreamString(item['stream_icon']) ??
getXtreamString(item['cover']) ??
''
);
}
@@ -584,26 +578,6 @@ export class DashboardDataService {
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';
}