fix(stalker): play radio collections with audio layout (#930)

* fix(stalker): play radio collections with audio layout

* fix(stalker): address radio review feedback
This commit is contained in:
4gray authored and GitHub committed 2026-05-12 19:48:38 +02:00
1 parent 6ae1251ad1
commit a33d506266
13 files changed
+588 -97

No files matched your search

+7
View File
@@ -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.
@@ -176,6 +180,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
@@ -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
@@ -445,4 +445,116 @@ 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<Playlist>)
);
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',
}),
})
);
});
it('uses direct Stalker radio HTTP commands without create_link', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com/portal.php',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
userAgent: 'IPTVnator',
referrer: 'https://ref.example.com',
origin: 'https://origin.example.com',
} satisfies Partial<Playlist>)
);
const detail = await service.resolveLiveDetail({
uid: 'stalker::stalker-1::40002',
name: 'Direct Radio',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '40002',
stalkerCmd: 'ffmpeg https://media.example.com/direct-radio.mp3',
logo: 'direct-radio.png',
radio: 'true',
} satisfies UnifiedCollectionItem);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
expect(stalkerSession.makeAuthenticatedRequest).not.toHaveBeenCalled();
expect(detail).toEqual(
expect.objectContaining({
epgMode: 'portal',
epgItems: [],
channel: expect.objectContaining({
id: '40002',
name: 'Direct Radio',
radio: 'true',
url: 'https://media.example.com/direct-radio.mp3',
http: expect.objectContaining({
referrer: 'https://ref.example.com',
'user-agent': 'IPTVnator',
origin: 'https://origin.example.com',
}),
}),
playback: expect.objectContaining({
streamUrl: 'https://media.example.com/direct-radio.mp3',
title: 'Direct Radio',
thumbnail: 'direct-radio.png',
userAgent: 'IPTVnator',
referer: 'https://ref.example.com',
origin: 'https://origin.example.com',
}),
})
);
});
});
@@ -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<ResolvedLiveCollectionDetail> {
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,23 @@ export class StreamResolverService {
const portalUrl =
item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
const normalizedCmd = this.normalizeStalkerCmd(item.stalkerCmd ?? '');
if (item.radio === 'true' && this.isHttpUrl(normalizedCmd)) {
return {
streamUrl: normalizedCmd,
title: item.name,
thumbnail: item.logo ?? null,
userAgent: playlist?.userAgent,
referer: playlist?.referrer,
origin: playlist?.origin,
};
}
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 +337,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: '',
};
}
@@ -826,4 +882,8 @@ export class StreamResolverService {
return trimmed;
}
private isHttpUrl(value: string): boolean {
return value.startsWith('http://') || value.startsWith('https://');
}
}
@@ -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}`
);
}
@@ -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<Playlist>)
);
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 = [
{
@@ -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<void> {
private async addXtreamFavorite(
item: UnifiedCollectionItem
): Promise<void> {
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<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
private async getM3uPlaylistFavorites(
id: string
): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(id);
return meta ? this.extractM3uFavorites(meta) : [];
}
private async extractM3uFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
private async extractM3uFavorites(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
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<string, Channel>();
channels.forEach((channel) => {
@@ -471,12 +486,17 @@ export class UnifiedFavoritesDataService {
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
private async getXtreamPlaylistFavorites(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
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<UnifiedCollectionItem[]> {
private async getStalkerPlaylistFavorites(
id: string
): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(id);
return meta ? this.extractStalkerFavorites(meta) : [];
}
private async extractStalkerFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
private async extractStalkerFavorites(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
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<PlaylistMeta[]> {
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<PlaylistMeta | undefined> {
private async getPlaylistMeta(
id: string
): Promise<PlaylistMeta | undefined> {
return (await this.getAllMeta()).find((p) => p._id === id);
}
private async getSavedOrder(): Promise<string[]> {
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<void> {
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<UnifiedCollectionItem, 'stalkerId' | 'uid'> | StalkerPortalItem
favorite:
| Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'>
| StalkerPortalItem
): string {
if ('uid' in favorite) {
return String(
@@ -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({
@@ -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<PlaylistMeta>,
])
);
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<Playlist>)
);
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);
});
@@ -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<PlaylistMeta | undefined> {
return (await this.getAllMeta()).find((playlist) => playlist._id === id);
return (await this.getAllMeta()).find(
(playlist) => playlist._id === id
);
}
private async getAllMeta(): Promise<PlaylistMeta[]> {
@@ -1,4 +1,41 @@
import { normalizeStalkerDate } from './stalker-item.normalizer';
import {
extractStalkerItemType,
isStalkerRadioItem,
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('isStalkerRadioItem', () => {
it('does not infer radio from a generic HTTP path segment', () => {
expect(
isStalkerRadioItem({
id: '77',
title: 'News Channel',
cmd: 'https://media.example.com/live/radio/news/index.m3u8',
})
).toBe(false);
});
});
describe('normalizeStalkerDate', () => {
it('normalizes SQLite UTC timestamps without relying on engine-specific parsing', () => {
@@ -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,39 @@ export function extractStalkerItemType(
return 'movie';
}
/**
* Detect radio station records from Stalker favorites/recently-viewed payloads.
*/
export function isStalkerRadioItem(
item: StalkerPortalItem | Record<string, unknown>
): boolean {
const raw = item as Record<string, unknown>;
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/')
);
}
/**
* Normalise a raw date value (epoch number, numeric string, or ISO string)
* into a consistent ISO 8601 string.
@@ -131,16 +168,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(
@@ -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[];