mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
fix(pwa): filter invalid Xtream collection rows
This commit is contained in:
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';
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user