From 4ac2b0dd6227f4fa1c63c804919fcb45e904f0f8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 12 May 2026 11:08:27 +0200 Subject: [PATCH] fix(stalker): play radio collections with audio layout --- docs/architecture/stalker-portal.md | 7 + .../unified-live-tab.component.ts | 21 ++- .../stream-resolver.service.spec.ts | 57 +++++++ .../lib/collection/stream-resolver.service.ts | 48 +++++- .../unified-collection-item.interface.ts | 8 +- .../unified-favorites-data.service.spec.ts | 51 +++++- .../unified-favorites-data.service.ts | 156 ++++++++++++------ .../unified-live-tab.component.spec.ts | 61 +++++++ .../unified-recent-data.service.spec.ts | 73 +++++++- .../collection/unified-recent-data.service.ts | 36 ++-- .../src/lib/stalker-item.normalizer.spec.ts | 26 ++- .../src/lib/stalker-item.normalizer.ts | 54 ++++-- .../src/lib/stalker-portal-item.interface.ts | 4 +- 13 files changed, 505 insertions(+), 97 deletions(-) diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index b45f909fc..78e316132 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -118,6 +118,10 @@ The Stalker live route and radio route intentionally share radio stations do not have EPG data. - Radio always uses the inline audio player. External player settings are ignored for Stalker radio, matching M3U radio behavior. +- Radio stations opened from favorites or recently viewed remain live + collection items with `radio: 'true'`; the shared collection resolver uses + `create_link` with `type=radio`, skips EPG loading, and renders the same + `AudioPlayerComponent` layout instead of the Stalker VOD detail layout. - Some Stalker portals do not expose radio categories. Radio category loading falls back to a synthetic `PORTALS.ALL_RADIO` category with `category_id: '*'` so the station list can still be loaded. @@ -170,6 +174,9 @@ Navigation rule to preserve: - Stalker favorites, recently viewed, and search stay in their current screen and open inline detail state. - They should not redirect into a canonical content/category/item route because Stalker detail rendering is currently store-state/inline driven, not route driven. +- Stalker radio favorites/recent items are the exception to VOD/series inline + detail opening: they are normalized as live items and must open through the + shared live collection audio-player path. - VOD-backed series favorites can be displayed in series collections, but detail opening must preserve their VOD origin: `is_series=1` favorites set the selected content type to `vod` so the lazy Ministra season/episode resources diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index e9c3843e1..42482a12f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -128,6 +128,10 @@ export class UnifiedLiveTabComponent { return []; } + if (detail.channel?.radio === 'true') { + return []; + } + return detail.epgPrograms ?? []; }); readonly currentM3uChannel = computed(() => { @@ -142,7 +146,7 @@ export class UnifiedLiveTabComponent { isM3uCatchupPlaybackSupported(this.currentM3uChannel()) ); readonly activeRadioChannel = computed(() => { - const channel = this.currentM3uChannel(); + const channel = this.activeDetail()?.channel ?? null; return channel?.radio === 'true' ? channel : null; }); readonly isRadioSelection = computed( @@ -412,19 +416,20 @@ export class UnifiedLiveTabComponent { detail: ResolvedLiveCollectionDetail, startPlayback = false ): boolean { - if (this.isRadioDetail(detail) || this.portalPlayer.isEmbeddedPlayer()) { + if ( + this.isRadioDetail(detail) || + this.portalPlayer.isEmbeddedPlayer() + ) { return false; } - return ( - !this.settingsStore.openStreamOnDoubleClick() || startPlayback - ); + return !this.settingsStore.openStreamOnDoubleClick() || startPlayback; } private isRadioDetail( detail: ResolvedLiveCollectionDetail | null | undefined ): boolean { - return detail?.epgMode === 'm3u' && detail.channel?.radio === 'true'; + return detail?.channel?.radio === 'true'; } private async hydrateSelectedM3uPrograms( @@ -436,6 +441,10 @@ export class UnifiedLiveTabComponent { return; } + if (detail.channel?.radio === 'true') { + return; + } + const epgPrograms = await this.streamResolver.loadM3uProgramsForItem( item, detail.channel diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts index 6c1e7938b..1c6462809 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.spec.ts @@ -445,4 +445,61 @@ describe('StreamResolverService', () => { channel: '77', }); }); + + it('resolves Stalker radio collection items with radio playback and no EPG request', async () => { + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + isFullStalkerPortal: false, + } satisfies Partial) + ); + dataService.sendIpcEvent.mockResolvedValue({ + js: { + cmd: 'ffmpeg https://media.example.com/jazz.mp3', + }, + }); + + const detail = await service.resolveLiveDetail({ + uid: 'stalker::stalker-1::40001', + name: 'Jazz Radio', + contentType: 'live', + sourceType: 'stalker', + playlistId: 'stalker-1', + playlistName: 'Stalker', + stalkerId: '40001', + stalkerCmd: 'ffrt4://radio/40001/index.mp3', + logo: 'jazz.png', + radio: 'true', + } satisfies UnifiedCollectionItem); + + expect(dataService.sendIpcEvent).toHaveBeenCalledTimes(1); + expect(dataService.sendIpcEvent).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + params: expect.objectContaining({ + action: 'create_link', + type: 'radio', + cmd: 'ffrt4://radio/40001/index.mp3', + }), + }) + ); + expect(detail).toEqual( + expect.objectContaining({ + epgMode: 'portal', + epgItems: [], + channel: expect.objectContaining({ + id: '40001', + name: 'Jazz Radio', + radio: 'true', + }), + playback: expect.objectContaining({ + streamUrl: 'https://media.example.com/jazz.mp3', + title: 'Jazz Radio', + thumbnail: 'jazz.png', + }), + }) + ); + }); }); diff --git a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts index 8fe5a63f9..5610ca921 100644 --- a/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/util/src/lib/collection/stream-resolver.service.ts @@ -146,6 +146,10 @@ export class StreamResolverService { continue; } + if (item.radio === 'true') { + continue; + } + if (item.sourceType === 'xtream') { const list = xtreamByPlaylist.get(item.playlistId) ?? []; list.push(item); @@ -212,6 +216,15 @@ export class StreamResolverService { item: UnifiedCollectionItem ): Promise { const playback = await this.resolveStalker(item); + if (item.radio === 'true') { + return { + playback, + epgMode: 'portal', + channel: this.buildStalkerRadioChannel(item, playback), + epgItems: [], + }; + } + const epgItems = await this.withFallbackTimeout( this.loadStalkerEpgItems(item, 10), this.portalEpgTimeoutMs, @@ -282,10 +295,11 @@ export class StreamResolverService { const portalUrl = item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? ''; const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? ''; + const contentType = item.radio === 'true' ? 'radio' : 'itv'; const params = { action: StalkerPortalActions.CreateLink, cmd: item.stalkerCmd ?? '', - type: 'itv' as const, + type: contentType, disable_ad: '0', download: '0', JsHttpRequest: '1-xml', @@ -311,7 +325,37 @@ export class StreamResolverService { streamUrl: this.normalizeStalkerCmd(rawCmd), title: item.name, thumbnail: item.logo ?? null, - isLive: true, + isLive: item.radio === 'true' ? undefined : true, + }; + } + + private buildStalkerRadioChannel( + item: UnifiedCollectionItem, + playback: ResolvedPortalPlayback + ): Channel { + const channelId = String( + item.stalkerId ?? item.tvgId ?? item.uid.split('::')[2] ?? '' + ); + + return { + id: channelId, + name: item.name, + url: playback.streamUrl, + tvg: { + id: item.tvgId ?? channelId, + name: item.name, + url: '', + logo: item.logo ?? playback.thumbnail ?? '', + rec: '', + }, + group: { title: '' }, + http: { + referrer: playback.referer ?? '', + 'user-agent': playback.userAgent ?? '', + origin: playback.origin ?? '', + }, + radio: 'true', + epgParams: '', }; } diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts index 5a0d5453f..2f5d7d577 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts @@ -44,7 +44,7 @@ export interface UnifiedCollectionItem { /** M3U playlist channel id used to rehydrate the full channel object */ channelId?: string; - /** M3U radio flag used to preserve the dedicated radio player layout */ + /** Radio flag used to preserve the dedicated audio player layout */ radio?: string; /** Full M3U channel metadata used by shared live collection row actions */ @@ -97,5 +97,9 @@ export function buildXtreamCollectionUid( contentType: CollectionContentType, xtreamId: string | number ): string { - return buildCollectionUid('xtream', playlistId, `${contentType}:${xtreamId}`); + return buildCollectionUid( + 'xtream', + playlistId, + `${contentType}:${xtreamId}` + ); } 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 0f2ebbb1a..da909fa8b 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 @@ -118,10 +118,7 @@ describe('UnifiedFavoritesDataService', () => { { _id: 'm3u-1', title: 'M3U List', - favorites: [ - 'https://example.com/2.m3u8', - 'channel-1', - ], + favorites: ['https://example.com/2.m3u8', 'channel-1'], }, { _id: 'stalker-1', @@ -230,10 +227,7 @@ describe('UnifiedFavoritesDataService', () => { playlistsService.getPlaylistById.mockReturnValue( of({ _id: 'm3u-1', - favorites: [ - 'https://example.com/2.m3u8', - 'channel-1', - ], + favorites: ['https://example.com/2.m3u8', 'channel-1'], playlist: { items: m3uChannels, }, @@ -252,6 +246,47 @@ describe('UnifiedFavoritesDataService', () => { expect(items[1].m3uChannel).toBe(m3uChannels[0]); }); + it('keeps Stalker radio favorites in the live collection with radio metadata', async () => { + const radioFavorite = { + id: '40001', + title: 'Jazz Radio', + name: 'Jazz Radio', + category_id: 'radio-genre-1', + cmd: 'ffrt4://radio/40001/index.mp3', + logo: 'jazz.png', + radio: true, + added_at: '2026-03-26T12:00:00.000Z', + } satisfies StalkerPortalItem; + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + title: 'Stalker List', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + favorites: [radioFavorite], + } satisfies Partial) + ); + + const items = await service.getFavorites( + 'playlist', + 'stalker-1', + 'stalker' + ); + + expect(items).toEqual([ + expect.objectContaining({ + uid: 'stalker::stalker-1::40001', + name: 'Jazz Radio', + contentType: 'live', + logo: 'jazz.png', + posterUrl: null, + radio: 'true', + stalkerCmd: 'ffrt4://radio/40001/index.mp3', + categoryId: 'radio-genre-1', + }), + ]); + }); + it('persists M3U playlist reorders through setFavorites', async () => { const reorderedItems = [ { 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 c3a5224d2..2e6160ff7 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 @@ -10,6 +10,7 @@ import { extractStalkerItemPoster, extractStalkerItemTitle, extractStalkerItemType, + isStalkerRadioItem, normalizeStalkerDate, Playlist, PlaylistMeta, @@ -76,12 +77,13 @@ export class UnifiedFavoritesDataService { const playlist = await firstValueFrom( this.playlistsService.getPlaylistById(item.playlistId) ); - const filtered = ((playlist.favorites as string[]) ?? []) - .filter( - (favoriteId) => - favoriteId !== item.streamUrl && - favoriteId !== item.channelId - ); + const filtered = ( + (playlist.favorites as string[]) ?? [] + ).filter( + (favoriteId) => + favoriteId !== item.streamUrl && + favoriteId !== item.channelId + ); await this.setM3uFavorites(item.playlistId, filtered); break; } @@ -112,13 +114,12 @@ export class UnifiedFavoritesDataService { return; } - const playlist = await firstValueFrom( + const playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(item.playlistId) - ) as Playlist | undefined; + )) as Playlist | undefined; const currentFavorites = Array.isArray(playlist?.favorites) ? playlist.favorites.filter( - (favorite): favorite is string => - typeof favorite === 'string' + (favorite): favorite is string => typeof favorite === 'string' ) : []; @@ -132,7 +133,9 @@ export class UnifiedFavoritesDataService { ]); } - private async addXtreamFavorite(item: UnifiedCollectionItem): Promise { + private async addXtreamFavorite( + item: UnifiedCollectionItem + ): Promise { if (!window.electron) { return; } @@ -176,18 +179,19 @@ export class UnifiedFavoritesDataService { name: stalkerItem.name ?? item.name, o_name: stalkerItem.o_name ?? item.name, category_id: String( - item.categoryId ?? stalkerItem.category_id ?? 'itv' + item.categoryId ?? + stalkerItem.category_id ?? + (item.radio === 'true' || isStalkerRadioItem(stalkerItem) + ? 'radio' + : 'itv') ), cover: - stalkerItem.cover ?? - item.logo ?? - item.posterUrl ?? - undefined, - logo: - stalkerItem.logo ?? - item.logo ?? - item.posterUrl ?? - undefined, + stalkerItem.cover ?? item.logo ?? item.posterUrl ?? undefined, + logo: stalkerItem.logo ?? item.logo ?? item.posterUrl ?? undefined, + radio: + item.radio === 'true' || isStalkerRadioItem(stalkerItem) + ? true + : undefined, added_at: new Date().toISOString(), }; @@ -256,7 +260,8 @@ export class UnifiedFavoritesDataService { const reorderedFavorites = items .map( (item) => - favoritesById.get(this.getStalkerFavoriteId(item)) ?? null + favoritesById.get(this.getStalkerFavoriteId(item)) ?? + null ) .filter( (favorite): favorite is StalkerPortalItem => @@ -396,26 +401,34 @@ export class UnifiedFavoritesDataService { playlistId: string, portalType?: string ): Promise { - if (portalType === 'xtream') return this.getXtreamPlaylistFavorites(playlistId); - if (portalType === 'stalker') return this.getStalkerPlaylistFavorites(playlistId); + if (portalType === 'xtream') + return this.getXtreamPlaylistFavorites(playlistId); + if (portalType === 'stalker') + return this.getStalkerPlaylistFavorites(playlistId); return this.getM3uPlaylistFavorites(playlistId); } private async getM3uFavorites(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; - for (const meta of allMeta.filter((p) => p._id && !p.serverUrl && !p.macAddress)) { + for (const meta of allMeta.filter( + (p) => p._id && !p.serverUrl && !p.macAddress + )) { results.push(...(await this.extractM3uFavorites(meta))); } return results; } - private async getM3uPlaylistFavorites(id: string): Promise { + private async getM3uPlaylistFavorites( + id: string + ): Promise { const meta = await this.getPlaylistMeta(id); return meta ? this.extractM3uFavorites(meta) : []; } - private async extractM3uFavorites(meta: PlaylistMeta): Promise { + private async extractM3uFavorites( + meta: PlaylistMeta + ): Promise { if (!meta.favorites?.length) return []; const favoriteIds = (meta.favorites as string[]).map(String); let playlist: PlaylistWithChannels | undefined; @@ -423,7 +436,9 @@ export class UnifiedFavoritesDataService { playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(meta._id) )) as PlaylistWithChannels | undefined; - } catch { return []; } + } catch { + return []; + } const channels = playlist?.playlist?.items ?? []; const channelsByFavoriteId = new Map(); channels.forEach((channel) => { @@ -471,12 +486,17 @@ export class UnifiedFavoritesDataService { private async getXtreamAllFavorites(): Promise { if (!window.electron?.dbGetAllGlobalFavorites) return []; try { - const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[]; + const rows = + (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[]; return rows.map((r) => this.mapXtreamRow(r)); - } catch { return []; } + } catch { + return []; + } } - private async getXtreamPlaylistFavorites(playlistId: string): Promise { + private async getXtreamPlaylistFavorites( + playlistId: string + ): Promise { if (!window.electron) return []; try { const rows = await this.dbService.getFavorites(playlistId); @@ -486,7 +506,9 @@ export class UnifiedFavoritesDataService { playlistId, playlistName: meta?.title || 'Xtream', })); - } catch { return []; } + } catch { + return []; + } } private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { @@ -505,8 +527,7 @@ export class UnifiedFavoritesDataService { tvgId: ct === 'live' ? String(row.xtream_id) : undefined, rating: row.rating ?? undefined, addedAt: - normalizeStalkerDate(row.added_at) || - new Date(0).toISOString(), + normalizeStalkerDate(row.added_at) || new Date(0).toISOString(), position: row.position ?? 0, contentId: row.id, }; @@ -521,25 +542,32 @@ export class UnifiedFavoritesDataService { return results; } - private async getStalkerPlaylistFavorites(id: string): Promise { + private async getStalkerPlaylistFavorites( + id: string + ): Promise { const meta = await this.getPlaylistMeta(id); return meta ? this.extractStalkerFavorites(meta) : []; } - private async extractStalkerFavorites(meta: PlaylistMeta): Promise { + private async extractStalkerFavorites( + meta: PlaylistMeta + ): Promise { if (!meta.favorites?.length) return []; let playlist: Playlist | undefined; try { playlist = (await firstValueFrom( this.playlistsService.getPlaylistById(meta._id) )) as Playlist | undefined; - } catch { return []; } + } catch { + return []; + } const favs = Array.isArray(playlist?.favorites) ? playlist.favorites.filter(isStalkerItem) : []; return favs.map((fav, index) => { const ct = extractStalkerItemType(fav); + const isRadio = isStalkerRadioItem(fav); const stalkerId = extractStalkerItemId(fav, meta._id, index); const poster = extractStalkerItemPoster(fav) || null; return { @@ -561,6 +589,7 @@ export class UnifiedFavoritesDataService { logo: ct === 'live' ? poster : null, posterUrl: ct !== 'live' ? poster : null, tvgId: ct === 'live' ? stalkerId : undefined, + radio: isRadio ? 'true' : undefined, stalkerId, stalkerCmd: fav.cmd, stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url, @@ -577,27 +606,40 @@ export class UnifiedFavoritesDataService { private async getAllMeta(): Promise { return firstValueFrom( - this.store.select(selectAllPlaylistsMeta).pipe(map((m) => m as PlaylistMeta[])) + this.store + .select(selectAllPlaylistsMeta) + .pipe(map((m) => m as PlaylistMeta[])) ); } - private async getPlaylistMeta(id: string): Promise { + private async getPlaylistMeta( + id: string + ): Promise { return (await this.getAllMeta()).find((p) => p._id === id); } private async getSavedOrder(): Promise { if (!window.electron?.dbGetAppState) return []; try { - const raw = await window.electron.dbGetAppState(GLOBAL_FAVORITES_ORDER_KEY); + const raw = await window.electron.dbGetAppState( + GLOBAL_FAVORITES_ORDER_KEY + ); return raw ? (JSON.parse(raw) as string[]) : []; - } catch { return []; } + } catch { + return []; + } } private async saveOrder(uidOrder: string[]): Promise { if (!window.electron?.dbSetAppState) return; try { - await window.electron.dbSetAppState(GLOBAL_FAVORITES_ORDER_KEY, JSON.stringify(uidOrder)); - } catch { /* ignore */ } + await window.electron.dbSetAppState( + GLOBAL_FAVORITES_ORDER_KEY, + JSON.stringify(uidOrder) + ); + } catch { + /* ignore */ + } } private async setM3uFavorites( @@ -617,13 +659,19 @@ export class UnifiedFavoritesDataService { ); } - private applyOrder(items: UnifiedCollectionItem[], savedOrder: string[]): UnifiedCollectionItem[] { + private applyOrder( + items: UnifiedCollectionItem[], + savedOrder: string[] + ): UnifiedCollectionItem[] { if (!savedOrder.length) { return items.slice().sort((a, b) => { const pa = a.position ?? 0; const pb = b.position ?? 0; if (pa !== pb) return pa - pb; - return new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime(); + return ( + new Date(b.addedAt ?? 0).getTime() - + new Date(a.addedAt ?? 0).getTime() + ); }); } const orderMap = new Map(savedOrder.map((uid, i) => [uid, i])); @@ -631,16 +679,24 @@ export class UnifiedFavoritesDataService { const unordered: UnifiedCollectionItem[] = []; for (const item of items) { const pos = orderMap.get(item.uid); - pos != null ? ordered.push({ ...item, position: pos }) : unordered.push(item); + pos != null + ? ordered.push({ ...item, position: pos }) + : unordered.push(item); } ordered.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); - unordered.sort((a, b) => new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime()); + unordered.sort( + (a, b) => + new Date(b.addedAt ?? 0).getTime() - + new Date(a.addedAt ?? 0).getTime() + ); return [...ordered, ...unordered]; } private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) { return items - .filter((item) => item.sourceType === 'xtream' && item.contentId != null) + .filter( + (item) => item.sourceType === 'xtream' && item.contentId != null + ) .map((item, index) => ({ content_id: item.contentId!, position: index, @@ -663,7 +719,9 @@ export class UnifiedFavoritesDataService { } private getStalkerFavoriteId( - favorite: Pick | StalkerPortalItem + favorite: + | Pick + | StalkerPortalItem ): string { if ('uid' in favorite) { return String( diff --git a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts index d40e0167b..d0e2ce36a 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-live-tab.component.spec.ts @@ -564,6 +564,67 @@ describe('UnifiedLiveTabComponent', () => { expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); }); + it('renders inline audio for Stalker radio items and skips external playback', async () => { + const item = { + ...buildLiveItem('stalker'), + name: 'Jazz Radio', + radio: 'true', + } satisfies UnifiedCollectionItem; + streamResolver.resolveLiveDetail.mockResolvedValue({ + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/jazz.mp3', + title: 'Jazz Radio', + thumbnail: 'jazz.png', + }, + channel: { + id: '40001', + name: 'Jazz Radio', + url: 'https://example.com/jazz.mp3', + group: { title: 'Radio' }, + tvg: { + id: '40001', + name: 'Jazz Radio', + url: '', + logo: 'jazz.png', + rec: '', + }, + http: { referrer: '', 'user-agent': '', origin: '' }, + radio: 'true', + epgParams: '', + }, + epgItems: [], + }); + recentData.recordLivePlayback.mockResolvedValue({ + ...item, + viewedAt: '2026-03-26T12:00:00.000Z', + }); + + fixture.componentRef.setInput('items', [item]); + fixture.componentRef.setInput('mode', 'recent'); + fixture.detectChanges(); + await fixture.whenStable(); + + await component.onChannelSelected(component.channelsForList()[0]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item); + expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled(); + expect( + fixture.nativeElement.querySelector('app-audio-player') + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull(); + expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull(); + + const audioPlayer = fixture.debugElement.query( + By.directive(StubAudioPlayerComponent) + ).componentInstance as StubAudioPlayerComponent; + expect(audioPlayer.url()).toBe('https://example.com/jazz.mp3'); + expect(audioPlayer.icon()).toBe('jazz.png'); + expect(audioPlayer.channelName()).toBe('Jazz Radio'); + }); + it('renders shared EPG view for Xtream items and records recent history', async () => { const item = buildLiveItem('xtream'); streamResolver.resolveLiveDetail.mockResolvedValue({ 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 6a9bd7841..416f7d5b0 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 @@ -112,12 +112,18 @@ describe('UnifiedRecentDataService', () => { ], }) ), - removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), - removeFromPortalRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), + removeFromM3uRecentlyViewed: jest + .fn() + .mockReturnValue(of({ recentlyViewed: [] })), + removeFromPortalRecentlyViewed: jest + .fn() + .mockReturnValue(of({ recentlyViewed: [] })), removeFromPlaylistRecentlyViewedBatch: jest .fn() .mockReturnValue(of({ recentlyViewed: [] })), - clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })), + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of({ recentlyViewed: [] })), getAllPlaylists: jest.fn().mockReturnValue(of([])), }; dbService = { @@ -260,7 +266,11 @@ describe('UnifiedRecentDataService', () => { }, ]); - const items = await service.getRecentItems('playlist', 'xtream-1', 'xtream'); + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); expect(items).toEqual( expect.arrayContaining([ @@ -278,6 +288,57 @@ describe('UnifiedRecentDataService', () => { ); }); + it('keeps Stalker radio recent items in the live collection with radio metadata', async () => { + store.select.mockReturnValue( + of([ + { + _id: 'stalker-1', + title: 'Stalker Portal', + macAddress: '00:11:22:33:44:55', + recentlyViewed: [ + { + id: '40001', + title: 'Jazz Radio', + name: 'Jazz Radio', + category_id: 'radio-genre-1', + cmd: 'ffrt4://radio/40001/index.mp3', + logo: 'jazz.png', + radio: true, + added_at: '2026-03-26T12:00:00.000Z', + }, + ], + } satisfies Partial, + ]) + ); + playlistsService.getPlaylistById.mockReturnValue( + of({ + _id: 'stalker-1', + title: 'Stalker Portal', + portalUrl: 'https://stalker.example.com/portal.php', + macAddress: '00:11:22:33:44:55', + } satisfies Partial) + ); + + const items = await service.getRecentItems( + 'playlist', + 'stalker-1', + 'stalker' + ); + + expect(items).toEqual([ + expect.objectContaining({ + uid: 'stalker::stalker-1::40001', + name: 'Jazz Radio', + contentType: 'live', + logo: 'jazz.png', + posterUrl: null, + radio: 'true', + stalkerCmd: 'ffrt4://radio/40001/index.mp3', + categoryId: 'radio-genre-1', + }), + ]); + }); + it('coalesces multiple M3U items from one playlist into a single batched removal', async () => { const items: UnifiedCollectionItem[] = [ { @@ -311,7 +372,9 @@ describe('UnifiedRecentDataService', () => { 'https://example.com/1.m3u8', 'https://example.com/2.m3u8', ]); - expect(playlistsService.removeFromM3uRecentlyViewed).not.toHaveBeenCalled(); + expect( + playlistsService.removeFromM3uRecentlyViewed + ).not.toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledTimes(1); }); 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 eecb77c08..ed99f010a 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 @@ -1,9 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; -import { - PlaylistActions, - selectAllPlaylistsMeta, -} from 'm3u-state'; +import { PlaylistActions, selectAllPlaylistsMeta } from 'm3u-state'; import { firstValueFrom, map } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { @@ -12,6 +9,7 @@ import { extractStalkerItemPoster, extractStalkerItemTitle, extractStalkerItemType, + isStalkerRadioItem, isM3uRecentlyViewedItem, M3uRecentlyViewedItem, normalizeStalkerDate, @@ -57,7 +55,10 @@ export class UnifiedRecentDataService { return; } - await this.dbService.removeRecentItem(item.contentId, item.playlistId); + await this.dbService.removeRecentItem( + item.contentId, + item.playlistId + ); return; } @@ -167,7 +168,10 @@ export class UnifiedRecentDataService { await Promise.all( playlists - .filter((playlist) => Boolean(playlist.macAddress) || !playlist.serverUrl) + .filter( + (playlist) => + Boolean(playlist.macAddress) || !playlist.serverUrl + ) .map(async (playlist) => { const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed( @@ -319,7 +323,8 @@ export class UnifiedRecentDataService { playlistId: row.playlist_id, playlistName: row.playlist_name ?? 'Xtream', logo: row.type === 'live' ? (row.poster_url ?? null) : null, - posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null, + posterUrl: + row.type !== 'live' ? (row.poster_url ?? null) : null, xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, @@ -350,7 +355,8 @@ export class UnifiedRecentDataService { playlistId, playlistName: meta?.title || 'Xtream', logo: row.type === 'live' ? (row.poster_url ?? null) : null, - posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null, + posterUrl: + row.type !== 'live' ? (row.poster_url ?? null) : null, xtreamId: row.xtream_id, categoryId: row.category_id, tvgId: row.type === 'live' ? String(row.xtream_id) : undefined, @@ -366,7 +372,9 @@ export class UnifiedRecentDataService { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; - for (const meta of allMeta.filter((playlist) => this.isM3uPlaylist(playlist))) { + for (const meta of allMeta.filter((playlist) => + this.isM3uPlaylist(playlist) + )) { results.push(...(await this.extractM3uRecent(meta))); } @@ -384,7 +392,9 @@ export class UnifiedRecentDataService { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; - for (const meta of allMeta.filter((playlist) => Boolean(playlist.macAddress))) { + for (const meta of allMeta.filter((playlist) => + Boolean(playlist.macAddress) + )) { results.push(...(await this.extractStalkerRecent(meta))); } @@ -504,6 +514,7 @@ export class UnifiedRecentDataService { return recentItems.map((item, index) => { const stalkerId = extractStalkerItemId(item, meta._id, index); const contentType = extractStalkerItemType(item); + const isRadio = isStalkerRadioItem(item); const imageUrl = extractStalkerItemPoster(item) || null; return { @@ -516,6 +527,7 @@ export class UnifiedRecentDataService { logo: contentType === 'live' ? imageUrl : null, posterUrl: contentType !== 'live' ? imageUrl : null, tvgId: contentType === 'live' ? stalkerId : undefined, + radio: isRadio ? 'true' : undefined, stalkerId, stalkerCmd: item.cmd, stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url, @@ -530,7 +542,9 @@ export class UnifiedRecentDataService { private async getPlaylistMeta( id: string ): Promise { - return (await this.getAllMeta()).find((playlist) => playlist._id === id); + return (await this.getAllMeta()).find( + (playlist) => playlist._id === id + ); } private async getAllMeta(): Promise { diff --git a/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts b/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts index d3e4bb8ac..2bf866536 100644 --- a/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts +++ b/libs/shared/interfaces/src/lib/stalker-item.normalizer.spec.ts @@ -1,4 +1,28 @@ -import { normalizeStalkerDate } from './stalker-item.normalizer'; +import { + extractStalkerItemType, + normalizeStalkerDate, +} from './stalker-item.normalizer'; + +describe('extractStalkerItemType', () => { + it('treats Stalker radio stations as live collection items', () => { + expect( + extractStalkerItemType({ + id: '40001', + title: 'Jazz Radio', + category_id: 'radio', + radio: true, + }) + ).toBe('live'); + expect( + extractStalkerItemType({ + id: '40002', + title: 'Rock Radio', + category_id: 'radio-genre-1', + radio: 'true', + }) + ).toBe('live'); + }); +}); describe('normalizeStalkerDate', () => { it('normalizes SQLite UTC timestamps without relying on engine-specific parsing', () => { diff --git a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts index 881306ae3..5a0d233fa 100644 --- a/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts +++ b/libs/shared/interfaces/src/lib/stalker-item.normalizer.ts @@ -49,7 +49,7 @@ export function extractStalkerItemPoster( /** * Determine the normalised activity type of a Stalker item. * - * - `itv` / `live` → `'live'` + * - `itv` / `live` / radio → `'live'` * - `series` or `is_series` truthy → `'series'` * - everything else → `'movie'` */ @@ -60,7 +60,11 @@ export function extractStalkerItemType( const categoryId = String(raw['category_id'] ?? '').toLowerCase(); const streamType = String(raw['stream_type'] ?? '').toLowerCase(); - if (categoryId === 'itv' || streamType === 'live') { + if ( + categoryId === 'itv' || + streamType === 'live' || + isStalkerRadioItem(raw) + ) { return 'live'; } @@ -76,6 +80,40 @@ export function extractStalkerItemType( return 'movie'; } +/** + * Detect radio station records from Stalker favorites/recently-viewed payloads. + */ +export function isStalkerRadioItem( + item: StalkerPortalItem | Record +): boolean { + const raw = item as Record; + const radio = raw['radio']; + const radioFlag = + radio === true || + radio === 1 || + String(radio ?? '') + .trim() + .toLowerCase() === 'true' || + String(radio ?? '').trim() === '1'; + const categoryId = String(raw['category_id'] ?? '') + .trim() + .toLowerCase(); + const streamType = String(raw['stream_type'] ?? '') + .trim() + .toLowerCase(); + const cmd = String(raw['cmd'] ?? '') + .trim() + .toLowerCase(); + + return ( + radioFlag || + categoryId === 'radio' || + streamType === 'radio' || + cmd.includes('://radio/') || + cmd.includes('/radio/') + ); +} + /** * Normalise a raw date value (epoch number, numeric string, or ISO string) * into a consistent ISO 8601 string. @@ -131,16 +169,8 @@ function normalizeSqliteUtcTimestamp(value: string): string | null { return null; } - const [ - , - year, - month, - day, - hours, - minutes, - seconds, - milliseconds = '0', - ] = match; + const [, year, month, day, hours, minutes, seconds, milliseconds = '0'] = + match; const normalizedMs = milliseconds.padEnd(3, '0').slice(0, 3); return new Date( diff --git a/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts b/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts index 819ec3c7d..2df08c85e 100644 --- a/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts +++ b/libs/shared/interfaces/src/lib/stalker-portal-item.interface.ts @@ -17,9 +17,11 @@ export interface StalkerPortalItem { cover?: string; logo?: string; poster_url?: string; - /** 'itv' | 'vod' | 'series' — Stalker content type */ + /** Stalker content/category type such as 'itv', 'radio', 'vod', or 'series'. */ category_id?: string | number; stream_type?: string; + /** Radio marker returned by some Stalker portals for radio station lists. */ + radio?: boolean | number | string; is_series?: boolean | number | string; /** Embedded VOD-series episode numbers preserved for VOD favorites. */ series?: unknown[];