feat(groups-view): add search functionality to filter grouped channels

feat(recent-view): implement search term filtering for recent items
fix(dashboard-data): update return type for navigation state methods
chore(jest): add jest configuration for workspace shell feature
refactor(global-favorites): migrate global favorites list to shared UI
refactor(global-favorites): remove global favorites page component and its styles
refactor(global-favorites): update unified favorite channel interface to use shared util
test(workspace-shell): setup test environment for workspace shell feature
Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-25 19:16:03 +01:00
1 parent aa5e4097f1
commit 8874e4a17e
80 files changed
+2396 -4316

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
export * from './lib/global-favorites/global-favorites-page.component';
export * from './lib/global-favorites/global-favorites-list.component';
export * from './lib/global-favorites/unified-favorite-channel.interface';
export * from './lib/workspace-shell/workspace-shell.component';
export * from './lib/workspace-sources/workspace-sources.component';
@@ -1,106 +1,4 @@
import {
CdkDragDrop,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
output,
signal,
} from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgProgram } from 'shared-interfaces';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
export interface EnrichedUnifiedFavorite extends UnifiedFavoriteChannel {
currentEpgProgram: EpgProgram | null;
progressPercentage: number;
}
@Component({
selector: 'app-global-favorites-list',
templateUrl: './global-favorites-list.component.html',
styleUrl: './global-favorites-list.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ChannelListItemComponent,
DragDropModule,
MatFormFieldModule,
MatIconModule,
MatInputModule,
TranslatePipe,
],
})
export class GlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input<number>(0);
readonly activeUid = input<string | null>(null);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
readonly searchTerm = signal('');
readonly enrichedChannels = computed((): EnrichedUnifiedFavorite[] => {
const channels = this.channels();
const epgMap = this.epgMap();
const term = this.searchTerm().toLowerCase();
// Read progressTick to trigger periodic recalculation
this.progressTick();
const filtered = term
? channels.filter((ch) => ch.name.toLowerCase().includes(term))
: channels;
return filtered.map((ch) => {
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
const currentEpgProgram = epgKey
? (epgMap.get(epgKey) ?? null)
: null;
return {
...ch,
currentEpgProgram,
progressPercentage: this.calcProgress(currentEpgProgram),
};
});
});
onChannelClick(channel: UnifiedFavoriteChannel): void {
this.channelSelected.emit(channel);
}
onFavoriteToggled(channel: UnifiedFavoriteChannel): void {
this.favoriteToggled.emit(channel);
}
onDrop(event: CdkDragDrop<EnrichedUnifiedFavorite[]>): void {
// Disable reorder while searching to avoid index confusion
if (this.searchTerm()) return;
const list = [...this.channels()];
moveItemInArray(list, event.previousIndex, event.currentIndex);
this.channelsReordered.emit(list);
}
trackByUid(_: number, ch: EnrichedUnifiedFavorite): string {
return ch.uid;
}
private calcProgress(program: EpgProgram | null | undefined): number {
if (!program) return 0;
const now = Date.now();
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
const total = stop - start;
const elapsed = now - start;
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
}
export {
EnrichedUnifiedFavorite,
GlobalFavoritesListComponent,
} from '@iptvnator/portal/shared/ui';
@@ -1,124 +0,0 @@
<div class="global-favorites-page">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="40" />
</div>
} @else {
<div
[class]="
activePlayback()
? isEmbeddedPlayer()
? 'itv-container'
: 'itv-container--external'
: 'itv-container-no-player'
"
>
<!-- Sidebar: resizable channel list -->
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="360"
storageKey="global-favorites-sidebar-width"
>
<div class="sidebar-header">
<span class="sidebar-title">{{
'WORKSPACE.GLOBAL_FAVORITES.TITLE' | translate
}}</span>
<span class="sidebar-subtitle">{{
'WORKSPACE.GLOBAL_FAVORITES.SUBTITLE' | translate
}}</span>
</div>
<app-global-favorites-list
[channels]="channels()"
[epgMap]="epgMap()"
[progressTick]="progressTick()"
[activeUid]="activeUid()"
(channelSelected)="onChannelSelected($event)"
(channelsReordered)="onReorder($event)"
(favoriteToggled)="onFavoriteToggled($event)"
/>
</div>
@if (activePlayback()) {
<!-- Video player (embedded players only) -->
@if (isEmbeddedPlayer()) {
<div class="video-player">
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
@if (player() === 'videojs') {
<app-vjs-player
[options]="{
sources: [
{
src: currentStreamUrl(),
type: 'application/x-mpegURL',
},
],
}"
[volume]="1"
/>
}
@if (player() === 'html5') {
<app-html-video-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
@if (player() === 'artplayer') {
<app-art-player
[channel]="activeChannelForOverlay()!"
[volume]="1"
[showCaptions]="false"
/>
}
</div>
}
<!-- EPG panel: spans full height for external players -->
<div class="epg">
@if (!isEmbeddedPlayer()) {
<div class="player-toolbar">
<span class="player-title">{{
activePlayback()?.title
}}</span>
<button
mat-icon-button
(click)="onClose()"
class="close-btn"
>
<mat-icon>close</mat-icon>
</button>
</div>
}
<div class="epg-content">
<app-epg-view [epgItems]="currentEpgItems()" />
</div>
</div>
} @else {
<div class="no-channel-selected">
<mat-icon>live_tv</mat-icon>
<p>
{{
'WORKSPACE.GLOBAL_FAVORITES.EMPTY_PLAYER'
| translate
}}
</p>
</div>
}
</div>
}
</div>
@@ -1,186 +0,0 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.global-favorites-page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
flex: 1;
}
// ─── Grid layouts ──────────────────────────────────────────────────────────────
// With embedded player: sidebar | video-player
// sidebar | epg
.itv-container {
display: grid;
grid-template-columns: auto 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
'sidebar video-player video-player'
'sidebar epg epg';
height: 100%;
}
// With external player (MPV/VLC): sidebar | epg full height
.itv-container--external {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar epg';
height: 100%;
.epg {
border-top: none;
}
}
// Without player: full-width sidebar + center prompt
.itv-container-no-player {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-areas: 'sidebar main';
height: 100%;
}
// ─── Sidebar ───────────────────────────────────────────────────────────────────
.sidebar {
grid-area: sidebar;
width: 360px;
min-width: 250px;
max-width: 600px;
flex-shrink: 0;
position: relative;
border-right: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 14px 16px 6px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 2px;
border-bottom: 1px solid var(--mat-sys-outline-variant);
}
.sidebar-title {
font-size: 15px;
font-weight: 600;
color: var(--mat-sys-on-surface);
line-height: 1.3;
}
.sidebar-subtitle {
font-size: 11px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.8;
}
// ─── Video player ──────────────────────────────────────────────────────────────
.video-player {
grid-area: video-player;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.player-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 16px;
flex-shrink: 0;
background: var(--mat-sys-surface-container-low);
border-bottom: 1px solid var(--mat-sys-outline-variant);
min-height: 40px;
}
.player-title {
flex: 1;
min-width: 0;
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mat-sys-on-surface);
}
.close-btn {
flex-shrink: 0;
}
.video-player > app-vjs-player,
.video-player > app-html-video-player,
.video-player > app-art-player {
flex: 1;
min-height: 0;
display: block;
}
// ─── EPG panel ─────────────────────────────────────────────────────────────────
.epg {
grid-area: epg;
min-height: 0;
overflow: hidden;
border-top: 1px solid var(--mat-sys-outline-variant);
display: flex;
flex-direction: column;
}
.epg-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.epg-content > app-epg-view {
flex: 1;
min-height: 0;
}
// ─── No channel selected ───────────────────────────────────────────────────────
.no-channel-selected {
grid-area: main;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--mat-sys-on-surface-variant);
opacity: 0.6;
font-size: 14px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
}
p {
margin: 0;
}
}
@@ -1,494 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
OnInit,
signal,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { ResizableDirective } from 'components';
import { firstValueFrom } from 'rxjs';
import {
DataService,
PlaylistsService,
SettingsStore,
} from 'services';
import {
Channel,
EpgItem,
EpgProgram,
Playlist,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
interface StalkerCreateLinkResponse {
readonly js?: {
readonly cmd?: string;
};
}
@Component({
selector: 'app-global-favorites-page',
templateUrl: './global-favorites-page.component.html',
styleUrl: './global-favorites-page.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ArtPlayerComponent,
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ResizableDirective,
TranslatePipe,
VjsPlayerComponent,
],
})
export class GlobalFavoritesPageComponent implements OnInit {
private readonly favoritesService = inject(GlobalFavoritesService);
private readonly playlistsService = inject(PlaylistsService);
private readonly xtreamApi = inject(XtreamApiService);
private readonly xtreamUrl = inject(XtreamUrlService);
private readonly dataService = inject(DataService);
private readonly stalkerSession = inject(StalkerSessionService);
private readonly destroyRef = inject(DestroyRef);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
readonly player = this.settingsStore.player;
readonly isEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
);
readonly isLoading = signal(true);
readonly channels = signal<UnifiedFavoriteChannel[]>([]);
readonly activePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly activeUid = signal<string | null>(null);
readonly epgMap = signal<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = signal(0);
readonly currentEpgItems = signal<EpgItem[]>([]);
readonly currentStreamUrl = computed(
() => this.activePlayback()?.streamUrl ?? ''
);
/** Active channel as minimal Channel for InfoOverlayComponent */
/** Minimal Channel object needed by html5/artplayer components */
readonly activeChannelForOverlay = computed((): Channel | undefined => {
const p = this.activePlayback();
if (!p) return undefined;
return {
id: this.activeUid() ?? '',
name: p.title ?? '',
url: this.currentStreamUrl(),
tvg: {
logo: p.thumbnail ?? '',
id: '',
name: '',
rec: '',
url: '',
},
group: { title: '' },
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
} as Channel;
});
private tickInterval: ReturnType<typeof setInterval> | null = null;
ngOnInit(): void {
this.loadFavorites();
this.tickInterval = setInterval(() => {
this.progressTick.update((t) => t + 1);
}, 30_000);
this.destroyRef.onDestroy(() => {
if (this.tickInterval) {
clearInterval(this.tickInterval);
}
});
}
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
if (this.activeUid() === channel.uid) {
// Deselect
this.activeUid.set(null);
this.activePlayback.set(null);
this.currentEpgItems.set([]);
return;
}
this.activeUid.set(channel.uid);
this.currentEpgItems.set([]);
try {
const playback = await this.resolvePlayback(channel);
this.activePlayback.set(playback);
if (!this.portalPlayer.isEmbeddedPlayer()) {
void this.portalPlayer.openResolvedPlayback(playback);
}
this.loadEpgItemsForChannel(channel).then((items) => {
this.currentEpgItems.set(items);
});
} catch {
this.activePlayback.set(null);
this.activeUid.set(null);
}
}
onClose(): void {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
async onFavoriteToggled(channel: UnifiedFavoriteChannel): Promise<void> {
await this.favoritesService.removeFavorite(channel);
this.channels.update((chs) => chs.filter((c) => c.uid !== channel.uid));
if (this.activeUid() === channel.uid) {
this.activePlayback.set(null);
this.activeUid.set(null);
this.currentEpgItems.set([]);
}
}
async onReorder(reordered: UnifiedFavoriteChannel[]): Promise<void> {
this.channels.set(reordered);
await this.favoritesService.reorder(reordered);
}
// ─── Private ────────────────────────────────────────────────────────────
private async loadFavorites(): Promise<void> {
this.isLoading.set(true);
try {
const channels =
await this.favoritesService.getUnifiedLiveFavorites();
this.channels.set(channels);
// Load EPG in background without blocking
this.loadEpgForChannels(channels);
} catch {
this.channels.set([]);
} finally {
this.isLoading.set(false);
}
}
private async loadEpgForChannels(
channels: UnifiedFavoriteChannel[]
): Promise<void> {
const epgMap = new Map<string, EpgProgram | null>(this.epgMap());
const now = Date.now();
// Group xtream channels by playlistId to batch credential lookups
const xtreamByPlaylist = new Map<string, UnifiedFavoriteChannel[]>();
for (const ch of channels) {
if (ch.sourceType === 'xtream') {
const list = xtreamByPlaylist.get(ch.playlistId) ?? [];
list.push(ch);
xtreamByPlaylist.set(ch.playlistId, list);
}
}
const tasks: Promise<void>[] = [];
// M3U EPG
for (const ch of channels) {
if (ch.sourceType !== 'm3u' || !ch.tvgId) continue;
tasks.push(this.loadM3uEpg(ch, epgMap, now));
}
// Xtream EPG – one credential lookup per playlist
for (const [playlistId, chList] of xtreamByPlaylist.entries()) {
tasks.push(
this.loadXtreamEpgForPlaylist(playlistId, chList, epgMap, now)
);
}
await Promise.all(tasks.map((t) => t.catch(() => null)));
this.epgMap.set(new Map(epgMap));
}
private async loadM3uEpg(
ch: UnifiedFavoriteChannel,
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
if (!window.electron?.getChannelPrograms || !ch.tvgId) return;
try {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
const current =
programs.find(
(p) =>
new Date(p.start).getTime() <= now &&
now < new Date(p.stop).getTime()
) ?? null;
epgMap.set(ch.tvgId.trim(), current);
} catch {
epgMap.set(ch.tvgId.trim(), null);
}
}
private async loadXtreamEpgForPlaylist(
playlistId: string,
channels: UnifiedFavoriteChannel[],
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
let playlist: Playlist | undefined;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
} catch {
return;
}
if (!playlist?.serverUrl || !playlist.username || !playlist.password) {
return;
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
await Promise.all(
channels
.map(async (ch) => {
if (!ch.xtreamId) return;
try {
const items: EpgItem[] =
await this.xtreamApi.getShortEpg(
credentials,
ch.xtreamId,
2
);
const nowSec = Math.floor(now / 1000);
const current =
items.find(
(p) =>
Number(p.start_timestamp) <= nowSec &&
nowSec < Number(p.stop_timestamp)
) ?? null;
const epgKey = ch.tvgId?.trim() || ch.name?.trim();
if (epgKey) {
epgMap.set(
epgKey,
current
? {
start: new Date(
Number(current.start_timestamp) *
1000
).toISOString(),
stop: new Date(
Number(current.stop_timestamp) *
1000
).toISOString(),
channel: String(ch.xtreamId),
title: current.title,
desc: current.description ?? null,
category: null,
}
: null
);
}
} catch {
// skip
}
})
.map((t) => t.catch(() => null))
);
}
private async resolvePlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
switch (ch.sourceType) {
case 'm3u':
return this.resolveM3uPlayback(ch);
case 'xtream':
return this.resolveXtreamPlayback(ch);
case 'stalker':
return this.resolveStalkerPlayback(ch);
}
}
private async loadEpgItemsForChannel(
ch: UnifiedFavoriteChannel
): Promise<EpgItem[]> {
try {
if (
ch.sourceType === 'm3u' &&
ch.tvgId &&
window.electron?.getChannelPrograms
) {
const programs: EpgProgram[] =
await window.electron.getChannelPrograms(ch.tvgId);
return programs.map((p, i) => ({
id: String(i),
epg_id: p.channel,
title: p.title,
lang: 'en',
start: p.start,
end: p.stop,
stop: p.stop,
description: p.desc ?? '',
channel_id: p.channel,
start_timestamp: String(
Math.floor(new Date(p.start).getTime() / 1000)
),
stop_timestamp: String(
Math.floor(new Date(p.stop).getTime() / 1000)
),
}));
}
if (ch.sourceType === 'xtream' && ch.xtreamId) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
if (
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
return [];
}
return await this.xtreamApi.getShortEpg(
{
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
},
ch.xtreamId,
10
);
}
} catch {
// ignore
}
return [];
}
private resolveM3uPlayback(
ch: UnifiedFavoriteChannel
): ResolvedPortalPlayback {
return {
streamUrl: ch.streamUrl ?? '',
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveXtreamPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
if (
ch.xtreamId == null ||
!playlist?.serverUrl ||
!playlist.username ||
!playlist.password
) {
throw new Error('Missing Xtream playback credentials');
}
const credentials = {
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
};
const streamUrl = this.xtreamUrl.constructLiveUrl(credentials, ch.xtreamId);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private async resolveStalkerPlayback(
ch: UnifiedFavoriteChannel
): Promise<ResolvedPortalPlayback> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(ch.playlistId)
)) as Playlist | undefined;
const portalUrl =
ch.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
const macAddress = ch.stalkerMacAddress ?? playlist?.macAddress ?? '';
const cmd = ch.stalkerCmd ?? '';
const params = {
action: StalkerPortalActions.CreateLink,
cmd,
type: 'itv' as const,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
};
let response: StalkerCreateLinkResponse | undefined;
if (playlist?.isFullStalkerPortal && playlist) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: portalUrl,
macAddress,
params,
});
}
const rawCmd = response?.js?.cmd ?? '';
const streamUrl = this.normalizeStalkerCmd(rawCmd);
return {
streamUrl,
title: ch.name,
thumbnail: ch.logo ?? null,
};
}
private normalizeStalkerCmd(value: string): string {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://')
) {
return candidate;
}
}
return trimmed;
}
}
@@ -26,7 +26,7 @@ import {
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
} from './unified-favorite-channel.interface';
} from '@iptvnator/portal/shared/util';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -1,73 +1,5 @@
/**
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
*/
export type FavoriteSourceType = 'm3u' | 'xtream' | 'stalker';
export interface UnifiedFavoriteChannel {
/**
* Unique identifier within the global-favorites list.
* Format: `{sourceType}::{playlistId}::{sourceItemId}`
*/
uid: string;
/** Display name of the channel */
name: string;
/** Logo / thumbnail URL */
logo: string | null;
/** Source type – determines how to resolve the stream URL */
sourceType: FavoriteSourceType;
/** ID of the parent playlist */
playlistId: string;
/** Human-readable playlist name */
playlistName: string;
/** Direct stream URL (M3U channels only) */
streamUrl?: string;
/** Xtream numeric stream ID (Xtream channels only) */
xtreamId?: number;
/**
* TVG id for M3U EPG lookup.
* For Xtream channels, also used for short-EPG lookup.
*/
tvgId?: string;
/**
* The stalker cmd value needed to resolve the stream URL.
* Stored for Stalker favorites that are live channels.
*/
stalkerCmd?: string;
/** ISO timestamp when added to favorites */
addedAt: string;
/**
* Display order within the unified list.
* Lower values appear first. 0 = unset (sort by addedAt desc).
*/
position: number;
/** Xtream DB content id — used for REORDER IPC call */
contentId?: number;
/** Stalker portal credentials needed to resolve the live stream */
stalkerPortalUrl?: string;
stalkerMacAddress?: string;
}
/**
* Build a stable UID string for a unified favorite channel.
*/
export function buildFavoriteUid(
sourceType: FavoriteSourceType,
playlistId: string,
sourceItemId: string | number
): string {
return `${sourceType}::${playlistId}::${sourceItemId}`;
}
export {
buildFavoriteUid,
FavoriteSourceType,
UnifiedFavoriteChannel,
} from '@iptvnator/portal/shared/util';
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});