diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 19f4da724..384fc3ca3 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts index 85152ecf6..8ad93e1da 100644 --- a/apps/electron-backend/src/app/events/database/favorites.events.ts +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -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; + } + } +); diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 0a0055583..7b418c7bd 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -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 ), }, { diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html new file mode 100644 index 000000000..8ba4ff07c --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.html @@ -0,0 +1,47 @@ +
+ + search + + +
+ + diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss new file mode 100644 index 000000000..ed09e3152 --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.scss @@ -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; + } +} + diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts new file mode 100644 index 000000000..23b882c13 --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-list.component.ts @@ -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(); + readonly epgMap = input>(new Map()); + readonly progressTick = input(0); + readonly activeUid = input(null); + + readonly channelSelected = output(); + readonly channelsReordered = output(); + readonly favoriteToggled = output(); + + 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): 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)); + } +} diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.html b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.html new file mode 100644 index 000000000..7c9da4634 --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.html @@ -0,0 +1,117 @@ +
+ @if (isLoading()) { +
+ +
+ } @else { +
+ + + + @if (activePlayback()) { + + @if (isEmbeddedPlayer()) { +
+
+ {{ + activePlayback()?.title + }} + +
+ + @if (player() === 'videojs') { + + } + @if (player() === 'html5') { + + } + @if (player() === 'artplayer') { + + } +
+ } + + +
+ @if (!isEmbeddedPlayer()) { +
+ {{ + activePlayback()?.title + }} + +
+ } +
+ +
+
+ } @else { +
+ live_tv +

Select a channel to start watching

+
+ } +
+ } +
diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.scss b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.scss new file mode 100644 index 000000000..a8d04abdf --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.scss @@ -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; + } +} diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.ts b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.ts new file mode 100644 index 000000000..797a5ffef --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites-page.component.ts @@ -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([]); + readonly activePlayback = signal(null); + readonly activeUid = signal(null); + readonly epgMap = signal>(new Map()); + readonly progressTick = signal(0); + readonly currentEpgItems = signal([]); + 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 | 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 { + 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 { + 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 { + this.channels.set(reordered); + await this.favoritesService.reorder(reordered); + } + + // ─── Private ──────────────────────────────────────────────────────────── + + private async loadFavorites(): Promise { + 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 { + const epgMap = new Map(this.epgMap()); + const now = Date.now(); + + // Group xtream channels by playlistId to batch credential lookups + const xtreamByPlaylist = new Map(); + 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[] = []; + + // 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, + now: number + ): Promise { + 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, + now: number + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/web/src/app/workspace/global-favorites/global-favorites.service.ts b/apps/web/src/app/workspace/global-favorites/global-favorites.service.ts new file mode 100644 index 000000000..89ccf759d --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/global-favorites.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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]; + } +} diff --git a/apps/web/src/app/workspace/global-favorites/unified-favorite-channel.interface.ts b/apps/web/src/app/workspace/global-favorites/unified-favorite-channel.interface.ts new file mode 100644 index 000000000..3f9b888c3 --- /dev/null +++ b/apps/web/src/app/workspace/global-favorites/unified-favorite-channel.interface.ts @@ -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}`; +} diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index be118f301..45c48177c 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -14,10 +14,7 @@ declare global { ) => () => void; getAppVersion: () => Promise; platform: string; - fetchPlaylistByUrl: ( - url: string, - title?: string - ) => Promise; + fetchPlaylistByUrl: (url: string, title?: string) => Promise; updatePlaylistFromFilePath: ( filePath: string, title: string @@ -57,7 +54,11 @@ declare global { autoUpdatePlaylists: (playlists: any[]) => Promise; fetchEpg: ( urls: string[] - ) => Promise<{ success: boolean; message?: string; skipped?: string[] }>; + ) => Promise<{ + success: boolean; + message?: string; + skipped?: string[]; + }>; getChannelPrograms: (channelId: string) => Promise; getEpgChannels: () => Promise; getEpgChannelsByRange: ( @@ -195,6 +196,9 @@ declare global { ) => Promise; dbGetFavorites: (playlistId: string) => Promise; dbGetGlobalFavorites: () => Promise; + dbReorderGlobalFavorites: ( + updates: { content_id: number; position: number }[] + ) => Promise<{ success: boolean }>; // Recently viewed (playlist-specific) dbGetRecentItems: (playlistId: string) => Promise; 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; - dbGetAllPlaybackPositions: ( - playlistId: string - ) => Promise; + dbGetAllPlaybackPositions: (playlistId: string) => Promise; 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; @@ -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; diff --git a/global.d.ts b/global.d.ts index 0874353c2..48e76361b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -202,6 +202,9 @@ declare global { ) => Promise; dbGetFavorites: (playlistId: string) => Promise; dbGetGlobalFavorites: () => Promise; + dbReorderGlobalFavorites: ( + updates: { content_id: number; position: number }[] + ) => Promise<{ success: boolean }>; // Recently viewed (playlist-specific) dbGetRecentItems: (playlistId: string) => Promise; dbAddRecentItem: ( diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index d4d4a93a5..c46deece9 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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`, ]; /** diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 6c4daee79..d99d7bcc9 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -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(