mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
feat(groups-view): add search functionality to filter grouped channels
feat(recent-view): implement search term filtering for recent items fix(dashboard-data): update return type for navigation state methods chore(jest): add jest configuration for workspace shell feature refactor(global-favorites): migrate global favorites list to shared UI refactor(global-favorites): remove global favorites page component and its styles refactor(global-favorites): update unified favorite channel interface to use shared util test(workspace-shell): setup test environment for workspace shell feature Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
1 parent
aa5e4097f1
commit
8874e4a17e
80 files changed
+2396
-4316
No files matched your search
@@ -1,4 +1,3 @@
|
||||
export * from './lib/favorites-context.service';
|
||||
export * from './lib/logger';
|
||||
export * from './lib/portal-catalog-detail';
|
||||
export * from './lib/portal-catalog-facade';
|
||||
@@ -7,8 +6,11 @@ export * from './lib/portal-player';
|
||||
export * from './lib/portal-playback-positions';
|
||||
export * from './lib/portal-navigation-actions';
|
||||
export * from './lib/portal-collection-context';
|
||||
export * from './lib/portal-collection-context.service';
|
||||
export * from './lib/portal-collection-items';
|
||||
export * from './lib/remote-channel-navigation';
|
||||
export * from './lib/workspace-header-context.service';
|
||||
export * from './lib/navigation/portal-rail-links';
|
||||
export * from './lib/navigation/portal-route.utils';
|
||||
export * from './lib/navigation/workspace-portal-navigation';
|
||||
export * from './lib/collection';
|
||||
@@ -0,0 +1,36 @@
|
||||
import { StalkerPortalItem } from 'shared-interfaces';
|
||||
import { CollectionContentType } from './unified-collection-item.interface';
|
||||
|
||||
export interface XtreamFavoriteRow {
|
||||
readonly id: number;
|
||||
readonly category_id: number;
|
||||
readonly playlist_id: string;
|
||||
readonly playlist_name: string;
|
||||
readonly xtream_id: number;
|
||||
readonly title: string;
|
||||
readonly type: string;
|
||||
readonly poster_url?: string | null;
|
||||
readonly rating?: string | null;
|
||||
readonly added_at?: string | null;
|
||||
readonly position?: number | null;
|
||||
}
|
||||
|
||||
export function isStalkerItem(
|
||||
v: string | StalkerPortalItem
|
||||
): v is StalkerPortalItem {
|
||||
return typeof v !== 'string';
|
||||
}
|
||||
|
||||
export function stalkerContentType(
|
||||
fav: StalkerPortalItem
|
||||
): CollectionContentType {
|
||||
if (fav.movie_id) return 'movie';
|
||||
if (fav.series_id) return 'series';
|
||||
return 'live';
|
||||
}
|
||||
|
||||
export function xtreamContentType(type: string): CollectionContentType {
|
||||
if (type === 'movie' || type === 'movies') return 'movie';
|
||||
if (type === 'series') return 'series';
|
||||
return 'live';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './unified-collection-item.interface';
|
||||
export * from './unified-favorite-channel.interface';
|
||||
export * from './scope-toggle.service';
|
||||
export * from './unified-favorites-data.service';
|
||||
export * from './unified-recent-data.service';
|
||||
export * from './stream-resolver.service';
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type CollectionScope = 'playlist' | 'all';
|
||||
|
||||
const STORAGE_PREFIX = 'collection-scope-';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScopeToggleService {
|
||||
private readonly scopes = new Map<string, ReturnType<typeof signal<CollectionScope>>>();
|
||||
|
||||
getScope(viewKey: string): ReturnType<typeof signal<CollectionScope>> {
|
||||
if (!this.scopes.has(viewKey)) {
|
||||
const persisted = this.readFromStorage(viewKey);
|
||||
this.scopes.set(viewKey, signal<CollectionScope>(persisted));
|
||||
}
|
||||
return this.scopes.get(viewKey)!;
|
||||
}
|
||||
|
||||
setScope(viewKey: string, value: CollectionScope): void {
|
||||
const s = this.getScope(viewKey);
|
||||
s.set(value);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_PREFIX + viewKey, value);
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private readFromStorage(viewKey: string): CollectionScope {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_PREFIX + viewKey);
|
||||
if (stored === 'all' || stored === 'playlist') return stored;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 'playlist';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { DataService, PlaylistsService } from 'services';
|
||||
import {
|
||||
EpgItem,
|
||||
EpgProgram,
|
||||
Playlist,
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
XtreamApiService,
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
|
||||
import { UnifiedCollectionItem } from './unified-collection-item.interface';
|
||||
|
||||
interface StalkerCreateLinkResponse {
|
||||
readonly js?: { readonly cmd?: string };
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StreamResolverService {
|
||||
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);
|
||||
|
||||
async resolvePlayback(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
switch (item.sourceType) {
|
||||
case 'm3u':
|
||||
return this.resolveM3u(item);
|
||||
case 'xtream':
|
||||
return this.resolveXtream(item);
|
||||
case 'stalker':
|
||||
return this.resolveStalker(item);
|
||||
}
|
||||
}
|
||||
|
||||
async loadEpgItems(item: UnifiedCollectionItem): Promise<EpgItem[]> {
|
||||
try {
|
||||
if (item.sourceType === 'm3u' && item.tvgId && window.electron?.getChannelPrograms) {
|
||||
const programs: EpgProgram[] =
|
||||
await window.electron.getChannelPrograms(item.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 (item.sourceType === 'xtream' && item.xtreamId) {
|
||||
const creds = await this.getXtreamCredentials(item.playlistId);
|
||||
if (!creds) return [];
|
||||
return await this.xtreamApi.getShortEpg(creds, item.xtreamId, 10);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async loadEpgForItems(
|
||||
items: UnifiedCollectionItem[]
|
||||
): Promise<Map<string, EpgProgram | null>> {
|
||||
const epgMap = new Map<string, EpgProgram | null>();
|
||||
const now = Date.now();
|
||||
|
||||
const xtreamByPlaylist = new Map<string, UnifiedCollectionItem[]>();
|
||||
for (const ch of items) {
|
||||
if (ch.sourceType === 'xtream' && ch.contentType === 'live') {
|
||||
const list = xtreamByPlaylist.get(ch.playlistId) ?? [];
|
||||
list.push(ch);
|
||||
xtreamByPlaylist.set(ch.playlistId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const tasks: Promise<void>[] = [];
|
||||
for (const ch of items) {
|
||||
if (ch.sourceType === 'm3u' && ch.tvgId && ch.contentType === 'live') {
|
||||
tasks.push(this.loadM3uEpg(ch, epgMap, now));
|
||||
}
|
||||
}
|
||||
for (const [playlistId, chList] of xtreamByPlaylist.entries()) {
|
||||
tasks.push(this.loadXtreamEpgBatch(playlistId, chList, epgMap, now));
|
||||
}
|
||||
|
||||
await Promise.all(tasks.map((t) => t.catch(() => null)));
|
||||
return epgMap;
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────
|
||||
|
||||
private resolveM3u(item: UnifiedCollectionItem): ResolvedPortalPlayback {
|
||||
return {
|
||||
streamUrl: item.streamUrl ?? '',
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveXtream(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
const creds = await this.getXtreamCredentials(item.playlistId);
|
||||
if (!creds || item.xtreamId == null) {
|
||||
throw new Error('Missing Xtream credentials');
|
||||
}
|
||||
const streamUrl = this.xtreamUrl.constructLiveUrl(creds, item.xtreamId);
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveStalker(
|
||||
item: UnifiedCollectionItem
|
||||
): Promise<ResolvedPortalPlayback> {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
)) as Playlist | undefined;
|
||||
|
||||
const portalUrl = item.stalkerPortalUrl ?? playlist?.portalUrl ?? playlist?.url ?? '';
|
||||
const macAddress = item.stalkerMacAddress ?? playlist?.macAddress ?? '';
|
||||
|
||||
const params = {
|
||||
action: StalkerPortalActions.CreateLink,
|
||||
cmd: item.stalkerCmd ?? '',
|
||||
type: 'itv' as const,
|
||||
disable_ad: '0',
|
||||
download: '0',
|
||||
JsHttpRequest: '1-xml',
|
||||
};
|
||||
|
||||
let response: StalkerCreateLinkResponse | undefined;
|
||||
if (playlist?.isFullStalkerPortal && playlist) {
|
||||
response = await this.stalkerSession.makeAuthenticatedRequest(playlist, params);
|
||||
} else {
|
||||
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: portalUrl,
|
||||
macAddress,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
const rawCmd = response?.js?.cmd ?? '';
|
||||
const streamUrl = this.normalizeStalkerCmd(rawCmd);
|
||||
return {
|
||||
streamUrl,
|
||||
title: item.name,
|
||||
thumbnail: item.logo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async getXtreamCredentials(playlistId: string) {
|
||||
const playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(playlistId)
|
||||
)) as Playlist | undefined;
|
||||
if (!playlist?.serverUrl || !playlist.username || !playlist.password) return null;
|
||||
return {
|
||||
serverUrl: playlist.serverUrl,
|
||||
username: playlist.username,
|
||||
password: playlist.password,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadM3uEpg(
|
||||
ch: UnifiedCollectionItem,
|
||||
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 loadXtreamEpgBatch(
|
||||
playlistId: string,
|
||||
channels: UnifiedCollectionItem[],
|
||||
epgMap: Map<string, EpgProgram | null>,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
const creds = await this.getXtreamCredentials(playlistId);
|
||||
if (!creds) return;
|
||||
|
||||
await Promise.all(
|
||||
channels.map(async (ch) => {
|
||||
if (!ch.xtreamId) return;
|
||||
try {
|
||||
const items = await this.xtreamApi.getShortEpg(creds, 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
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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,81 @@
|
||||
/**
|
||||
* Unified interface for displaying favorites or recently-viewed items
|
||||
* from any source (M3U, Xtream, Stalker) in one list.
|
||||
*
|
||||
* Extends the previous live-only UnifiedFavoriteChannel to cover
|
||||
* all content types: live TV, movies, and series.
|
||||
*/
|
||||
export type CollectionSourceType = 'm3u' | 'xtream' | 'stalker';
|
||||
export type CollectionContentType = 'live' | 'movie' | 'series';
|
||||
|
||||
export interface UnifiedCollectionItem {
|
||||
/** Unique identifier: `{sourceType}::{playlistId}::{sourceItemId}` */
|
||||
uid: string;
|
||||
|
||||
/** Display name */
|
||||
name: string;
|
||||
|
||||
/** Content type — determines which tab / layout to use */
|
||||
contentType: CollectionContentType;
|
||||
|
||||
/** Source type — determines how to resolve playback */
|
||||
sourceType: CollectionSourceType;
|
||||
|
||||
/** Parent playlist ID */
|
||||
playlistId: string;
|
||||
|
||||
/** Human-readable playlist name */
|
||||
playlistName: string;
|
||||
|
||||
/** Logo / thumbnail URL (live channels) */
|
||||
logo?: string | null;
|
||||
|
||||
/** Poster image URL (movies / series) */
|
||||
posterUrl?: string | null;
|
||||
|
||||
/** Direct stream URL (M3U channels only) */
|
||||
streamUrl?: string;
|
||||
|
||||
/** TVG id for EPG lookup (M3U / Xtream live) */
|
||||
tvgId?: string;
|
||||
|
||||
/** Xtream numeric stream ID */
|
||||
xtreamId?: number;
|
||||
|
||||
/** Xtream DB content id — used for reorder / remove IPC */
|
||||
contentId?: number;
|
||||
|
||||
/** Stalker cmd for stream resolution */
|
||||
stalkerCmd?: string;
|
||||
stalkerPortalUrl?: string;
|
||||
stalkerMacAddress?: string;
|
||||
|
||||
/** Category ID for VOD/Series navigation */
|
||||
categoryId?: string | number;
|
||||
|
||||
/** Content rating (VOD) */
|
||||
rating?: string;
|
||||
|
||||
/** ISO timestamp when added to favorites */
|
||||
addedAt?: string;
|
||||
|
||||
/** Display order (favorites) */
|
||||
position?: number;
|
||||
|
||||
/** ISO timestamp when viewed (recently-viewed) */
|
||||
viewedAt?: string;
|
||||
|
||||
/** Original stalker item for detail navigation */
|
||||
stalkerItem?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable UID string for a unified collection item.
|
||||
*/
|
||||
export function buildCollectionUid(
|
||||
sourceType: CollectionSourceType,
|
||||
playlistId: string,
|
||||
sourceItemId: string | number
|
||||
): string {
|
||||
return `${sourceType}::${playlistId}::${sourceItemId}`;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { selectAllPlaylistsMeta } from 'm3u-state';
|
||||
import { firstValueFrom, map } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import { Channel, Playlist, PlaylistMeta } from 'shared-interfaces';
|
||||
import {
|
||||
buildCollectionUid,
|
||||
UnifiedCollectionItem,
|
||||
} from './unified-collection-item.interface';
|
||||
import { CollectionScope } from './scope-toggle.service';
|
||||
import {
|
||||
isStalkerItem,
|
||||
stalkerContentType,
|
||||
xtreamContentType,
|
||||
XtreamFavoriteRow,
|
||||
} from './collection-helpers';
|
||||
|
||||
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
|
||||
|
||||
type PlaylistWithChannels = Playlist & {
|
||||
readonly playlist?: { readonly items?: Channel[] };
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UnifiedFavoritesDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
async getFavorites(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
return this.getPlaylistFavorites(playlistId, portalType);
|
||||
}
|
||||
return this.getAllFavorites();
|
||||
}
|
||||
|
||||
async removeFavorite(item: UnifiedCollectionItem): Promise<void> {
|
||||
switch (item.sourceType) {
|
||||
case 'm3u': {
|
||||
const playlist = await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(item.playlistId)
|
||||
);
|
||||
const filtered = ((playlist.favorites as string[]) ?? [])
|
||||
.filter((f) => f !== item.streamUrl);
|
||||
await firstValueFrom(
|
||||
this.playlistsService.setFavorites(item.playlistId, filtered)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'xtream':
|
||||
if (window.electron && item.contentId != null) {
|
||||
await window.electron.dbRemoveFavorite(item.contentId, item.playlistId);
|
||||
}
|
||||
break;
|
||||
case 'stalker': {
|
||||
const sourceItemId = item.uid.split('::')[2];
|
||||
await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalFavorites(item.playlistId, sourceItemId)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reorder(items: UnifiedCollectionItem[]): Promise<void> {
|
||||
const uidOrder = items.map((i) => i.uid);
|
||||
const xtreamUpdates = items
|
||||
.filter((i) => i.sourceType === 'xtream' && i.contentId != null)
|
||||
.map((i, idx) => ({ content_id: i.contentId!, position: idx }));
|
||||
await Promise.all([
|
||||
xtreamUpdates.length > 0 && window.electron
|
||||
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
|
||||
: Promise.resolve(),
|
||||
this.saveOrder(uidOrder),
|
||||
]);
|
||||
}
|
||||
|
||||
private async getAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const [m3u, xtream, stalker, order] = await Promise.all([
|
||||
this.getM3uFavorites(),
|
||||
this.getXtreamAllFavorites(),
|
||||
this.getStalkerAllFavorites(),
|
||||
this.getSavedOrder(),
|
||||
]);
|
||||
return this.applyOrder([...m3u, ...xtream, ...stalker], order);
|
||||
}
|
||||
|
||||
private async getPlaylistFavorites(
|
||||
playlistId: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (portalType === 'xtream') return this.getXtreamPlaylistFavorites(playlistId);
|
||||
if (portalType === 'stalker') return this.getStalkerPlaylistFavorites(playlistId);
|
||||
return this.getM3uPlaylistFavorites(playlistId);
|
||||
}
|
||||
|
||||
private async getM3uFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
for (const meta of allMeta.filter((p) => p._id && !p.serverUrl && !p.macAddress)) {
|
||||
results.push(...(await this.extractM3uFavorites(meta)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getM3uPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(id);
|
||||
return meta ? this.extractM3uFavorites(meta) : [];
|
||||
}
|
||||
|
||||
private async extractM3uFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
|
||||
if (!meta.favorites?.length) return [];
|
||||
const favIds = new Set((meta.favorites as string[]).map(String));
|
||||
let playlist: PlaylistWithChannels | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as PlaylistWithChannels | undefined;
|
||||
} catch { return []; }
|
||||
return (playlist?.playlist?.items ?? [])
|
||||
.filter((ch) => favIds.has(ch.id) || favIds.has(ch.url))
|
||||
.map((ch) => ({
|
||||
uid: buildCollectionUid('m3u', meta._id, ch.url || ch.id),
|
||||
name: ch.name,
|
||||
contentType: 'live' as const,
|
||||
sourceType: 'm3u' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || 'M3U',
|
||||
logo: ch.tvg?.logo ?? null,
|
||||
streamUrl: ch.url,
|
||||
tvgId: ch.tvg?.id || ch.tvg?.name || ch.name,
|
||||
addedAt: new Date(0).toISOString(),
|
||||
position: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron?.dbGetAllGlobalFavorites) return [];
|
||||
try {
|
||||
const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
|
||||
return rows.map((r) => this.mapXtreamRow(r));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private async getXtreamPlaylistFavorites(playlistId: string): Promise<UnifiedCollectionItem[]> {
|
||||
if (!window.electron) return [];
|
||||
try {
|
||||
const rows = await this.dbService.getFavorites(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({
|
||||
...this.mapXtreamRow(r),
|
||||
playlistId,
|
||||
playlistName: meta?.title || 'Xtream',
|
||||
}));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
|
||||
const ct = xtreamContentType(row.type);
|
||||
return {
|
||||
uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
|
||||
name: row.title,
|
||||
contentType: ct,
|
||||
sourceType: 'xtream',
|
||||
playlistId: row.playlist_id,
|
||||
playlistName: row.playlist_name ?? 'Xtream',
|
||||
logo: ct === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
|
||||
rating: row.rating ?? undefined,
|
||||
addedAt: row.added_at ?? new Date(0).toISOString(),
|
||||
position: row.position ?? 0,
|
||||
contentId: row.id,
|
||||
};
|
||||
}
|
||||
|
||||
private async getStalkerAllFavorites(): Promise<UnifiedCollectionItem[]> {
|
||||
const allMeta = await this.getAllMeta();
|
||||
const results: UnifiedCollectionItem[] = [];
|
||||
for (const meta of allMeta.filter((p) => p._id && p.macAddress)) {
|
||||
results.push(...(await this.extractStalkerFavorites(meta)));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async getStalkerPlaylistFavorites(id: string): Promise<UnifiedCollectionItem[]> {
|
||||
const meta = await this.getPlaylistMeta(id);
|
||||
return meta ? this.extractStalkerFavorites(meta) : [];
|
||||
}
|
||||
|
||||
private async extractStalkerFavorites(meta: PlaylistMeta): Promise<UnifiedCollectionItem[]> {
|
||||
if (!meta.favorites?.length) return [];
|
||||
let playlist: Playlist | undefined;
|
||||
try {
|
||||
playlist = (await firstValueFrom(
|
||||
this.playlistsService.getPlaylistById(meta._id)
|
||||
)) as Playlist | undefined;
|
||||
} catch { return []; }
|
||||
const favs = Array.isArray(playlist?.favorites)
|
||||
? playlist.favorites.filter(isStalkerItem) : [];
|
||||
return favs.map((fav) => {
|
||||
const ct = stalkerContentType(fav);
|
||||
return {
|
||||
uid: buildCollectionUid('stalker', meta._id, fav.stream_id ?? fav.id),
|
||||
name: fav.o_name || fav.name || this.translate.instant('WORKSPACE.GLOBAL_FAVORITES.UNKNOWN_CHANNEL'),
|
||||
contentType: ct,
|
||||
sourceType: 'stalker' as const,
|
||||
playlistId: meta._id,
|
||||
playlistName: meta.title || meta.filename || this.translate.instant('WORKSPACE.DASHBOARD.STALKER_PORTAL'),
|
||||
logo: ct === 'live' ? (fav.logo ?? fav.cover ?? null) : null,
|
||||
posterUrl: ct !== 'live' ? (fav.logo ?? fav.cover ?? null) : null,
|
||||
stalkerCmd: fav.cmd,
|
||||
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
|
||||
stalkerMacAddress: playlist?.macAddress,
|
||||
categoryId: fav.category_id,
|
||||
stalkerItem: fav,
|
||||
addedAt: fav.added_at ? new Date(fav.added_at).toISOString() : new Date(0).toISOString(),
|
||||
position: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async getAllMeta(): Promise<PlaylistMeta[]> {
|
||||
return firstValueFrom(
|
||||
this.store.select(selectAllPlaylistsMeta).pipe(map((m) => m as PlaylistMeta[]))
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistMeta(id: string): Promise<PlaylistMeta | undefined> {
|
||||
return (await this.getAllMeta()).find((p) => p._id === id);
|
||||
}
|
||||
|
||||
private async getSavedOrder(): Promise<string[]> {
|
||||
if (!window.electron?.dbGetAppState) return [];
|
||||
try {
|
||||
const raw = await window.electron.dbGetAppState(GLOBAL_FAVORITES_ORDER_KEY);
|
||||
return raw ? (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(items: UnifiedCollectionItem[], savedOrder: string[]): UnifiedCollectionItem[] {
|
||||
if (!savedOrder.length) {
|
||||
return items.slice().sort((a, b) => {
|
||||
const pa = a.position ?? 0, pb = b.position ?? 0;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime();
|
||||
});
|
||||
}
|
||||
const orderMap = new Map(savedOrder.map((uid, i) => [uid, i]));
|
||||
const ordered: UnifiedCollectionItem[] = [];
|
||||
const unordered: UnifiedCollectionItem[] = [];
|
||||
for (const item of items) {
|
||||
const pos = orderMap.get(item.uid);
|
||||
pos != null ? ordered.push({ ...item, position: pos }) : unordered.push(item);
|
||||
}
|
||||
ordered.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
unordered.sort((a, b) => new Date(b.addedAt ?? 0).getTime() - new Date(a.addedAt ?? 0).getTime());
|
||||
return [...ordered, ...unordered];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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 {
|
||||
buildPlaylistRecentItems,
|
||||
Playlist,
|
||||
PlaylistMeta,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildCollectionUid,
|
||||
UnifiedCollectionItem,
|
||||
} from './unified-collection-item.interface';
|
||||
import { CollectionScope } from './scope-toggle.service';
|
||||
import { xtreamContentType } from './collection-helpers';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UnifiedRecentDataService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly dbService = inject(DatabaseService);
|
||||
private readonly playlistsService = inject(PlaylistsService);
|
||||
|
||||
async getRecentItems(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
return this.getPlaylistRecentItems(playlistId, portalType);
|
||||
}
|
||||
return this.getAllRecentItems();
|
||||
}
|
||||
|
||||
async removeRecentItem(item: UnifiedCollectionItem): Promise<void> {
|
||||
if (item.sourceType === 'xtream' && item.contentId != null) {
|
||||
await this.dbService.removeRecentItem(
|
||||
item.contentId,
|
||||
item.playlistId
|
||||
);
|
||||
} else if (item.sourceType === 'stalker' || item.sourceType === 'm3u') {
|
||||
const sourceItemId = item.uid.split('::')[2];
|
||||
await firstValueFrom(
|
||||
this.playlistsService.removeFromPortalRecentlyViewed(
|
||||
item.playlistId,
|
||||
sourceItemId
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async clearRecentItems(
|
||||
scope: CollectionScope,
|
||||
playlistId?: string
|
||||
): Promise<void> {
|
||||
if (scope === 'playlist' && playlistId) {
|
||||
await this.dbService.clearPlaylistRecentItems(playlistId);
|
||||
await firstValueFrom(
|
||||
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
|
||||
);
|
||||
} else {
|
||||
await this.dbService.clearGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
)) as Playlist[];
|
||||
await Promise.all(
|
||||
playlists
|
||||
.filter((p) => Boolean(p.macAddress) || !p.serverUrl)
|
||||
.map((p) =>
|
||||
firstValueFrom(
|
||||
this.playlistsService.clearPlaylistRecentlyViewed(p._id)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private ──────────────────────────────────────
|
||||
|
||||
private async getAllRecentItems(): Promise<UnifiedCollectionItem[]> {
|
||||
const [xtream, playlistBased] = await Promise.all([
|
||||
this.getXtreamGlobalRecent(),
|
||||
this.getPlaylistBasedRecent(),
|
||||
]);
|
||||
return [...xtream, ...playlistBased].sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewedAt ?? 0).getTime() -
|
||||
new Date(a.viewedAt ?? 0).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistRecentItems(
|
||||
playlistId: string,
|
||||
portalType?: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
if (portalType === 'xtream') {
|
||||
return this.getXtreamPlaylistRecent(playlistId);
|
||||
}
|
||||
if (portalType === 'stalker') {
|
||||
return this.getStalkerPlaylistRecent(playlistId);
|
||||
}
|
||||
return this.getM3uPlaylistRecent(playlistId);
|
||||
}
|
||||
|
||||
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.dbService.getGlobalRecentlyViewed();
|
||||
return (rows || []).map((row) => ({
|
||||
uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
|
||||
name: row.title,
|
||||
contentType: xtreamContentType(row.type),
|
||||
sourceType: 'xtream' as const,
|
||||
playlistId: row.playlist_id,
|
||||
playlistName: row.playlist_name ?? 'Xtream',
|
||||
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
contentId: row.id,
|
||||
viewedAt: row.viewed_at,
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getXtreamPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
try {
|
||||
const rows = await this.dbService.getRecentItems(playlistId);
|
||||
const meta = await this.getPlaylistMeta(playlistId);
|
||||
return (rows || []).map((row) => ({
|
||||
uid: buildCollectionUid('xtream', playlistId, row.xtream_id),
|
||||
name: row.title,
|
||||
contentType: xtreamContentType(row.type),
|
||||
sourceType: 'xtream' as const,
|
||||
playlistId,
|
||||
playlistName: meta?.title || 'Xtream',
|
||||
logo: row.type === 'live' ? (row.poster_url ?? null) : null,
|
||||
posterUrl: row.type !== 'live' ? (row.poster_url ?? null) : null,
|
||||
xtreamId: row.xtream_id,
|
||||
categoryId: row.category_id,
|
||||
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
|
||||
contentId: row.id,
|
||||
viewedAt: row.viewed_at ?? '',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getPlaylistBasedRecent(): Promise<UnifiedCollectionItem[]> {
|
||||
const playlists = (await firstValueFrom(
|
||||
this.playlistsService.getAllPlaylists()
|
||||
)) as Playlist[];
|
||||
const items = buildPlaylistRecentItems(playlists, {
|
||||
stalker: 'Stalker Portal',
|
||||
m3u: 'M3U',
|
||||
});
|
||||
return items.map((item) => ({
|
||||
uid: buildCollectionUid(
|
||||
(item.source as 'm3u' | 'stalker') ?? 'm3u',
|
||||
item.playlist_id,
|
||||
item.id
|
||||
),
|
||||
name: item.title,
|
||||
contentType: xtreamContentType(item.type),
|
||||
sourceType: (item.source as 'm3u' | 'stalker') ?? 'm3u',
|
||||
playlistId: item.playlist_id,
|
||||
playlistName: item.playlist_name ?? '',
|
||||
logo: item.type === 'live' ? (item.poster_url ?? null) : null,
|
||||
posterUrl: item.type !== 'live' ? (item.poster_url ?? null) : null,
|
||||
stalkerItem: item.stalker_item,
|
||||
viewedAt: item.viewed_at,
|
||||
}));
|
||||
}
|
||||
|
||||
private async getStalkerPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const all = await this.getPlaylistBasedRecent();
|
||||
return all.filter(
|
||||
(i) => i.sourceType === 'stalker' && i.playlistId === playlistId
|
||||
);
|
||||
}
|
||||
|
||||
private async getM3uPlaylistRecent(
|
||||
playlistId: string
|
||||
): Promise<UnifiedCollectionItem[]> {
|
||||
const all = await this.getPlaylistBasedRecent();
|
||||
return all.filter(
|
||||
(i) => i.sourceType === 'm3u' && i.playlistId === playlistId
|
||||
);
|
||||
}
|
||||
|
||||
private async getPlaylistMeta(
|
||||
id: string
|
||||
): Promise<PlaylistMeta | undefined> {
|
||||
const all = await firstValueFrom(
|
||||
this.store.select(selectAllPlaylistsMeta)
|
||||
.pipe(map((m) => m as PlaylistMeta[]))
|
||||
);
|
||||
return all.find((p) => p._id === id);
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function buildPortalRailLinks(
|
||||
section: 'recent',
|
||||
},
|
||||
{
|
||||
icon: 'star',
|
||||
icon: 'favorite',
|
||||
tooltip: 'Favorites (this playlist)',
|
||||
path: [...root, 'favorites'],
|
||||
exact: true,
|
||||
|
||||
@@ -7,9 +7,11 @@ import { TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import {
|
||||
extractPortalPlaylistId,
|
||||
extractPortalSection,
|
||||
isWorkspaceLayoutRoute,
|
||||
queryParamSignal,
|
||||
resolveCurrentPortalPlaylistId,
|
||||
resolveCurrentPortalSection,
|
||||
} from './portal-route.utils';
|
||||
|
||||
@@ -26,6 +28,36 @@ describe('portal-route.utils', () => {
|
||||
expect(isWorkspaceLayoutRoute(route)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects workspace layout from ancestor route data', () => {
|
||||
const parentRoute = {
|
||||
snapshot: {
|
||||
data: {
|
||||
layout: 'workspace',
|
||||
},
|
||||
},
|
||||
} as ActivatedRoute;
|
||||
const childRoute = {
|
||||
snapshot: {
|
||||
data: {},
|
||||
},
|
||||
pathFromRoot: [
|
||||
{
|
||||
snapshot: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
parentRoute,
|
||||
{
|
||||
snapshot: {
|
||||
data: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
expect(isWorkspaceLayoutRoute(childRoute)).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts Xtream sections from workspace and local URLs', () => {
|
||||
expect(extractPortalSection('/xtreams/123/search', 'xtreams')).toBe(
|
||||
'search'
|
||||
@@ -44,21 +76,47 @@ describe('portal-route.utils', () => {
|
||||
).toBe('favorites');
|
||||
});
|
||||
|
||||
it('prefers the child snapshot section and falls back to URL parsing', () => {
|
||||
const routeWithChild = {
|
||||
it('extracts playlist ids from workspace and local URLs', () => {
|
||||
expect(
|
||||
extractPortalPlaylistId('/workspace/xtreams/123/search', 'xtreams')
|
||||
).toBe('123');
|
||||
expect(extractPortalPlaylistId('/stalker/321/series', 'stalker')).toBe(
|
||||
'321'
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves portal sections from the active descendant chain and falls back to URL parsing', () => {
|
||||
const routeWithDescendants = {
|
||||
snapshot: {
|
||||
url: [],
|
||||
},
|
||||
firstChild: {
|
||||
snapshot: {
|
||||
url: [{ path: 'vod' }],
|
||||
url: [{ path: 'workspace' }],
|
||||
},
|
||||
firstChild: {
|
||||
snapshot: {
|
||||
url: [{ path: 'stalker' }],
|
||||
},
|
||||
firstChild: {
|
||||
snapshot: {
|
||||
url: [{ path: 'vod' }],
|
||||
},
|
||||
firstChild: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
const routeWithoutChild = {
|
||||
snapshot: {
|
||||
url: [],
|
||||
},
|
||||
firstChild: null,
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
expect(
|
||||
resolveCurrentPortalSection(
|
||||
routeWithChild,
|
||||
routeWithDescendants,
|
||||
'/workspace/stalker/123/search',
|
||||
'stalker'
|
||||
)
|
||||
@@ -72,6 +130,56 @@ describe('portal-route.utils', () => {
|
||||
).toBe('search');
|
||||
});
|
||||
|
||||
it('resolves playlist ids from ancestors and falls back to URL parsing', () => {
|
||||
const routeWithParent = {
|
||||
pathFromRoot: [
|
||||
{
|
||||
snapshot: {
|
||||
params: {},
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
{
|
||||
snapshot: {
|
||||
params: { id: 'playlist-1' },
|
||||
paramMap: convertToParamMap({ id: 'playlist-1' }),
|
||||
},
|
||||
},
|
||||
{
|
||||
snapshot: {
|
||||
params: {},
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ActivatedRoute;
|
||||
const routeWithoutParent = {
|
||||
pathFromRoot: [
|
||||
{
|
||||
snapshot: {
|
||||
params: {},
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
expect(
|
||||
resolveCurrentPortalPlaylistId(
|
||||
routeWithParent,
|
||||
'/workspace/xtreams/ignored/vod',
|
||||
'xtreams'
|
||||
)
|
||||
).toBe('playlist-1');
|
||||
expect(
|
||||
resolveCurrentPortalPlaylistId(
|
||||
routeWithoutParent,
|
||||
'/workspace/stalker/playlist-2/vod',
|
||||
'stalker'
|
||||
)
|
||||
).toBe('playlist-2');
|
||||
});
|
||||
|
||||
it('creates normalized query-param signals', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
|
||||
|
||||
@@ -9,8 +9,30 @@ import {
|
||||
|
||||
type QueryParamNormalizer<T> = (value: string | null) => T;
|
||||
|
||||
const PORTAL_SECTIONS = new Set<PortalRailSection>([
|
||||
'all',
|
||||
'downloads',
|
||||
'favorites',
|
||||
'groups',
|
||||
'itv',
|
||||
'library',
|
||||
'live',
|
||||
'recent',
|
||||
'recently-added',
|
||||
'search',
|
||||
'series',
|
||||
'vod',
|
||||
]);
|
||||
|
||||
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
|
||||
return route.snapshot.data['layout'] === 'workspace';
|
||||
const routeChain =
|
||||
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
|
||||
? route.pathFromRoot
|
||||
: [route];
|
||||
|
||||
return routeChain.some(
|
||||
(currentRoute) => currentRoute.snapshot.data['layout'] === 'workspace'
|
||||
);
|
||||
}
|
||||
|
||||
export function queryParamSignal<T = string>(
|
||||
@@ -42,16 +64,54 @@ export function extractPortalSection(
|
||||
return (match?.[1] as PortalRailSection | undefined) ?? null;
|
||||
}
|
||||
|
||||
export function extractPortalPlaylistId(
|
||||
url: string,
|
||||
provider: PortalProvider
|
||||
): string | null {
|
||||
const match = url.match(new RegExp(`^/(?:workspace/)?${provider}/([^/?]+)`));
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function resolveCurrentPortalPlaylistId(
|
||||
route: ActivatedRoute,
|
||||
routerUrl: string,
|
||||
provider: PortalProvider
|
||||
): string | null {
|
||||
const routeChain =
|
||||
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
|
||||
? route.pathFromRoot
|
||||
: [route];
|
||||
|
||||
for (let index = routeChain.length - 1; index >= 0; index -= 1) {
|
||||
const currentRoute = routeChain[index];
|
||||
const playlistId =
|
||||
currentRoute.snapshot.paramMap.get('id') ??
|
||||
currentRoute.snapshot.params['id'];
|
||||
if (playlistId) {
|
||||
return playlistId;
|
||||
}
|
||||
}
|
||||
|
||||
return extractPortalPlaylistId(routerUrl, provider);
|
||||
}
|
||||
|
||||
export function resolveCurrentPortalSection(
|
||||
route: ActivatedRoute,
|
||||
routerUrl: string,
|
||||
provider: PortalProvider
|
||||
): PortalRailSection | null {
|
||||
const sectionFromSnapshot =
|
||||
route.firstChild?.snapshot?.url?.[0]?.path ?? null;
|
||||
let currentRoute: ActivatedRoute | null = route;
|
||||
while (currentRoute) {
|
||||
const currentSegment = currentRoute.snapshot.url?.[0]?.path;
|
||||
if (
|
||||
currentSegment &&
|
||||
PORTAL_SECTIONS.has(currentSegment as PortalRailSection)
|
||||
) {
|
||||
return currentSegment as PortalRailSection;
|
||||
}
|
||||
|
||||
return (
|
||||
(sectionFromSnapshot as PortalRailSection | null) ??
|
||||
extractPortalSection(routerUrl, provider)
|
||||
);
|
||||
currentRoute = currentRoute.firstChild;
|
||||
}
|
||||
|
||||
return extractPortalSection(routerUrl, provider);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
buildXtreamItemLink,
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
getUnifiedCollectionNavigation,
|
||||
} from './workspace-portal-navigation';
|
||||
|
||||
describe('workspace-portal-navigation', () => {
|
||||
it('builds canonical Xtream live links without category segments', () => {
|
||||
expect(
|
||||
buildXtreamItemLink({
|
||||
playlistId: 'xtream-1',
|
||||
type: 'live',
|
||||
categoryId: 42,
|
||||
itemId: 99,
|
||||
})
|
||||
).toEqual(['/workspace', 'xtreams', 'xtream-1', 'live']);
|
||||
});
|
||||
|
||||
it('builds Xtream detail links with category and item ids', () => {
|
||||
expect(
|
||||
buildXtreamItemLink({
|
||||
playlistId: 'xtream-1',
|
||||
type: 'movie',
|
||||
categoryId: 42,
|
||||
itemId: 99,
|
||||
})
|
||||
).toEqual([
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
'xtream-1',
|
||||
'vod',
|
||||
'42',
|
||||
'99',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps M3U favorites on the playlist favorites route', () => {
|
||||
expect(
|
||||
getGlobalFavoriteNavigation({
|
||||
id: 'channel-1',
|
||||
title: 'Channel One',
|
||||
type: 'live',
|
||||
playlist_id: 'm3u-1',
|
||||
category_id: 'live',
|
||||
xtream_id: 'channel-1',
|
||||
source: 'm3u',
|
||||
poster_url: 'https://example.com/logo.png',
|
||||
added_at: '2026-03-01T00:00:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
|
||||
});
|
||||
});
|
||||
|
||||
it('builds recent-item state for Stalker recent entries', () => {
|
||||
expect(
|
||||
getRecentItemNavigation({
|
||||
id: 'stalker-1',
|
||||
title: 'Movie One',
|
||||
type: 'movie',
|
||||
playlist_id: 'stalker-1',
|
||||
category_id: 'vod',
|
||||
xtream_id: 'stalker-1',
|
||||
source: 'stalker',
|
||||
poster_url: 'https://example.com/poster.png',
|
||||
viewed_at: '2026-03-01T00:00:00.000Z',
|
||||
})
|
||||
).toEqual({
|
||||
link: ['/workspace', 'stalker', 'stalker-1', 'recent'],
|
||||
state: {
|
||||
openRecentItem: expect.objectContaining({
|
||||
category_id: 'vod',
|
||||
id: 'stalker-1',
|
||||
title: 'Movie One',
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('builds unified Xtream grid navigation from category-aware items', () => {
|
||||
expect(
|
||||
getUnifiedCollectionNavigation({
|
||||
uid: 'xtream::xtream-1::99',
|
||||
name: 'Movie One',
|
||||
contentType: 'movie',
|
||||
sourceType: 'xtream',
|
||||
playlistId: 'xtream-1',
|
||||
playlistName: 'Xtream Playlist',
|
||||
xtreamId: 99,
|
||||
categoryId: 42,
|
||||
})
|
||||
).toEqual({
|
||||
link: ['/workspace', 'xtreams', 'xtream-1', 'vod', '42', '99'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import { UnifiedCollectionItem } from '../collection/unified-collection-item.interface';
|
||||
|
||||
export interface WorkspaceNavigationTarget {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function getRecentItemNavigation(
|
||||
item: PortalRecentItem
|
||||
): WorkspaceNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
|
||||
state: {
|
||||
openRecentItem: buildStalkerStateItem(item.stalker_item, item),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
return {
|
||||
link: ['/workspace', 'playlists', item.playlist_id, 'recent'],
|
||||
state: {
|
||||
openRecentChannelUrl: String(item.xtream_id ?? item.id ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
});
|
||||
}
|
||||
|
||||
export function getGlobalFavoriteNavigation(
|
||||
item: PortalFavoriteItem
|
||||
): WorkspaceNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
|
||||
state: {
|
||||
openFavoriteItem: buildStalkerStateItem(
|
||||
item.stalker_item,
|
||||
item
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (item.source === 'm3u') {
|
||||
return {
|
||||
link: ['/workspace', 'playlists', item.playlist_id, 'favorites'],
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildXtreamNavigationTarget(params: {
|
||||
playlistId: string;
|
||||
type: PortalActivityType;
|
||||
categoryId?: string | number | null;
|
||||
itemId?: string | number | null;
|
||||
title?: string;
|
||||
imageUrl?: string | null;
|
||||
}): WorkspaceNavigationTarget {
|
||||
const link = buildXtreamItemLink(params);
|
||||
const routeType = toXtreamRouteType(params.type);
|
||||
|
||||
if (routeType !== 'live') {
|
||||
return { link };
|
||||
}
|
||||
|
||||
const streamId = Number(toPathSegment(params.itemId));
|
||||
if (!Number.isFinite(streamId) || streamId <= 0) {
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
link,
|
||||
state: {
|
||||
openXtreamLiveItemId: streamId,
|
||||
openXtreamLiveTitle: params.title || '',
|
||||
openXtreamLivePoster: params.imageUrl || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXtreamItemLink(params: {
|
||||
playlistId: string;
|
||||
type: PortalActivityType;
|
||||
categoryId?: string | number | null;
|
||||
itemId?: string | number | null;
|
||||
}): string[] {
|
||||
const routeType = toXtreamRouteType(params.type);
|
||||
const categoryId = toPathSegment(params.categoryId);
|
||||
const itemId = toPathSegment(params.itemId);
|
||||
|
||||
if (routeType === 'live') {
|
||||
return ['/workspace', 'xtreams', params.playlistId, 'live'];
|
||||
}
|
||||
|
||||
if (categoryId && itemId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
itemId,
|
||||
];
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
];
|
||||
}
|
||||
|
||||
return ['/workspace', 'xtreams', params.playlistId, routeType];
|
||||
}
|
||||
|
||||
export function getUnifiedCollectionNavigation(
|
||||
item: UnifiedCollectionItem
|
||||
): WorkspaceNavigationTarget | null {
|
||||
if (item.sourceType === 'xtream') {
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlistId,
|
||||
type: item.contentType,
|
||||
categoryId: item.categoryId,
|
||||
itemId: item.xtreamId,
|
||||
title: item.name,
|
||||
imageUrl: item.posterUrl ?? item.logo ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.sourceType === 'stalker') {
|
||||
const section = item.contentType === 'movie' ? 'vod' : item.contentType;
|
||||
const categoryId = toPathSegment(item.categoryId);
|
||||
const link = categoryId
|
||||
? ['/workspace', 'stalker', item.playlistId, section, categoryId]
|
||||
: ['/workspace', 'stalker', item.playlistId, section];
|
||||
|
||||
return { link };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildStalkerStateItem(
|
||||
rawItem: StalkerPortalItem | undefined,
|
||||
fallback: {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: PortalActivityType;
|
||||
poster_url?: string;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
const normalizedCategory = toStalkerCategoryId(
|
||||
rawItem?.category_id ?? fallback.type
|
||||
);
|
||||
if (rawItem) {
|
||||
return {
|
||||
...(rawItem as Record<string, unknown>),
|
||||
category_id: normalizedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
const title = fallback.title || '';
|
||||
return {
|
||||
id: String(fallback.id ?? ''),
|
||||
title,
|
||||
name: title,
|
||||
o_name: title,
|
||||
category_id: normalizedCategory,
|
||||
cover: fallback.poster_url || '',
|
||||
logo: fallback.poster_url || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toXtreamRouteType(
|
||||
type: PortalActivityType
|
||||
): 'live' | 'vod' | 'series' {
|
||||
return type === 'movie' ? 'vod' : type;
|
||||
}
|
||||
|
||||
export function toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
if (normalized === 'series') return 'series';
|
||||
if (normalized === 'itv' || normalized === 'live') return 'itv';
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
function toPathSegment(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
+3
-3
@@ -2,15 +2,15 @@ import { Injectable, signal } from '@angular/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
|
||||
/**
|
||||
* Lightweight service that bridges the favorites/recently-viewed routed
|
||||
* Lightweight service that bridges collection-backed routed
|
||||
* components (inside the router-outlet) with the WorkspaceShellComponent
|
||||
* (the outer shell that renders the context panel aside).
|
||||
*
|
||||
* The routed component pushes its category list here via an effect;
|
||||
* The active routed component pushes its category list here via an effect;
|
||||
* the workspace shell reads it to populate the context panel.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FavoritesContextService {
|
||||
export class PortalCollectionContextService {
|
||||
/** Category list managed by the active routed component (Favorites / RecentlyViewed). */
|
||||
readonly categories = signal<XtreamCategory[]>([]);
|
||||
|
||||
@@ -5,18 +5,18 @@ import {
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { FavoritesContextService } from './favorites-context.service';
|
||||
import { createPortalCollectionContext } from './portal-collection-context';
|
||||
import { PortalCollectionContextService } from './portal-collection-context.service';
|
||||
|
||||
describe('portal-collection-context', () => {
|
||||
it('syncs categories into the favorites context', () => {
|
||||
it('syncs categories into the shared collection context', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
providers: [PortalCollectionContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const ctx = TestBed.inject(PortalCollectionContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
@@ -45,12 +45,12 @@ describe('portal-collection-context', () => {
|
||||
|
||||
it('does not mutate the shared context when disabled', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
providers: [PortalCollectionContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const ctx = TestBed.inject(PortalCollectionContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
@@ -94,12 +94,12 @@ describe('portal-collection-context', () => {
|
||||
|
||||
it('resets managed context state on destroy', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [FavoritesContextService],
|
||||
providers: [PortalCollectionContextService],
|
||||
});
|
||||
|
||||
const parentInjector = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parentInjector);
|
||||
const ctx = TestBed.inject(FavoritesContextService);
|
||||
const ctx = TestBed.inject(PortalCollectionContextService);
|
||||
const categories = signal([
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DestroyRef, Signal, effect, inject } from '@angular/core';
|
||||
import { XtreamCategory } from 'shared-interfaces';
|
||||
import { FavoritesContextService } from './favorites-context.service';
|
||||
import { PortalCollectionContextService } from './portal-collection-context.service';
|
||||
|
||||
export interface PortalCollectionContextBridge {
|
||||
readonly selectedCategoryId: Signal<string>;
|
||||
@@ -8,7 +8,7 @@ export interface PortalCollectionContextBridge {
|
||||
}
|
||||
|
||||
interface CreatePortalCollectionContextOptions {
|
||||
ctx: FavoritesContextService;
|
||||
ctx: PortalCollectionContextService;
|
||||
categories: () => XtreamCategory[];
|
||||
enabled?: () => boolean;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user