diff --git a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts index 222f12ab8..3931310e0 100644 --- a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts +++ b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts @@ -34,3 +34,35 @@ export function xtreamContentType(type: string): CollectionContentType { if (type === 'series') return 'series'; return 'live'; } + +export function getPwaXtreamContentType( + item: Record +): 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, + 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; +} diff --git a/libs/portal/shared/util/src/lib/collection/index.ts b/libs/portal/shared/util/src/lib/collection/index.ts index 032061832..8d56da304 100644 --- a/libs/portal/shared/util/src/lib/collection/index.ts +++ b/libs/portal/shared/util/src/lib/collection/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts index 1cab76dbe..2c241658d 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts @@ -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', diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts index 61ea822ca..c15756e99 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts @@ -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 { 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; - 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 - ): 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, - 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 { diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts index 083a11322..15e37fa91 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts @@ -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, + ]) + ); + 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([ diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts index 84a51e474..7de7da2e9 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts @@ -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 { 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; - 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 - ): 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, - 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 { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 443bccae3..874ddecdc 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -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, }; }) diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index a48a929eb..c866bd1aa 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -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 { const record = item as unknown as Record; - 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 { - if (item['series_id'] != null) { - return 'series'; - } - - return String(item['type'] ?? item['stream_type'] ?? 'movie'); - } - private getXtreamTitle(item: Record): 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 { 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, - 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'; }