mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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}`;
|
||||
}
|
||||
Vendored
+22
-11
@@ -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
@@ -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`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user