mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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:
1 parent
6ae1251ad1
commit
a33d506266
13 files changed
+588
-97
No files matched your search
@@ -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
|
||||
|
||||
+15
-6
@@ -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[];
|
||||
|
||||
Reference in new issue
Block a user