mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
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:
1 parent
aa5e4097f1
commit
8874e4a17e
80 files changed
+2396
-4316
No files matched your search
@@ -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';
|
||||
+4
-106
@@ -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';
|
||||
-124
@@ -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>
|
||||
-186
@@ -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;
|
||||
}
|
||||
}
|
||||
-494
@@ -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';
|
||||
|
||||
|
||||
+5
-73
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user