feat(global-favorites): implement global favorites feature with drag-and-drop support

- Added GlobalFavoritesListComponent to manage and display favorite channels.
- Created GlobalFavoritesPageComponent to serve as the main page for global favorites.
- Implemented loading and displaying of EPG data for channels.
- Introduced GlobalFavoritesService to handle fetching, removing, and reordering favorites from various sources (M3U, Xtream, Stalker).
- Enhanced unified favorite channel interface to support new features and data structures.
- Added necessary HTML and SCSS for the global favorites page layout and styling.
- Updated database schema to include position column for favorites to support ordering.
- Added typings for new database operations related to global favorites.
This commit is contained in:
4gray committed 2026-03-08 18:17:04 +01:00
1 parent 9e1ef0b0bd
commit 3b04b5105f
15 files changed
+1462 -16

No files matched your search

@@ -295,6 +295,9 @@ contextBridge.exposeInMainWorld('electron', {
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_RECENT_ITEMS', playlistId),
@@ -3,7 +3,7 @@
* Operations for managing user's favorite content
*/
import { and, desc, eq, sql } from 'drizzle-orm';
import { and, asc, desc, eq, sql } from 'drizzle-orm';
import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
@@ -110,7 +110,7 @@ ipcMain.handle('DB_GET_FAVORITES', async (event, playlistId: string) => {
});
/**
* Get global favorites across all playlists
* Get global favorites across all playlists (live TV channels only)
*/
ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
try {
@@ -128,6 +128,7 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
@@ -142,7 +143,11 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
schema.playlists,
eq(schema.categories.playlistId, schema.playlists.id)
)
.orderBy(desc(schema.favorites.addedAt))
.where(eq(schema.content.type, 'live'))
.orderBy(
asc(schema.favorites.position),
desc(schema.favorites.addedAt)
)
.limit(300);
return result;
} catch (error) {
@@ -150,3 +155,29 @@ ipcMain.handle('DB_GET_GLOBAL_FAVORITES', async () => {
throw error;
}
});
/**
* Reorder global favorites by updating the position field on each Xtream favorite row.
* Accepts an array of { content_id, position } pairs.
*/
ipcMain.handle(
'DB_REORDER_GLOBAL_FAVORITES',
async (event, updates: { content_id: number; position: number }[]) => {
try {
if (!Array.isArray(updates) || updates.length === 0) {
return { success: true };
}
const db = await getDatabase();
for (const { content_id, position } of updates) {
await db
.update(schema.favorites)
.set({ position })
.where(eq(schema.favorites.contentId, content_id));
}
return { success: true };
} catch (error) {
console.error('Error reordering global favorites:', error);
throw error;
}
}
);
+2 -2
View File
@@ -68,8 +68,8 @@ export const routes: Routes = [
layout: 'workspace',
},
loadComponent: () =>
import('./home/video-player/video-player.component').then(
(c) => c.VideoPlayerComponent
import('./workspace/global-favorites/global-favorites-page.component').then(
(c) => c.GlobalFavoritesPageComponent
),
},
{
@@ -0,0 +1,47 @@
<div class="search-panel">
<mat-form-field class="search-bar">
<mat-icon matPrefix>search</mat-icon>
<input
matInput
type="search"
placeholder="Search favorites..."
[value]="searchTerm()"
(input)="searchTerm.set($any($event.target).value)"
/>
</mat-form-field>
</div>
<div class="nav-list" cdkDropList (cdkDropListDropped)="onDrop($event)">
@if (enrichedChannels().length === 0) {
<div class="empty-state">
<mat-icon class="empty-icon">star_border</mat-icon>
@if (searchTerm()) {
<p class="empty-title">No results</p>
<p class="empty-hint">Try a different search term</p>
} @else {
<p class="empty-title">No live TV favorites yet</p>
<p class="empty-hint">
Star live channels from M3U, Xtream, or Stalker playlists to
see them here.
</p>
}
</div>
}
@for (ch of enrichedChannels(); track trackByUid($index, ch)) {
<app-channel-list-item
[isDraggable]="!searchTerm()"
[logo]="ch.logo"
[name]="ch.name"
[selected]="activeUid() === ch.uid"
[showEpg]="true"
[epgProgram]="ch.currentEpgProgram"
[progressPercentage]="ch.progressPercentage"
[showFavoriteButton]="true"
[isFavorite]="true"
[showProgramInfoButton]="true"
(clicked)="onChannelClick(ch)"
(favoriteToggled)="onFavoriteToggled(ch)"
/>
}
</div>
@@ -0,0 +1,81 @@
@use '@angular/material' as mat;
:host {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
// ─── Search ────────────────────────────────────────────────────────────────────
.search-panel {
margin-top: 10px;
display: flex;
justify-content: center;
margin-bottom: -10px;
flex-shrink: 0;
}
.search-bar {
width: 90%;
@include mat.form-field-density(-5);
}
::ng-deep {
.search-bar div {
border-radius: 20px;
}
div[matformfieldlineripple],
.mat-mdc-form-field-subscript-wrapper {
display: none;
}
}
// ─── List ──────────────────────────────────────────────────────────────────────
.nav-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 8px 12px;
display: flex;
flex-direction: column;
gap: 2px;
}
// ─── Empty state ───────────────────────────────────────────────────────────────
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
text-align: center;
color: var(--mat-sys-on-surface-variant);
.empty-icon {
font-size: 48px;
height: 48px;
width: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-title {
font-size: 14px;
font-weight: 500;
margin: 0 0 8px;
}
.empty-hint {
font-size: 12px;
opacity: 0.7;
margin: 0;
max-width: 260px;
}
}
@@ -0,0 +1,104 @@
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 { 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,
],
})
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));
}
}
@@ -0,0 +1,117 @@
<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">Global Favorites</span>
<span class="sidebar-subtitle"
>Live TV from all playlists</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>Select a channel to start watching</p>
</div>
}
</div>
}
</div>
@@ -0,0 +1,186 @@
: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;
}
}
@@ -0,0 +1,465 @@
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 {
ArtPlayerComponent,
HtmlVideoPlayerComponent,
ResizableDirective,
VjsPlayerComponent,
} from 'components';
import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Channel,
EpgItem,
EpgProgram,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamApiService } from '../../xtream-electron/services/xtream-api.service';
import { XtreamUrlService } from '../../xtream-electron/services/xtream-url.service';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
@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,
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 playerService = inject(PlayerService);
readonly player = this.settingsStore.player;
readonly isEmbeddedPlayer = computed(() =>
this.playerService.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);
// For external players (MPV/VLC), trigger the IPC call to launch them
if (!this.playerService.isEmbeddedPlayer()) {
void this.playerService.openResolvedPlayback(playback);
}
// Load EPG items for the view in background
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: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
);
} catch {
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)
);
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)
);
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)
);
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: any;
if (playlist.isFullStalkerPortal) {
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;
}
}
@@ -0,0 +1,321 @@
/**
* GlobalFavoritesService
*
* Aggregates live TV favorites from all three playlist source types:
* - M3U – channels whose URL/id is listed in playlist.favorites[]
* - Xtream – rows in the `favorites` DB table where content.type = 'live'
* - Stalker – items in playlist.favorites[] that have a `cmd` (live channels)
* and do NOT have movie_id / series_id
*
* Also handles reordering: persists position for Xtream rows via IPC.
* For M3U and Stalker the list order is managed in the app_state table
* as a JSON array of UIDs (keyed by GLOBAL_FAVORITES_ORDER_KEY).
*/
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { selectAllPlaylistsMeta } from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import { Channel, PlaylistMeta } from 'shared-interfaces';
import {
buildFavoriteUid,
UnifiedFavoriteChannel,
} from './unified-favorite-channel.interface';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@Injectable({ providedIn: 'root' })
export class GlobalFavoritesService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
/**
* Fetch and merge live TV favorites from all source types.
* The returned list is sorted by persisted position first,
* then by addedAt descending for items without a position.
*/
async getUnifiedLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const [m3uItems, xtreamItems, stalkerItems, savedOrder] =
await Promise.all([
this.getM3uFavorites(),
this.getXtreamLiveFavorites(),
this.getStalkerLiveFavorites(),
this.getSavedOrder(),
]);
const all = [...m3uItems, ...xtreamItems, ...stalkerItems];
return this.applyOrder(all, savedOrder);
}
/**
* Remove a channel from its source playlist's favorites.
* Works for all three source types (M3U, Xtream, Stalker).
*/
async removeFavorite(channel: UnifiedFavoriteChannel): Promise<void> {
switch (channel.sourceType) {
case 'm3u': {
const playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(channel.playlistId)
);
const currentFavs = (playlist.favorites as string[]) ?? [];
const filtered = currentFavs.filter(
(f) => f !== channel.streamUrl
);
await firstValueFrom(
this.playlistsService.setFavorites(
channel.playlistId,
filtered
)
);
break;
}
case 'xtream': {
if (window.electron && channel.contentId != null) {
await window.electron.dbRemoveFavorite(
channel.contentId,
channel.playlistId
);
}
break;
}
case 'stalker': {
const sourceItemId = channel.uid.split('::')[2];
await firstValueFrom(
this.playlistsService.removeFromPortalFavorites(
channel.playlistId,
sourceItemId
)
);
break;
}
}
}
/**
* Persist the new display order.
* For Xtream items update the DB position column.
* For all items, persist the UID order in app_state.
*/
async reorder(channels: UnifiedFavoriteChannel[]): Promise<void> {
const uidOrder = channels.map((ch) => ch.uid);
// Persist xtream positions to DB
const xtreamUpdates = channels
.filter((ch) => ch.sourceType === 'xtream' && ch.contentId != null)
.map((ch, index) => ({
content_id: ch.contentId!,
position: index,
}));
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(uidOrder),
]);
}
// ─── Private helpers ───────────────────────────────────────────
private async getM3uFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const m3uPlaylists = allMeta.filter(
(p) => p._id && !p.serverUrl && !p.macAddress
);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of m3uPlaylists) {
if (!meta.favorites?.length) continue;
const favoriteIds = new Set(
(meta.favorites as string[]).map(String)
);
// Load full playlist to access channel items
let playlist: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
} catch {
continue;
}
const items: Channel[] = playlist?.playlist?.items ?? [];
for (const ch of items) {
if (favoriteIds.has(ch.id) || favoriteIds.has(ch.url)) {
const sourceItemId = ch.url || ch.id;
results.push({
uid: buildFavoriteUid('m3u', meta._id, sourceItemId),
name: ch.name,
logo: ch.tvg?.logo ?? null,
sourceType: 'm3u',
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
streamUrl: ch.url,
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
addedAt: new Date(0).toISOString(),
position: 0,
});
}
}
}
return results;
}
private async getXtreamLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
if (!window.electron) return [];
let items: any[] = [];
try {
items = await this.dbService.getGlobalFavorites();
} catch {
return [];
}
// getGlobalFavorites now only returns type='live'
return items.map((item) => ({
uid: buildFavoriteUid('xtream', item.playlist_id, item.xtream_id),
name: item.title,
logo: item.poster_url ?? null,
sourceType: 'xtream' as const,
playlistId: item.playlist_id,
playlistName: item.playlist_name,
xtreamId: item.xtream_id,
tvgId: String(item.xtream_id),
addedAt: item.added_at ?? new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
}));
}
private async getStalkerLiveFavorites(): Promise<UnifiedFavoriteChannel[]> {
const allMeta = await firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((metas) => metas as PlaylistMeta[]))
);
const stalkerPlaylists = allMeta.filter((p) => p._id && p.macAddress);
const results: UnifiedFavoriteChannel[] = [];
for (const meta of stalkerPlaylists) {
if (!meta.favorites?.length) continue;
// Fetch full playlist to get credentials
let playlist: any;
try {
playlist = await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
);
} catch {
continue;
}
const favorites = Array.isArray(playlist?.favorites)
? playlist.favorites
: [];
for (const fav of favorites) {
// Live channels: have a cmd, no movie_id / series_id
const isLive =
fav.cmd &&
!fav.movie_id &&
!fav.series_id &&
(fav.stream_type === 'live' ||
fav.category_id === 'itv' ||
(!fav.stream_type && !fav.series_id && !fav.movie_id));
if (!isLive) continue;
const streamId = fav.stream_id ?? fav.id;
results.push({
uid: buildFavoriteUid('stalker', meta._id, streamId),
name: fav.o_name || fav.name || 'Unknown',
logo: fav.logo ?? fav.cover ?? null,
sourceType: 'stalker' as const,
playlistId: meta._id,
playlistName:
meta.title || meta.filename || 'Stalker Portal',
stalkerCmd: fav.cmd,
stalkerPortalUrl: playlist.portalUrl ?? playlist.url,
stalkerMacAddress: playlist.macAddress,
addedAt: fav.added_at
? new Date(fav.added_at).toISOString()
: new Date(0).toISOString(),
position: 0,
});
}
}
return results;
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
try {
const raw = await window.electron.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
);
if (!raw) return [];
return JSON.parse(raw) as string[];
} catch {
return [];
}
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
try {
await window.electron.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
JSON.stringify(uidOrder)
);
} catch {
// ignore
}
}
private applyOrder(
channels: UnifiedFavoriteChannel[],
savedOrder: string[]
): UnifiedFavoriteChannel[] {
if (!savedOrder.length) {
// Default: sort by addedAt descending, xtream position asc
return channels.slice().sort((a, b) => {
if (a.position !== b.position) return a.position - b.position;
return (
new Date(b.addedAt).getTime() -
new Date(a.addedAt).getTime()
);
});
}
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
const ordered: UnifiedFavoriteChannel[] = [];
const unordered: UnifiedFavoriteChannel[] = [];
for (const ch of channels) {
if (orderMap.has(ch.uid)) {
ordered.push({ ...ch, position: orderMap.get(ch.uid)! });
} else {
unordered.push(ch);
}
}
ordered.sort((a, b) => a.position - b.position);
unordered.sort(
(a, b) =>
new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime()
);
return [...ordered, ...unordered];
}
}
@@ -0,0 +1,73 @@
/**
* 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}`;
}
+22 -11
View File
@@ -14,10 +14,7 @@ declare global {
) => () => void;
getAppVersion: () => Promise<string>;
platform: string;
fetchPlaylistByUrl: (
url: string,
title?: string
) => Promise<any>;
fetchPlaylistByUrl: (url: string, title?: string) => Promise<any>;
updatePlaylistFromFilePath: (
filePath: string,
title: string
@@ -57,7 +54,11 @@ declare global {
autoUpdatePlaylists: (playlists: any[]) => Promise<any[]>;
fetchEpg: (
urls: string[]
) => Promise<{ success: boolean; message?: string; skipped?: string[] }>;
) => Promise<{
success: boolean;
message?: string;
skipped?: string[];
}>;
getChannelPrograms: (channelId: string) => Promise<any>;
getEpgChannels: () => Promise<any>;
getEpgChannelsByRange: (
@@ -195,6 +196,9 @@ declare global {
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (
@@ -265,7 +269,9 @@ declare global {
}) => void
) => void;
// DB save content progress listener
onDbSaveContentProgress: (callback: (count: number) => void) => void;
onDbSaveContentProgress: (
callback: (count: number) => void
) => void;
removeDbSaveContentProgress: () => void;
dbDeleteAllPlaylists: () => Promise<{ success: boolean }>;
// Playback positions
@@ -296,15 +302,15 @@ declare global {
playlistId: string,
limit?: number
) => Promise<any[]>;
dbGetAllPlaybackPositions: (
playlistId: string
) => Promise<any[]>;
dbGetAllPlaybackPositions: (playlistId: string) => Promise<any[]>;
dbClearPlaybackPosition: (
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
) => Promise<{ success: boolean }>;
onPlaybackPositionUpdate: (callback: (data: any) => void) => () => void;
onPlaybackPositionUpdate: (
callback: (data: any) => void
) => () => void;
closeExternalPlayerSession: (
sessionId: string
) => Promise<ExternalPlayerSession | null>;
@@ -327,7 +333,12 @@ declare global {
episodeNumber?: number;
// Playlist info for auto-creation if needed
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
playlistType?:
| 'xtream'
| 'stalker'
| 'm3u-file'
| 'm3u-text'
| 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
Vendored
+3
View File
@@ -202,6 +202,9 @@ declare global {
) => Promise<boolean>;
dbGetFavorites: (playlistId: string) => Promise<any[]>;
dbGetGlobalFavorites: () => Promise<any[]>;
dbReorderGlobalFavorites: (
updates: { content_id: number; position: number }[]
) => Promise<{ success: boolean }>;
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) => Promise<any[]>;
dbAddRecentItem: (
@@ -232,6 +232,8 @@ const MIGRATION_STATEMENTS = [
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
];
/**
+2
View File
@@ -129,6 +129,8 @@ export const favorites = sqliteTable(
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
/** Display order position in the global favorites list (lower = first) */
position: integer('position').default(0),
},
(table) => ({
contentPlaylistUnique: uniqueIndex(