From 1e9f5d9d05f57b259dd5119ef991dcfdb6de4a5b Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 11 Mar 2026 23:57:13 +0100 Subject: [PATCH] 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. --- .../video-player/video-player.component.ts | 118 +++++++- .../playlist-switcher.component.ts | 40 ++- .../favorites-layout.component.ts | 2 +- .../lib/navigation/portal-rail-links.spec.ts | 7 + .../src/lib/navigation/portal-rail-links.ts | 7 + .../data-access/src/lib/with-recent-items.ts | 79 +---- .../recently-viewed.component.html | 2 +- .../recently-viewed.component.ts | 22 ++ libs/services/src/lib/playlists.service.ts | 269 +++++++++++------- libs/shared/interfaces/src/index.ts | 2 + .../lib/playlist-recently-viewed.interface.ts | 34 +++ .../src/lib/playlist-recently-viewed.utils.ts | 117 ++++++++ .../interfaces/src/lib/playlist.interface.ts | 3 +- .../src/lib/portal-activity-item.interface.ts | 2 +- .../channel-list-container.component.html | 19 ++ .../channel-list-container.component.ts | 120 +++++++- .../channel-list-item.component.html | 13 +- .../channel-list-item.component.ts | 9 + .../recent-view/recent-view.component.html | 37 +++ .../recent-view/recent-view.component.scss | 33 +++ .../recent-view/recent-view.component.ts | 80 ++++++ .../sidebar/sidebar.component.html | 1 + .../video-player/sidebar/sidebar.component.ts | 3 +- .../src/lib/dashboard-data.service.spec.ts | 211 +++++++++++++- .../src/lib/dashboard-data.service.ts | 223 ++++++++++++--- .../src/lib/dashboard-navigation.utils.ts | 15 + .../workspace-shell.component.ts | 45 ++- 27 files changed, 1286 insertions(+), 227 deletions(-) create mode 100644 libs/shared/interfaces/src/lib/playlist-recently-viewed.interface.ts create mode 100644 libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index 5e6a09508..94ab75a51 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -32,10 +32,12 @@ import { FavoritesActions, PlaylistActions, selectActive, + selectActivePlaylistId, selectChannels, selectCurrentEpgProgram, } from 'm3u-state'; import { + firstValueFrom, Observable, Subscription, combineLatest, @@ -64,7 +66,9 @@ import { DataService, PlaylistsService, SettingsStore } from 'services'; import { Channel, EpgProgram, + M3uRecentlyViewedItem, PLAYLIST_PARSE_BY_URL, + PlaylistMeta, STORE_KEY, Settings, SidebarView, @@ -106,6 +110,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Active selected channel */ readonly activeChannel = this.store.selectSignal(selectActive); + readonly activePlaylistId = this.store.selectSignal(selectActivePlaylistId); + readonly channels = this.store.selectSignal(selectChannels); /** Channels list */ readonly channels$: Observable = this.store.select( @@ -115,7 +121,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { /** Current epg program */ readonly epgProgram = this.store.selectSignal(selectCurrentEpgProgram); - /** Active M3U view (all, groups, favorites) */ + /** Active M3U view (all, groups, favorites, recent) */ readonly activeView = toSignal( this.activatedRoute.params.pipe( map((params) => { @@ -153,6 +159,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private statusSubscription?: Subscription; private routeSubscription?: Subscription; private lastKnownVolume = 1; + private lastRecordedRecentKey = ''; /** Info overlay component reference for manual triggering */ readonly infoOverlay = viewChild(InfoOverlayComponent); @@ -178,6 +185,62 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { showCaptions: this.settingsStore.showCaptions(), }; }); + + effect(() => { + const playlistId = this.activePlaylistId(); + const activeChannel = this.activeChannel(); + const currentUrl = this.router.url; + + if ( + !playlistId || + !activeChannel?.url || + currentUrl.includes('/workspace/global-favorites') + ) { + return; + } + + const nextKey = `${playlistId}::${activeChannel.url}`; + if (this.lastRecordedRecentKey === nextKey) { + return; + } + + this.lastRecordedRecentKey = nextKey; + void this.persistRecentlyViewedChannel(playlistId, activeChannel); + }); + + effect(() => { + const currentView = this.activeView(); + const channels = this.channels(); + const activeChannel = this.activeChannel(); + const state = + this.router.currentNavigation()?.extras?.state ?? + window.history.state; + const targetUrl = + typeof state?.openRecentChannelUrl === 'string' + ? state.openRecentChannelUrl.trim() + : ''; + + if (currentView !== 'recent' || !targetUrl || channels.length === 0) { + return; + } + + if (activeChannel?.url === targetUrl) { + this.clearConsumedRecentChannelState(); + return; + } + + const matchedChannel = channels.find( + (channel) => channel.url === targetUrl + ); + if (!matchedChannel) { + return; + } + + this.store.dispatch( + ChannelActions.setActiveChannel({ channel: matchedChannel }) + ); + this.clearConsumedRecentChannelState(); + }); } /** @@ -410,6 +473,59 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { }); } + private async persistRecentlyViewedChannel( + playlistId: string, + channel: Channel + ): Promise { + const recentlyViewedItem: M3uRecentlyViewedItem = { + source: 'm3u', + id: channel.url, + url: channel.url, + title: channel.name?.trim() || channel.tvg?.name || channel.url, + channel_id: channel.id, + poster_url: channel.tvg?.logo || undefined, + tvg_id: channel.tvg?.id || undefined, + tvg_name: channel.tvg?.name || undefined, + group_title: channel.group?.title || undefined, + category_id: 'live', + added_at: new Date().toISOString(), + }; + + const updatedPlaylist = await firstValueFrom( + this.playlistsService.addM3uRecentlyViewed( + playlistId, + recentlyViewedItem + ) + ); + + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as PlaylistMeta, + }) as any + ); + } + + private clearConsumedRecentChannelState(): void { + const historyState = (window.history.state ?? {}) as Record< + string, + unknown + >; + if (!historyState['openRecentChannelUrl']) { + return; + } + + try { + const nextState = { ...historyState }; + delete nextState['openRecentChannelUrl']; + window.history.replaceState(nextState, document.title); + } catch { + // no-op + } + } + /** * Opens the overlay with multi EPG view */ diff --git a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts index 06e554580..6decf0151 100644 --- a/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts +++ b/libs/playlist/shared/ui/src/lib/playlist-switcher/playlist-switcher.component.ts @@ -32,7 +32,7 @@ import { PortalStatus, PortalStatusService } from 'services'; import { PlaylistMeta } from 'shared-interfaces'; type PlaylistRouteProvider = 'playlists' | 'xtreams' | 'stalker'; -type PlaylistSectionProvider = Exclude; +type PlaylistSectionProvider = PlaylistRouteProvider; type PlaylistFilterType = 'm3u' | 'stalker' | 'xtream'; interface PlaylistRouteContext { @@ -83,6 +83,7 @@ const STALKER_SECTIONS = [ 'search', 'downloads', ] as const; +const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const; @Component({ selector: 'app-playlist-switcher', @@ -292,11 +293,7 @@ export class PlaylistSwitcherComponent { private rememberSectionFromRoute(url: string): void { const routeContext = this.getRouteContext(url); - if ( - !routeContext.provider || - routeContext.provider === 'playlists' || - !routeContext.section - ) { + if (!routeContext.provider || !routeContext.section) { return; } @@ -502,7 +499,12 @@ export class PlaylistSwitcherComponent { } if (provider === 'playlists') { - return [...prefix, 'playlists', playlistId]; + const section = this.resolveTargetSection( + provider, + routeContext.section, + playlistId + ); + return [...prefix, 'playlists', playlistId, section]; } if (!this.supportsSectionNavigation(provider)) { @@ -551,6 +553,10 @@ export class PlaylistSwitcherComponent { return normalizedProviderSection; } + if (provider === 'playlists') { + return 'all'; + } + return 'vod'; } @@ -582,6 +588,14 @@ export class PlaylistSwitcherComponent { : null; } + if (provider === 'playlists') { + return M3U_SECTIONS.includes( + section as (typeof M3U_SECTIONS)[number] + ) + ? section + : null; + } + if (section === 'live') { return 'itv'; } @@ -620,6 +634,9 @@ export class PlaylistSwitcherComponent { parsedProviders !== null ) { const candidate = parsedProviders as Record; + if (typeof candidate['playlists'] === 'string') { + providers.playlists = candidate['playlists']; + } if (typeof candidate['xtreams'] === 'string') { providers.xtreams = candidate['xtreams']; } @@ -628,8 +645,11 @@ export class PlaylistSwitcherComponent { } } else { // Backward compatibility with previous format: - // { xtreams: "...", stalker: "..." } + // { playlists: "...", xtreams: "...", stalker: "..." } const legacy = parsed as Record; + if (typeof legacy['playlists'] === 'string') { + providers.playlists = legacy['playlists']; + } if (typeof legacy['xtreams'] === 'string') { providers.xtreams = legacy['xtreams']; } @@ -657,7 +677,9 @@ export class PlaylistSwitcherComponent { const section = row['section']; const updatedAt = row['updatedAt']; const isProviderValid = - provider === 'xtreams' || provider === 'stalker'; + provider === 'playlists' || + provider === 'xtreams' || + provider === 'stalker'; if (!isProviderValid || typeof section !== 'string') { return; } diff --git a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts index 340c7a3fd..1cdd1afd8 100644 --- a/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts +++ b/libs/portal/shared/ui/src/lib/components/favorites-layout/favorites-layout.component.ts @@ -31,7 +31,7 @@ export interface FavoriteLayoutItem { readonly playlist_id?: string; readonly playlist_name?: string; readonly poster_url?: string; - readonly source?: 'xtream' | 'stalker'; + readonly source?: 'xtream' | 'stalker' | 'm3u'; readonly stalker_item?: StalkerPortalItem; readonly stream_icon?: string; readonly title?: string; diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index ad3c432a6..4ce28546a 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -94,6 +94,13 @@ describe('buildPortalRailLinks', () => { exact: true, section: 'groups', }, + { + icon: 'history', + tooltip: 'Recently viewed (this playlist)', + path: ['/workspace', 'playlists', 'm3u-1', 'recent'], + exact: true, + section: 'recent', + }, { icon: 'star', tooltip: 'Favorites (this playlist)', diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index 4ce9dc444..bf24ffe78 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -187,6 +187,13 @@ export function buildPortalRailLinks( exact: true, section: 'groups', }, + { + icon: 'history', + tooltip: 'Recently viewed (this playlist)', + path: [...root, 'recent'], + exact: true, + section: 'recent', + }, { icon: 'star', tooltip: 'Favorites (this playlist)', diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index fc1094c2d..1065afc69 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -9,11 +9,7 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { firstValueFrom, pipe, switchMap, tap } from 'rxjs'; import { DatabaseService, PlaylistsService } from 'services'; import { - extractStalkerItemId, - extractStalkerItemPoster, - extractStalkerItemTitle, - extractStalkerItemType, - normalizeStalkerDate, + buildPlaylistRecentItems, Playlist, PortalRecentItem, } from 'shared-interfaces'; @@ -148,61 +144,14 @@ export const withRecentItems = function () { const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; - const stalkerItems: RecentlyViewedItem[] = playlists - .filter((playlist) => Boolean(playlist.macAddress)) - .reduce( - (acc: RecentlyViewedItem[], playlist) => { - const recent = Array.isArray( - playlist.recentlyViewed - ) - ? playlist.recentlyViewed - : []; - const mapped = recent.map( - (rawItem, index: number) => { - const item = (rawItem ?? - {}) as Record; - const id = extractStalkerItemId( - item, - String(playlist._id), - index - ); - - return { - id, - title: extractStalkerItemTitle( - item - ), - type: extractStalkerItemType( - item - ), - poster_url: - extractStalkerItemPoster( - item - ), - content_id: id, - playlist_id: String( - playlist._id - ), - playlist_name: - playlist.title || - 'Stalker Portal', - viewed_at: normalizeStalkerDate( - item['added_at'] - ), - xtream_id: id, - category_id: String( - item['category_id'] ?? '' - ), - source: 'stalker' as const, - stalker_item: rawItem, - }; - } - ); - acc.push(...mapped); - return acc; - }, - [] - ); + const playlistBackedItems = + buildPlaylistRecentItems(playlists, { + stalker: 'Stalker Portal', + m3u: 'M3U', + }).map((item) => ({ + ...item, + content_id: item.id, + })) as RecentlyViewedItem[]; const normalizedXtream: RecentlyViewedItem[] = ( xtreamItems || [] @@ -222,7 +171,7 @@ export const withRecentItems = function () { const items = [ ...normalizedXtream, - ...stalkerItems, + ...playlistBackedItems, ].sort( (a, b) => new Date(b.viewed_at).getTime() - @@ -247,12 +196,14 @@ export const withRecentItems = function () { )) as Playlist[]; await Promise.all( playlists - .filter((playlist) => - Boolean(playlist.macAddress) + .filter( + (playlist) => + Boolean(playlist.macAddress) || + !playlist.serverUrl ) .map((playlist) => firstValueFrom( - playlistsService.clearPortalRecentlyViewed( + playlistsService.clearPlaylistRecentlyViewed( playlist._id ) ) diff --git a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html index 3781767f3..5cc6951f2 100644 --- a/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html +++ b/libs/portal/xtream/feature/src/lib/recently-viewed/recently-viewed.component.html @@ -32,7 +32,7 @@
info_outline - Includes Xtream and Stalker items + Includes Xtream, Stalker, and M3U items
undefined)); } - getPortalRecentlyViewed(portalId: string) { - if (!portalId) { - throw new Error('Portal ID is required'); + private normalizePortalRecentIdentity(value: unknown): string { + const raw = String(value ?? '').trim(); + if (!raw) { + return ''; } - return this.getPlaylistById(portalId).pipe( + return raw.includes(':') ? raw.split(':')[0] : raw; + } + + private getPlaylistRecentIdentity(item: PlaylistRecentlyViewedItem): string { + if (isM3uRecentlyViewedItem(item)) { + return String(item.url ?? item.id ?? '').trim(); + } + + return this.normalizePortalRecentIdentity( + extractStalkerItemId(item ?? {}) + ); + } + + private matchesPlaylistRecentIdentity( + item: PlaylistRecentlyViewedItem, + expectedIdentity: string | number + ): boolean { + const expectedRaw = String(expectedIdentity ?? '').trim(); + if (!expectedRaw) { + return false; + } + + if (isM3uRecentlyViewedItem(item)) { + return this.getPlaylistRecentIdentity(item) === expectedRaw; + } + + return ( + this.getPlaylistRecentIdentity(item) === + this.normalizePortalRecentIdentity(expectedRaw) + ); + } + + private sortPlaylistRecentItems( + items: PlaylistRecentlyViewedItem[] + ): PlaylistRecentlyViewedItem[] { + return [...items].sort( + (a, b) => + new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() - + new Date(normalizeStalkerDate(a.added_at ?? '')).getTime() + ); + } + + getPlaylistRecentlyViewed(playlistId: string) { + if (!playlistId) { + throw new Error('Playlist ID is required'); + } + + return this.getPlaylistById(playlistId).pipe( map((item) => { - if (!item || !item.recentlyViewed) return []; - return item.recentlyViewed as PortalRecentlyViewedItem[]; + if (!item || !Array.isArray(item.recentlyViewed)) { + return []; + } + return item.recentlyViewed as PlaylistRecentlyViewedItem[]; }), - map((items) => - items.sort( - (a, b) => - new Date(b.added_at ?? '').getTime() - - new Date(a.added_at ?? '').getTime() - ) - ) + map((items) => this.sortPlaylistRecentItems(items)) ); } - addPortalRecentlyViewed( - portalId: string, - item: PortalRecentlyViewedItem & { id: string | number; title: string } + addPlaylistRecentlyViewed( + playlistId: string, + item: PlaylistRecentlyViewedItem ) { - if (!portalId) { - throw new Error('Portal ID is required'); + if (!playlistId) { + throw new Error('Playlist ID is required'); } - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { + + return this.getPlaylistById(playlistId).pipe( + switchMap((playlist) => { const nowIso = new Date().toISOString(); - const recentItems = - (portal.recentlyViewed as PortalRecentlyViewedItem[]) ?? []; - const existingIndex = recentItems.findIndex( - (recentItem) => String(recentItem?.id) === String(item.id) + const recentItems = Array.isArray(playlist.recentlyViewed) + ? (playlist.recentlyViewed as PlaylistRecentlyViewedItem[]) + : []; + const existingIndex = recentItems.findIndex((recentItem) => + this.matchesPlaylistRecentIdentity( + recentItem, + this.getPlaylistRecentIdentity(item) + ) + ); + const existingItem = + existingIndex >= 0 ? recentItems[existingIndex] : null; + const nextItem: PlaylistRecentlyViewedItem = { + ...(existingItem ?? {}), + ...item, + added_at: nowIso, + }; + const remainingItems = recentItems.filter( + (_, index) => index !== existingIndex ); - - if (existingIndex >= 0) { - const updatedRecentItems = [...recentItems]; - updatedRecentItems[existingIndex] = { - ...updatedRecentItems[existingIndex], - ...item, - added_at: nowIso, - }; - - const nextPlaylist: Playlist = { - ...portal, - recentlyViewed: updatedRecentItems, - }; - - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update( - DbStores.Playlists, - nextPlaylist - ); - } - const nextPlaylist: Playlist = { - ...portal, - recentlyViewed: [ - ...recentItems, - { - ...item, - added_at: item.added_at ?? nowIso, - }, - ], + ...playlist, + recentlyViewed: [nextItem, ...remainingItems], }; if (this.isElectronStorageAvailable) { @@ -732,49 +761,47 @@ export class PlaylistsService { ); } - removeFromPortalRecentlyViewed(portalId: string, id: string | number) { - const normalizePortalItemId = (value: unknown): string => { - const raw = String(value ?? '').trim(); - if (!raw) return ''; - return raw.includes(':') ? raw.split(':')[0] : raw; - }; - - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { - const expectedId = String(id); - const expectedNormalized = normalizePortalItemId(id); - - const nextPlaylist: Playlist = { - ...portal, - recentlyViewed: ( - portal.recentlyViewed as PortalRecentlyViewedItem[] - )?.filter((item) => { - const itemId = String(item?.id ?? ''); - const itemNormalized = normalizePortalItemId(itemId); - return ( - itemId !== expectedId && - itemNormalized !== expectedNormalized - ); - }), - }; - - if (this.isElectronStorageAvailable) { - return this.upsertSqlitePlaylist(nextPlaylist); - } - - return this.dbService.update(DbStores.Playlists, nextPlaylist); - }) - ); - } - - clearPortalRecentlyViewed(portalId: string) { - if (!portalId) { - throw new Error('Portal ID is required'); + removeFromPlaylistRecentlyViewed( + playlistId: string, + identity: string | number + ) { + if (!playlistId) { + throw new Error('Playlist ID is required'); } - return this.getPlaylistById(portalId).pipe( - switchMap((portal) => { + + return this.getPlaylistById(playlistId).pipe( + switchMap((playlist) => { const nextPlaylist: Playlist = { - ...portal, + ...playlist, + recentlyViewed: ( + playlist.recentlyViewed as PlaylistRecentlyViewedItem[] + )?.filter( + (item) => + !this.matchesPlaylistRecentIdentity( + item, + identity + ) + ), + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) + ); + } + + clearPlaylistRecentlyViewed(playlistId: string) { + if (!playlistId) { + throw new Error('Playlist ID is required'); + } + + return this.getPlaylistById(playlistId).pipe( + switchMap((playlist) => { + const nextPlaylist: Playlist = { + ...playlist, recentlyViewed: [], }; @@ -786,4 +813,42 @@ export class PlaylistsService { }) ); } + + getPortalRecentlyViewed(portalId: string) { + return this.getPlaylistRecentlyViewed(portalId).pipe( + map((items) => + items.filter( + (item): item is StalkerPortalItem => + !isM3uRecentlyViewedItem(item) + ) + ) + ); + } + + addPortalRecentlyViewed( + portalId: string, + item: StalkerPortalItem & { id: string | number; title: string } + ) { + return this.addPlaylistRecentlyViewed(portalId, item); + } + + addM3uRecentlyViewed(playlistId: string, item: M3uRecentlyViewedItem) { + return this.addPlaylistRecentlyViewed(playlistId, item); + } + + removeFromPortalRecentlyViewed(portalId: string, id: string | number) { + return this.removeFromPlaylistRecentlyViewed(portalId, id); + } + + removeFromM3uRecentlyViewed(playlistId: string, channelUrl: string) { + return this.removeFromPlaylistRecentlyViewed(playlistId, channelUrl); + } + + clearPortalRecentlyViewed(portalId: string) { + return this.clearPlaylistRecentlyViewed(portalId); + } + + clearM3uRecentlyViewed(playlistId: string) { + return this.clearPlaylistRecentlyViewed(playlistId); + } } diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 96c43f53d..61899cf1a 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.interface.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.interface.ts new file mode 100644 index 000000000..679b8c10b --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.interface.ts @@ -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; + return ( + candidate['source'] === 'm3u' && + typeof candidate['url'] === 'string' && + candidate['url'].trim().length > 0 + ); +} diff --git a/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts new file mode 100644 index 000000000..ab037c7c0 --- /dev/null +++ b/libs/shared/interfaces/src/lib/playlist-recently-viewed.utils.ts @@ -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( + (acc, rawItem, index) => { + if (isM3uRecentlyViewedItem(rawItem)) { + return acc; + } + + const item = (rawItem ?? {}) as Record; + 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( + (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((acc, playlist) => { + if (playlist.macAddress) { + acc.push( + ...mapStalkerPlaylistRecentItems(playlist, labels.stalker) + ); + return acc; + } + + if (!playlist.serverUrl) { + acc.push(...mapM3uPlaylistRecentItems(playlist, labels.m3u)); + } + + return acc; + }, []); +} diff --git a/libs/shared/interfaces/src/lib/playlist.interface.ts b/libs/shared/interfaces/src/lib/playlist.interface.ts index 8c4e51c3a..d20486123 100644 --- a/libs/shared/interfaces/src/lib/playlist.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist.interface.ts @@ -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 */ diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index e02f1a336..bb430a344 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -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; } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index a9f69725f..614358967 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -3,6 +3,14 @@

{{ viewTitle() | translate }}

+ @if ( + activeView() === 'recent' && + recentChannelItems().length > 0 + ) { + + }
@@ -49,6 +57,17 @@ /> } } + @case ('recent') { + + } } } @else {
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index a8052adc9..dccdaf408 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -11,6 +11,7 @@ import { signal, } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -20,15 +21,37 @@ import { EpgService } from '@iptvnator/epg/data-access'; import { ChannelActions, FavoritesActions, + PlaylistActions, selectActive, selectActivePlaylistId, selectFavorites, } from 'm3u-state'; -import { BehaviorSubject, combineLatest, filter, map, skipWhile } from 'rxjs'; -import { Channel, EpgProgram, Settings, STORE_KEY } from 'shared-interfaces'; +import { + BehaviorSubject, + combineLatest, + filter, + firstValueFrom, + map, + skipWhile, +} from 'rxjs'; +import { PlaylistsService } from 'services'; +import { + Channel, + EpgProgram, + isM3uRecentlyViewedItem, + normalizeStalkerDate, + PlaylistMeta, + PlaylistRecentlyViewedItem, + Settings, + STORE_KEY, +} from 'shared-interfaces'; import { AllChannelsViewComponent } from './all-channels-view/all-channels-view.component'; import { FavoritesViewComponent } from './favorites-view/favorites-view.component'; import { GroupsViewComponent } from './groups-view/groups-view.component'; +import { + RecentViewComponent, + RecentViewItem, +} from './recent-view/recent-view.component'; function groupChannelsByTitle(channels: Channel[]): Record { return channels.reduce>((groups, channel) => { @@ -51,12 +74,15 @@ function groupChannelsByTitle(channels: Channel[]): Record { CommonModule, FavoritesViewComponent, GroupsViewComponent, + MatButtonModule, MatIconModule, + RecentViewComponent, TranslatePipe, ], }) export class ChannelListContainerComponent implements OnInit, OnDestroy { private readonly epgService = inject(EpgService); + private readonly playlistsService = inject(PlaylistsService); private readonly storage = inject(StorageMap); private readonly store = inject(Store); private readonly router = inject(Router); @@ -79,8 +105,9 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { /** Item size for virtual scroll - compact when no EPG */ readonly itemSize = computed(() => (this.shouldShowEpg() ? 68 : 48)); - /** Active view (all, groups, favorites) */ + /** Active view (all, groups, favorites, recent) */ readonly activeView = input('all'); + readonly recentItems = input([]); readonly currentUrl = toSignal( this.router.events.pipe( @@ -95,6 +122,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { const url = this.currentUrl(); if (view === 'all') return 'CHANNELS.ALL_CHANNELS'; if (view === 'groups') return 'CHANNELS.GROUPS'; + if (view === 'recent') return 'PORTALS.SIDEBAR.RECENT'; if (view === 'favorites') { return url.includes('/workspace/global-favorites') ? 'HOME.PLAYLISTS.GLOBAL_FAVORITES' @@ -136,6 +164,49 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { groupChannelsByTitle(this.displayedChannels()) ); + readonly recentChannelItems = computed(() => { + const channels = this.channelListSignal(); + const recentItems = this.recentItems(); + const channelsByUrl = new Map( + channels.map((channel) => [channel.url, channel] as const) + ); + const channelsById = new Map( + channels.map((channel) => [channel.id, channel] as const) + ); + const seenUrls = new Set(); + + return [...recentItems] + .filter(isM3uRecentlyViewedItem) + .sort( + (a, b) => + new Date(normalizeStalkerDate(b.added_at)).getTime() - + new Date(normalizeStalkerDate(a.added_at)).getTime() + ) + .reduce((acc, item) => { + const channelUrl = item.url?.trim(); + if (!channelUrl || seenUrls.has(channelUrl)) { + return acc; + } + + const channel = + channelsByUrl.get(channelUrl) || + (item.channel_id + ? channelsById.get(item.channel_id) + : undefined); + + if (!channel) { + return acc; + } + + seenUrls.add(channel.url); + acc.push({ + channel, + viewedAt: normalizeStalkerDate(item.added_at), + }); + return acc; + }, []); + }); + /** Selected channel */ readonly activeChannel = this.store.selectSignal(selectActive); @@ -263,4 +334,47 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { onFavoritesReordered(channelIds: string[]): void { this.store.dispatch(FavoritesActions.setFavorites({ channelIds })); } + + async removeRecentChannel(channelUrl: string): Promise { + const playlistId = this.activePlaylistIdSignal(); + if (!playlistId) { + return; + } + + const updatedPlaylist = await firstValueFrom( + this.playlistsService.removeFromM3uRecentlyViewed( + playlistId, + channelUrl + ) + ); + + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as PlaylistMeta, + }) as any + ); + } + + async clearRecentChannels(): Promise { + const playlistId = this.activePlaylistIdSignal(); + if (!playlistId) { + return; + } + + const updatedPlaylist = await firstValueFrom( + this.playlistsService.clearM3uRecentlyViewed(playlistId) + ); + + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: playlistId, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as PlaylistMeta, + }) as any + ); + } } diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 9a90a725d..bd2dc37cf 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -58,7 +58,8 @@
@if ( (showProgramInfoButton() && showEpg() && epgProgram()) || - showFavoriteButton() + showFavoriteButton() || + showAuxActionButton() ) {
@if (showProgramInfoButton() && showEpg() && epgProgram(); as program) { @@ -90,6 +91,16 @@ }} } + @if (showAuxActionButton()) { + + }
}
diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index dc13185f4..fc795178f 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -39,6 +39,7 @@ export class ChannelListItemComponent { readonly logo = input(''); readonly name = input(''); readonly showFavoriteButton = input(false); + readonly showAuxActionButton = input(false); readonly showProgramInfoButton = input(true); readonly isFavorite = input(false); readonly selected = input(false); @@ -46,9 +47,12 @@ export class ChannelListItemComponent { readonly epgProgram = input(); /** Progress percentage pre-computed by parent for performance */ readonly progressPercentage = input(0); + readonly auxActionIcon = input('delete'); + readonly auxActionTooltip = input(''); readonly clicked = output(); readonly favoriteToggled = output(); + readonly auxActionClicked = output(); constructor() { effect(() => { @@ -86,6 +90,11 @@ export class ChannelListItemComponent { this.favoriteToggled.emit(event); } + onAuxActionClick(event: MouseEvent): void { + event.stopPropagation(); + this.auxActionClicked.emit(event); + } + showLogoFallback(): boolean { return !this.logo() || this.logoFailed(); } diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html new file mode 100644 index 000000000..522cb90fa --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.html @@ -0,0 +1,37 @@ +
+ @if (enrichedRecentItems().length > 0) { + @for ( + item of enrichedRecentItems(); + track item.channel.url; + let i = $index + ) { + + } + } @else { +
+ {{ 'WORKSPACE.DASHBOARD.NO_HISTORY' | translate }} +
+
+ {{ 'WORKSPACE.DASHBOARD.NO_HISTORY_HINT' | translate }} +
+ } +
diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss new file mode 100644 index 000000000..a9839386a --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.scss @@ -0,0 +1,33 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +#recent-list { + flex: 1; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; +} + +.channel-list { + overflow-x: hidden; +} + +.empty-recent, +.empty-recent-hint { + padding: 16px 24px; + text-align: center; +} + +.empty-recent { + margin-top: 40px; + font-size: 1rem; +} + +.empty-recent-hint { + opacity: 0.7; + font-size: 0.9rem; +} diff --git a/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts new file mode 100644 index 000000000..99b86996d --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/recent-view/recent-view.component.ts @@ -0,0 +1,80 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, +} from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; +import { Channel, EpgProgram } from 'shared-interfaces'; +import { ChannelListItemComponent } from '../channel-list-item/channel-list-item.component'; + +export interface RecentViewItem { + channel: Channel; + viewedAt: string; +} + +@Component({ + selector: 'app-recent-view', + templateUrl: './recent-view.component.html', + styleUrls: ['./recent-view.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ChannelListItemComponent, TranslatePipe], +}) +export class RecentViewComponent { + readonly recentItems = input.required(); + readonly channelEpgMap = input.required>(); + readonly progressTick = input.required(); + readonly shouldShowEpg = input.required(); + readonly activeChannelUrl = input(); + + readonly channelSelected = output(); + readonly removeRecent = output(); + + readonly enrichedRecentItems = computed(() => { + const recentItems = this.recentItems(); + const epgMap = this.channelEpgMap(); + this.progressTick(); + + return recentItems.map(({ channel, viewedAt }) => { + const channelId = channel?.tvg?.id?.trim() || channel?.name?.trim(); + const epgProgram = channelId ? epgMap.get(channelId) : null; + + return { + channel, + viewedAt, + epgProgram, + progressPercentage: this.calculateProgress(epgProgram), + }; + }); + }); + + private calculateProgress( + epgProgram: EpgProgram | null | undefined + ): number { + if (!epgProgram) { + return 0; + } + + const now = new Date().getTime(); + const start = new Date(epgProgram.start).getTime(); + const stop = new Date(epgProgram.stop).getTime(); + const total = stop - start; + const elapsed = now - start; + + return Math.min(100, Math.max(0, (elapsed / total) * 100)); + } + + trackByFn(_: number, item: RecentViewItem): string { + return item.channel?.url; + } + + onChannelClick(channel: Channel): void { + this.channelSelected.emit(channel); + } + + onRemoveRecent(channel: Channel, event: MouseEvent): void { + event.stopPropagation(); + this.removeRecent.emit(channel.url); + } +} diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index 6fbdcb0df..fc3b288de 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -20,4 +20,5 @@ diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index a50096cf6..c2d9d627c 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -6,7 +6,7 @@ import { RouterLink } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui'; -import { selectPlaylistTitle } from 'm3u-state'; +import { selectActivePlaylist, selectPlaylistTitle } from 'm3u-state'; import { Channel } from 'shared-interfaces'; import { ChannelListContainerComponent } from 'components'; @@ -33,6 +33,7 @@ export class SidebarComponent { private readonly translate = inject(TranslateService); readonly playlistTitle = this.store.selectSignal(selectPlaylistTitle); + readonly activePlaylist = this.store.selectSignal(selectActivePlaylist); readonly subtitle = computed(() => { const count = this.channels()?.length ?? 0; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index f091c8977..c2ff67761 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -4,11 +4,11 @@ import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { selectAllPlaylistsMeta } from 'm3u-state'; import { of } from 'rxjs'; -import { DatabaseService } from 'services'; -import { PlaylistMeta } from 'shared-interfaces'; +import { DatabaseService, PlaylistsService } from 'services'; +import { Playlist, PlaylistMeta } from 'shared-interfaces'; import { DashboardDataService } from './dashboard-data.service'; -describe('DashboardDataService.matchesScope', () => { +describe('DashboardDataService', () => { let service: DashboardDataService; const playlistsSignal = signal([ @@ -18,6 +18,22 @@ describe('DashboardDataService.matchesScope', () => { count: 1, importDate: '2026-01-01T00:00:00.000Z', autoRefresh: false, + favorites: ['channel-1', 'https://example.com/stream-2.m3u8'], + recentlyViewed: [ + { + source: 'm3u', + id: 'https://example.com/stream-1.m3u8', + url: 'https://example.com/stream-1.m3u8', + title: 'Channel One', + channel_id: 'channel-1', + poster_url: 'https://example.com/logo-1.png', + tvg_id: 'tvg-1', + tvg_name: 'Channel One', + group_title: 'News', + category_id: 'live', + added_at: '2026-02-01T12:00:00.000Z', + }, + ], }, { _id: 'xtream-1', @@ -36,19 +52,90 @@ describe('DashboardDataService.matchesScope', () => { } return signal(null); }), + dispatch: jest.fn(), }; const dbServiceMock = { - getGlobalRecentlyViewed: jest.fn(), - getGlobalFavorites: jest.fn(), + getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]), + getGlobalFavorites: jest.fn().mockResolvedValue([]), + removeFromFavorites: jest.fn().mockResolvedValue(undefined), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; + const playlistMock: Playlist = { + _id: 'm3u-1', + title: 'M3U Playlist', + count: 2, + importDate: '2026-01-01T00:00:00.000Z', + lastUsage: '2026-01-01T00:00:00.000Z', + autoRefresh: false, + favorites: ['channel-1', 'https://example.com/stream-2.m3u8'], + playlist: { + items: [ + { + id: 'channel-1', + name: 'Channel One', + url: 'https://example.com/stream-1.m3u8', + tvg: { + logo: 'https://example.com/logo-1.png', + id: 'tvg-1', + name: 'Channel One', + }, + }, + { + id: 'channel-2', + name: 'Channel Two', + url: 'https://example.com/stream-2.m3u8', + tvg: { + logo: 'https://example.com/logo-2.png', + id: 'tvg-2', + name: 'Channel Two', + }, + }, + ], + }, + } as Playlist; + const playlistsServiceMock = { + getPlaylistById: jest.fn().mockReturnValue(of(playlistMock)), + setFavorites: jest.fn().mockReturnValue(of(undefined)), + removeFromM3uRecentlyViewed: jest + .fn() + .mockReturnValue( + of({ + ...playlistMock, + recentlyViewed: [], + }) + ), }; beforeEach(() => { + playlistsServiceMock.getPlaylistById.mockClear(); + playlistsServiceMock.getPlaylistById.mockReturnValue(of(playlistMock)); + playlistsServiceMock.setFavorites.mockClear(); + playlistsServiceMock.setFavorites.mockReturnValue(of(undefined)); + playlistsServiceMock.removeFromM3uRecentlyViewed.mockClear(); + playlistsServiceMock.removeFromM3uRecentlyViewed.mockReturnValue( + of({ + ...playlistMock, + recentlyViewed: [], + }) + ); + dbServiceMock.getGlobalFavorites.mockClear(); + dbServiceMock.getGlobalFavorites.mockResolvedValue([]); + dbServiceMock.getGlobalRecentlyViewed.mockClear(); + dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]); + dbServiceMock.removeFromFavorites.mockClear(); + dbServiceMock.removeRecentItem.mockClear(); + storeMock.dispatch.mockClear(); + TestBed.configureTestingModule({ providers: [ DashboardDataService, { provide: Store, useValue: storeMock }, { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: PlaylistsService, + useValue: playlistsServiceMock, + }, { provide: TranslateService, useValue: { @@ -102,4 +189,118 @@ describe('DashboardDataService.matchesScope', () => { expect(result).toBe(true); }); + + it('includes M3U favorites in global favorite items', async () => { + await service.reloadGlobalFavorites(); + + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'channel-1', + title: 'Channel One', + type: 'live', + playlist_id: 'm3u-1', + source: 'm3u', + poster_url: 'https://example.com/logo-1.png', + }), + expect.objectContaining({ + id: 'https://example.com/stream-2.m3u8', + title: 'Channel Two', + type: 'live', + playlist_id: 'm3u-1', + source: 'm3u', + poster_url: 'https://example.com/logo-2.png', + }), + ]) + ); + }); + + it('builds the M3U favorites route', async () => { + await service.reloadGlobalFavorites(); + const m3uItem = service + .globalFavoriteItems() + .find((item) => item.source === 'm3u'); + + expect(m3uItem).toBeDefined(); + expect(service.getGlobalFavoriteLink(m3uItem!)).toEqual([ + '/workspace', + 'playlists', + 'm3u-1', + 'favorites', + ]); + }); + + it('removes M3U favorites via PlaylistsService', async () => { + await service.reloadGlobalFavorites(); + const m3uItem = service + .globalFavoriteItems() + .find((item) => item.id === 'channel-1'); + + expect(m3uItem).toBeDefined(); + + await service.removeGlobalFavorite(m3uItem!); + + expect(playlistsServiceMock.setFavorites).toHaveBeenCalledWith( + 'm3u-1', + ['https://example.com/stream-2.m3u8'] + ); + }); + + it('matches M3U provider scope', () => { + const result = service.matchesScope('m3u-1', 'm3u', { + providers: ['m3u'], + playlistIds: [], + }); + + expect(result).toBe(true); + }); + + it('includes M3U recently viewed items in global recent items', () => { + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'https://example.com/stream-1.m3u8', + title: 'Channel One', + type: 'live', + playlist_id: 'm3u-1', + source: 'm3u', + xtream_id: 'https://example.com/stream-1.m3u8', + }), + ]) + ); + }); + + it('builds the M3U recent route with navigation state', () => { + const m3uItem = service + .globalRecentItems() + .find((item) => item.source === 'm3u'); + + expect(m3uItem).toBeDefined(); + expect(service.getRecentItemLink(m3uItem!)).toEqual([ + '/workspace', + 'playlists', + 'm3u-1', + 'recent', + ]); + expect(service.getRecentItemNavigationState(m3uItem!)).toEqual({ + openRecentChannelUrl: 'https://example.com/stream-1.m3u8', + }); + }); + + it('removes M3U recently viewed via PlaylistsService', async () => { + const m3uItem = service + .globalRecentItems() + .find((item) => item.source === 'm3u'); + + expect(m3uItem).toBeDefined(); + + await service.removeGlobalRecentItem(m3uItem!); + + expect( + playlistsServiceMock.removeFromM3uRecentlyViewed + ).toHaveBeenCalledWith( + 'm3u-1', + 'https://example.com/stream-1.m3u8' + ); + }); }); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 837242416..2407d0701 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -7,9 +7,12 @@ import { selectActivePlaylist, selectAllPlaylistsMeta, } from 'm3u-state'; -import { startWith } from 'rxjs'; -import { DatabaseService } from 'services'; +import { firstValueFrom, startWith } from 'rxjs'; +import { DatabaseService, PlaylistsService } from 'services'; import { + buildPlaylistRecentItems, + Channel, + Playlist, PlaylistMeta, PortalActivityType, PortalFavoriteItem, @@ -18,7 +21,6 @@ import { } from 'shared-interfaces'; import { buildStalkerFavoriteItems, - buildStalkerRecentItems, getActivityTypeLabelKey, mapDbFavoriteToItem, mapDbRecentToItem, @@ -46,6 +48,7 @@ export type DashboardFavoriteItem = PortalFavoriteItem; export class DashboardDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); + private readonly playlistsService = inject(PlaylistsService); private readonly ngZone = inject(NgZone); private readonly translate = inject(TranslateService); private readonly languageTick = toSignal( @@ -57,19 +60,27 @@ export class DashboardDataService { private readonly xtreamGlobalFavorites = signal( [] ); + private readonly m3uGlobalFavorites = signal([]); readonly playlists = this.store.selectSignal(selectAllPlaylistsMeta); readonly activePlaylist = this.store.selectSignal(selectActivePlaylist); - readonly stalkerGlobalRecentItems = computed(() => { + readonly playlistBackedGlobalRecentItems = computed( + () => { this.languageTick(); - return buildStalkerRecentItems( - this.playlists(), - this.translateText('WORKSPACE.DASHBOARD.STALKER_PORTAL') - ); - }); + return buildPlaylistRecentItems(this.playlists(), { + stalker: this.translateText( + 'WORKSPACE.DASHBOARD.STALKER_PORTAL' + ), + m3u: this.translateText('WORKSPACE.DASHBOARD.M3U'), + }); + } + ); readonly globalRecentItems = computed(() => - [...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()] + [ + ...this.xtreamGlobalRecentItems(), + ...this.playlistBackedGlobalRecentItems(), + ] .sort( (a, b) => toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at) @@ -86,15 +97,19 @@ export class DashboardDataService { }); readonly globalFavoriteItems = computed(() => - [...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()] + [ + ...this.xtreamGlobalFavorites(), + ...this.m3uGlobalFavorites(), + ...this.stalkerGlobalFavorites(), + ] .sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at)) .slice(0, 200) ); constructor() { + void this.reloadGlobalFavorites(); if (window.electron) { void this.reloadGlobalRecentItems(); - void this.reloadGlobalFavorites(); } } @@ -142,6 +157,13 @@ export class DashboardDataService { } async reloadGlobalFavorites(): Promise { + const xtreamReload = this.reloadXtreamGlobalFavorites(); + const m3uReload = this.reloadM3uGlobalFavorites(); + + await Promise.all([xtreamReload, m3uReload]); + } + + private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { this.xtreamGlobalFavorites.set([]); return; @@ -162,6 +184,38 @@ export class DashboardDataService { } } + private async reloadM3uGlobalFavorites(): Promise { + const m3uPlaylists = this.playlists().filter( + (playlist) => + !playlist.serverUrl && + !playlist.macAddress && + Array.isArray(playlist.favorites) && + playlist.favorites.some( + (favorite): favorite is string => + typeof favorite === 'string' && favorite.trim().length > 0 + ) + ); + + if (m3uPlaylists.length === 0) { + this.ngZone.run(() => this.m3uGlobalFavorites.set([])); + return; + } + + const favoriteItems = await Promise.all( + m3uPlaylists.map((playlist) => + this.loadM3uPlaylistFavorites(playlist).catch( + (): DashboardFavoriteItem[] => [] + ) + ) + ); + const flattenedFavorites = favoriteItems.reduce( + (acc, items) => [...acc, ...items], + [] as DashboardFavoriteItem[] + ); + + this.ngZone.run(() => this.m3uGlobalFavorites.set(flattenedFavorites)); + } + isTypeInKind( type: PortalActivityType, kind: DashboardContentKind @@ -256,6 +310,9 @@ export class DashboardDataService { if (item.source === 'xtream') { return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } + if (item.source === 'm3u') { + return this.translateText('WORKSPACE.DASHBOARD.M3U'); + } return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); } @@ -285,36 +342,37 @@ export class DashboardDataService { } if (item.source === 'stalker') { - const playlist = this.playlists().find( - (p) => p._id === item.playlist_id - ); - if (!playlist) return; - - const currentRecent = Array.isArray( - (playlist as { recentlyViewed?: unknown[] }).recentlyViewed - ) - ? [ - ...((playlist as { recentlyViewed?: unknown[] }) - .recentlyViewed ?? []), - ] - : []; - - const itemMatchStr = String(item.id); - const filteredRecent = currentRecent.filter( - (raw, index) => - !stalkerItemMatchesId( - raw, - itemMatchStr, - playlist._id, - index - ) + const updatedPlaylist = await firstValueFrom( + this.playlistsService.removeFromPortalRecentlyViewed( + item.playlist_id, + item.id + ) ); this.store.dispatch( PlaylistActions.updatePlaylistMeta({ playlist: { _id: item.playlist_id, - recentlyViewed: filteredRecent, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as unknown as PlaylistMeta, + }) as any + ); + return; + } + + if (item.source === 'm3u') { + const updatedPlaylist = await firstValueFrom( + this.playlistsService.removeFromM3uRecentlyViewed( + item.playlist_id, + String(item.xtream_id ?? item.id) + ) + ); + + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: item.playlist_id, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], } as unknown as PlaylistMeta, }) as any ); @@ -330,6 +388,9 @@ export class DashboardDataService { if (item.source === 'xtream') { return this.translateText('WORKSPACE.DASHBOARD.XTREAM'); } + if (item.source === 'm3u') { + return this.translateText('WORKSPACE.DASHBOARD.M3U'); + } return this.translateText('WORKSPACE.DASHBOARD.PROVIDER'); } @@ -388,6 +449,29 @@ export class DashboardDataService { }) as any ); } + + if (item.source === 'm3u') { + const playlist = await firstValueFrom( + this.playlistsService.getPlaylistById(item.playlist_id) + ); + const currentFavorites = Array.isArray(playlist?.favorites) + ? playlist.favorites.filter( + (favorite): favorite is string => + typeof favorite === 'string' + ) + : []; + const filteredFavorites = currentFavorites.filter( + (favorite) => favorite !== String(item.id) + ); + + await firstValueFrom( + this.playlistsService.setFavorites( + item.playlist_id, + filteredFavorites + ) + ); + await this.reloadGlobalFavorites(); + } } formatTimestamp(value?: string | number): string { @@ -422,6 +506,73 @@ export class DashboardDataService { return fallback; } + private async loadM3uPlaylistFavorites( + playlistMeta: PlaylistMeta + ): Promise { + const playlist = (await firstValueFrom( + this.playlistsService.getPlaylistById(playlistMeta._id) + )) as Playlist & { + playlist?: { + items?: Channel[]; + }; + }; + const favorites = Array.isArray(playlist?.favorites) + ? playlist.favorites.filter( + (favorite): favorite is string => + typeof favorite === 'string' && favorite.trim().length > 0 + ) + : []; + + if (favorites.length === 0) { + return []; + } + + const items: Channel[] = Array.isArray(playlist?.playlist?.items) + ? (playlist.playlist.items as Channel[]) + : []; + const fallbackTimestamp = + this.getM3uFavoriteTimestamp(playlistMeta) ?? + new Date(0).toISOString(); + + return items.reduce((acc, channel) => { + const channelId = String(channel.id ?? '').trim(); + const channelUrl = String(channel.url ?? '').trim(); + const matchedFavoriteId = favorites.find( + (favorite) => + favorite === channelId || favorite === channelUrl + ); + + if (!matchedFavoriteId) { + return acc; + } + + acc.push({ + id: matchedFavoriteId, + title: channel.name?.trim() || channel.tvg?.name || channelId, + type: 'live', + playlist_id: playlistMeta._id, + playlist_name: + playlistMeta.title || playlistMeta.filename || 'M3U', + added_at: fallbackTimestamp, + category_id: 'live', + xtream_id: matchedFavoriteId, + poster_url: channel.tvg?.logo || undefined, + source: 'm3u', + }); + return acc; + }, []); + } + + private getM3uFavoriteTimestamp(playlist: PlaylistMeta): string | null { + if (playlist.updateDate) { + return new Date(playlist.updateDate).toISOString(); + } + + return typeof playlist.importDate === 'string' + ? playlist.importDate + : null; + } + private getRecentTimestamp(item: PlaylistMeta): number { return toTimestamp(item.updateDate) || toTimestamp(item.importDate); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts index c57cfa843..d94870714 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-navigation.utils.ts @@ -25,6 +25,15 @@ export function getRecentItemNavigation( }; } + if (item.source === 'm3u') { + return { + link: ['/workspace', 'playlists', item.playlist_id, 'recent'], + state: { + openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''), + }, + }; + } + return buildXtreamNavigationTarget({ playlistId: item.playlist_id, type: item.type, @@ -52,6 +61,12 @@ export function getGlobalFavoriteNavigation( }; } + if (item.source === 'm3u') { + return { + link: ['/workspace', 'playlists', item.playlist_id, 'favorites'], + }; + } + return buildXtreamNavigationTarget({ playlistId: item.playlist_id, type: item.type, diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts index bd26e17d3..7546cd012 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts @@ -36,6 +36,7 @@ import { PlaylistType, } from '@iptvnator/playlist/shared/ui'; import { + PlaylistActions, selectActivePlaylist, selectAllPlaylistsMeta, selectPlaylistTitle, @@ -55,6 +56,7 @@ import { PlaylistsService, SettingsStore, } from 'services'; +import { PlaylistMeta } from 'shared-interfaces'; import { SettingsContextService, WorkspaceAccountInfoData, @@ -481,6 +483,21 @@ export class WorkspaceShellComponent { }; } + if (context.provider === 'playlists' && section === 'recent') { + return { + icon: 'delete_sweep', + tooltip: this.translateText( + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION' + ), + ariaLabel: this.translateText( + 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_SECTION_ARIA' + ), + disabled: + (this.activePlaylist()?.recentlyViewed?.length ?? 0) === + 0, + }; + } + return null; } ); @@ -707,11 +724,37 @@ export class WorkspaceShellComponent { } if (context.provider === 'stalker' && section === 'recent') { - await firstValueFrom( + const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPortalRecentlyViewed( context.playlistId ) ); + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: context.playlistId, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as PlaylistMeta, + }) as any + ); + this.bumpRefreshQueryParam(); + return; + } + + if (context.provider === 'playlists' && section === 'recent') { + const updatedPlaylist = await firstValueFrom( + this.playlistsService.clearM3uRecentlyViewed( + context.playlistId + ) + ); + this.store.dispatch( + PlaylistActions.updatePlaylistMeta({ + playlist: { + _id: context.playlistId, + recentlyViewed: updatedPlaylist?.recentlyViewed ?? [], + } as PlaylistMeta, + }) as any + ); this.bumpRefreshQueryParam(); } }