feat(global-widgets): add M3U recently-viewed and favs support

- Introduced M3uRecentlyViewedItem interface to define structure for recently viewed items.
- Implemented utility functions to handle mapping and building of recently viewed items from playlists.
- Updated Playlist interface to use PlaylistRecentlyViewedItem type for recently viewed items.
- Enhanced PortalActivityItem interface to include 'm3u' as a possible source.
- Added UI components for displaying recently viewed channels, including clear functionality.
- Integrated recent view functionality into the channel list container and sidebar components.
- Updated dashboard data service to manage M3U favorites and recently viewed items.
- Added tests to ensure correct behavior of recently viewed items in the dashboard data service.
This commit is contained in:
4gray committed 2026-03-11 23:57:13 +01:00
1 parent 188ebb6fd9
commit 1e9f5d9d05
27 files changed
+1286 -227

No files matched your search

+2
View File
@@ -12,6 +12,8 @@ export * from './lib/language.enum';
export * from './lib/parsed-playlist.interface';
export * from './lib/playback-position.interface';
export * from './lib/playlist-meta.type';
export * from './lib/playlist-recently-viewed.interface';
export * from './lib/playlist-recently-viewed.utils';
export * from './lib/playlist.interface';
export * from './lib/portal-activity-item.interface';
export * from './lib/portal-debug.interface';
@@ -0,0 +1,34 @@
import { StalkerPortalItem } from './stalker-portal-item.interface';
export interface M3uRecentlyViewedItem {
source: 'm3u';
id: string;
url: string;
title: string;
channel_id?: string;
poster_url?: string;
tvg_id?: string;
tvg_name?: string;
group_title?: string;
category_id: 'live';
added_at: string | number;
}
export type PlaylistRecentlyViewedItem =
| M3uRecentlyViewedItem
| StalkerPortalItem;
export function isM3uRecentlyViewedItem(
item: unknown
): item is M3uRecentlyViewedItem {
if (!item || typeof item !== 'object') {
return false;
}
const candidate = item as Record<string, unknown>;
return (
candidate['source'] === 'm3u' &&
typeof candidate['url'] === 'string' &&
candidate['url'].trim().length > 0
);
}
@@ -0,0 +1,117 @@
import { PlaylistMeta } from './playlist-meta.type';
import { PortalRecentItem } from './portal-activity-item.interface';
import {
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
} from './stalker-item.normalizer';
import {
PlaylistRecentlyViewedItem,
isM3uRecentlyViewedItem,
} from './playlist-recently-viewed.interface';
interface PlaylistRecentLabels {
stalker: string;
m3u: string;
}
function getPlaylistRecentItems(
playlist: PlaylistMeta
): PlaylistRecentlyViewedItem[] {
return Array.isArray(playlist.recentlyViewed)
? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[])
: [];
}
function mapStalkerPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem, index) => {
if (isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const item = (rawItem ?? {}) as Record<string, unknown>;
const id = extractStalkerItemId(item, playlist._id, index);
acc.push({
id,
title: extractStalkerItemTitle(item),
type: extractStalkerItemType(item),
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(item['added_at']),
category_id: String(item['category_id'] ?? ''),
xtream_id: id,
poster_url: extractStalkerItemPoster(item),
source: 'stalker',
stalker_item: rawItem,
});
return acc;
},
[]
);
}
function mapM3uPlaylistRecentItems(
playlist: PlaylistMeta,
defaultPlaylistName: string
): PortalRecentItem[] {
return getPlaylistRecentItems(playlist).reduce<PortalRecentItem[]>(
(acc, rawItem) => {
if (!isM3uRecentlyViewedItem(rawItem)) {
return acc;
}
const channelUrl = rawItem.url.trim();
if (!channelUrl) {
return acc;
}
acc.push({
id: rawItem.id || channelUrl,
title:
rawItem.title?.trim() ||
rawItem.tvg_name?.trim() ||
rawItem.channel_id ||
channelUrl,
type: 'live',
playlist_id: playlist._id,
playlist_name: playlist.title || defaultPlaylistName,
viewed_at: normalizeStalkerDate(rawItem.added_at),
category_id: rawItem.category_id || 'live',
xtream_id: channelUrl,
poster_url: rawItem.poster_url || undefined,
source: 'm3u',
});
return acc;
},
[]
);
}
export function buildPlaylistRecentItems(
playlists: PlaylistMeta[],
labels: PlaylistRecentLabels
): PortalRecentItem[] {
return playlists.reduce<PortalRecentItem[]>((acc, playlist) => {
if (playlist.macAddress) {
acc.push(
...mapStalkerPlaylistRecentItems(playlist, labels.stalker)
);
return acc;
}
if (!playlist.serverUrl) {
acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u));
}
return acc;
}, []);
}
@@ -1,3 +1,4 @@
import { PlaylistRecentlyViewedItem } from './playlist-recently-viewed.interface';
import { StalkerPortalItem } from './stalker-portal-item.interface';
/**
@@ -42,7 +43,7 @@ export interface Playlist {
password?: string;
macAddress?: string;
portalUrl?: string;
recentlyViewed?: any[];
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Session token for full stalker portal authentication - persisted for session */
@@ -16,7 +16,7 @@ export interface PortalActivityItem {
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
source?: 'xtream' | 'stalker';
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
}