Proyecto App Windows

This commit is contained in:
2026-05-03 23:36:48 +02:00
commit 96793b2e1e
1248 changed files with 189750 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
export default {
displayName: 'portal-shared-util',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../../coverage/libs/portal/shared/util',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
+21
View File
@@ -0,0 +1,21 @@
{
"name": "portal-shared-util",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/portal/shared/util/src",
"prefix": "lib",
"projectType": "library",
"tags": ["scope:portal", "domain:portal-shared", "type:util"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/portal/shared/util/jest.config.ts",
"tsConfig": "libs/portal/shared/util/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+20
View File
@@ -0,0 +1,20 @@
export * from './lib/logger';
export * from './lib/channel-sort';
export * from './lib/favorites-channel-sort';
export * from './lib/portal-catalog-detail';
export * from './lib/portal-catalog-facade';
export * from './lib/portal-external-playback';
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/workspace-view-command.types';
export * from './lib/workspace-view-command.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,61 @@
const CHANNEL_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type PortalChannelSortMode = 'server' | 'name-asc' | 'name-desc';
export const DEFAULT_PORTAL_CHANNEL_SORT_MODE: PortalChannelSortMode = 'server';
export function isPortalChannelSortMode(
value: unknown
): value is PortalChannelSortMode {
return value === 'server' || value === 'name-asc' || value === 'name-desc';
}
export function restorePortalChannelSortMode(
storageKey: string,
fallback: PortalChannelSortMode = DEFAULT_PORTAL_CHANNEL_SORT_MODE
): PortalChannelSortMode {
const storedValue = localStorage.getItem(storageKey);
return isPortalChannelSortMode(storedValue) ? storedValue : fallback;
}
export function persistPortalChannelSortMode(
storageKey: string,
mode: PortalChannelSortMode
): void {
localStorage.setItem(storageKey, mode);
}
export function getPortalChannelSortModeLabel(
mode: PortalChannelSortMode
): string {
if (mode === 'name-asc') {
return 'Name A-Z';
}
if (mode === 'name-desc') {
return 'Name Z-A';
}
return 'Server Order';
}
export function sortPortalChannelItems<T>(
items: readonly T[],
mode: PortalChannelSortMode,
getDisplayName: (item: T) => string | null | undefined
): readonly T[] {
if (mode === 'server') {
return items;
}
return [...items].sort((a, b) => {
const result = CHANNEL_SORT_COLLATOR.compare(
getDisplayName(a) ?? '',
getDisplayName(b) ?? ''
);
return mode === 'name-asc' ? result : -result;
});
}
@@ -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,448 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import {
XtreamApiService,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { StalkerSessionService } from '@iptvnator/portal/stalker/data-access';
import { DataService, PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import {
ResolvedLiveCollectionDetail,
StreamResolverService,
} from './stream-resolver.service';
describe('StreamResolverService', () => {
let service: StreamResolverService;
let playlistsService: { getPlaylistById: jest.Mock };
let xtreamApi: { getShortEpg: jest.Mock };
let xtreamUrl: { constructLiveUrl: jest.Mock };
let dataService: { sendIpcEvent: jest.Mock };
let stalkerSession: { makeAuthenticatedRequest: jest.Mock };
beforeEach(() => {
playlistsService = {
getPlaylistById: jest.fn(),
};
xtreamApi = {
getShortEpg: jest.fn(),
};
xtreamUrl = {
constructLiveUrl: jest.fn(),
};
dataService = {
sendIpcEvent: jest.fn(),
};
stalkerSession = {
makeAuthenticatedRequest: jest.fn(),
};
window.electron = {
...window.electron,
getChannelPrograms: jest.fn(),
} as typeof window.electron;
TestBed.configureTestingModule({
providers: [
StreamResolverService,
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: XtreamApiService, useValue: xtreamApi },
{ provide: XtreamUrlService, useValue: xtreamUrl },
{ provide: DataService, useValue: dataService },
{ provide: StalkerSessionService, useValue: stalkerSession },
],
});
service = TestBed.inject(StreamResolverService);
});
it('returns M3U live detail with the full channel and raw EPG programs', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
playlist: {
items: [
{
id: 'channel-1',
name: 'News',
url: 'https://example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'news-id',
name: 'News',
url: '',
logo: 'news.png',
rec: '',
},
http: {
referrer: 'https://ref.example.com',
'user-agent': 'IPTVnator',
origin: 'https://origin.example.com',
},
radio: 'false',
epgParams: '',
},
],
},
} satisfies Partial<Playlist>)
);
(window.electron.getChannelPrograms as jest.Mock).mockResolvedValue([
{
start: '2026-03-26T11:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
channel: 'news-id',
title: 'Morning News',
desc: 'Latest headlines',
category: null,
},
]);
const detail = await service.resolveLiveDetail({
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
name: 'News',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/live.m3u8',
channelId: 'channel-1',
tvgId: 'news-id',
logo: 'news.png',
} satisfies UnifiedCollectionItem);
expect(detail).toMatchObject<Partial<ResolvedLiveCollectionDetail>>({
epgMode: 'm3u',
channel: expect.objectContaining({
id: 'channel-1',
url: 'https://example.com/live.m3u8',
}),
playback: expect.objectContaining({
streamUrl: 'https://example.com/live.m3u8',
userAgent: 'IPTVnator',
referer: 'https://ref.example.com',
origin: 'https://origin.example.com',
}),
});
expect(detail.epgPrograms).toHaveLength(1);
});
it('resolves M3U playback detail without blocking on channel-program lookup', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
playlist: {
items: [
{
id: 'channel-1',
name: 'News',
url: 'https://example.com/live.m3u8',
group: { title: 'News' },
tvg: {
id: 'news-id',
name: 'News',
url: '',
logo: 'news.png',
rec: '',
},
http: {
referrer: 'https://ref.example.com',
'user-agent': 'IPTVnator',
origin: 'https://origin.example.com',
},
radio: 'false',
epgParams: '',
},
],
},
} satisfies Partial<Playlist>)
);
const detail = await service.resolveM3uPlaybackDetail({
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
name: 'News',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/live.m3u8',
channelId: 'channel-1',
tvgId: 'news-id',
logo: 'news.png',
} satisfies UnifiedCollectionItem);
expect(detail.playback.streamUrl).toBe('https://example.com/live.m3u8');
expect(detail.epgPrograms).toEqual([]);
expect(window.electron.getChannelPrograms).not.toHaveBeenCalled();
});
it('preserves the radio flag when M3U playback falls back to item metadata', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
playlist: {
items: [],
},
} satisfies Partial<Playlist>)
);
const detail = await service.resolveM3uPlaybackDetail({
uid: 'm3u::m3u-1::radio-channel',
name: 'Radio Channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/radio.m3u8',
channelId: 'radio-channel',
tvgId: 'radio-id',
logo: 'radio.png',
radio: 'true',
} satisfies UnifiedCollectionItem);
expect(detail.channel).toMatchObject({
id: 'radio-channel',
url: 'https://example.com/radio.m3u8',
radio: 'true',
});
});
it('returns Xtream live detail with shared EPG items', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockResolvedValue([
{
id: '1',
epg_id: '',
title: 'Xtream Show',
description: 'Current Xtream program',
lang: '',
start: '2026-03-26T11:00:00.000Z',
end: '2026-03-26T12:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
channel_id: '1',
start_timestamp: '1774522800',
stop_timestamp: '1774526400',
},
]);
const detail = await service.resolveLiveDetail({
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
logo: 'xtream.png',
} satisfies UnifiedCollectionItem);
expect(detail.epgMode).toBe('portal');
expect(detail.playback.streamUrl).toBe(
'https://xtream.example.com/live/1'
);
expect(detail.epgItems).toHaveLength(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
10,
{
suppressErrorLog: true,
}
);
});
it('reuses cached empty Xtream preview EPG results instead of refetching immediately', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamApi.getShortEpg.mockResolvedValue([]);
const items = [
{
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem,
];
await service.loadEpgForItems(items);
await service.loadEpgForItems(items);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
expect(xtreamApi.getShortEpg).toHaveBeenCalledWith(
{
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
},
1,
2,
{
suppressErrorLog: true,
}
);
});
it('backs off repeated Xtream detail EPG failures during the cooldown window', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockRejectedValue(new Error('EPG failed'));
const item = {
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem;
const firstDetail = await service.resolveLiveDetail(item);
const secondDetail = await service.resolveLiveDetail(item);
expect(firstDetail.epgItems).toEqual([]);
expect(secondDetail.epgItems).toEqual([]);
expect(xtreamApi.getShortEpg).toHaveBeenCalledTimes(1);
});
it('falls back to empty Xtream EPG when the provider response is too slow', async () => {
jest.useFakeTimers();
try {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'xtream-1',
serverUrl: 'https://xtream.example.com',
username: 'user',
password: 'pass',
} satisfies Partial<Playlist>)
);
xtreamUrl.constructLiveUrl.mockReturnValue(
'https://xtream.example.com/live/1'
);
xtreamApi.getShortEpg.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(
() =>
resolve([
{
id: '1',
epg_id: '',
title: 'Late Xtream Show',
description: 'Delayed Xtream program',
lang: '',
start: '2026-03-26T11:00:00.000Z',
end: '2026-03-26T12:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
channel_id: '1',
start_timestamp: '1774522800',
stop_timestamp: '1774526400',
},
]),
10_000
);
})
);
const detailPromise = service.resolveLiveDetail({
uid: 'xtream::xtream-1::1',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 1,
} satisfies UnifiedCollectionItem);
await jest.advanceTimersByTimeAsync(3000);
await expect(detailPromise).resolves.toMatchObject({
epgMode: 'portal',
playback: expect.objectContaining({
streamUrl: 'https://xtream.example.com/live/1',
}),
epgItems: [],
});
} finally {
jest.useRealTimers();
}
});
it('loads current Stalker EPG previews for live collection rows', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
portalUrl: 'https://stalker.example.com',
macAddress: '00:11:22:33:44:55',
isFullStalkerPortal: false,
} satisfies Partial<Playlist>)
);
dataService.sendIpcEvent.mockResolvedValue({
js: [
{
id: '10',
name: 'Stalker Live',
descr: 'Current Stalker program',
time: '2026-03-26T11:00:00.000Z',
time_to: '2026-03-26T12:00:00.000Z',
ch_id: '77',
start_timestamp: String(Math.floor(Date.now() / 1000) - 60),
stop_timestamp: String(Math.floor(Date.now() / 1000) + 60),
},
],
});
const epgMap = await service.loadEpgForItems([
{
uid: 'stalker::stalker-1::77',
name: 'Stalker Channel',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker',
stalkerId: '77',
tvgId: '77',
stalkerCmd: 'ffmpeg http://stalker/77',
} satisfies UnifiedCollectionItem,
]);
expect(epgMap.get('77')).toMatchObject({
title: 'Stalker Live',
channel: '77',
});
});
});
@@ -0,0 +1,827 @@
import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { DataService, PlaylistsService } from 'services';
import {
Channel,
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';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
};
interface StalkerCreateLinkResponse {
readonly js?: { readonly cmd?: string };
}
interface StalkerEpgEntry {
readonly id?: string | number;
readonly name?: string;
readonly descr?: string;
readonly time?: string;
readonly time_to?: string;
readonly ch_id?: string | number;
readonly start_timestamp?: string | number;
readonly stop_timestamp?: string | number;
}
interface StalkerEpgResponse {
readonly js?: StalkerEpgEntry[] | { readonly data?: StalkerEpgEntry[] };
}
interface XtreamEpgCacheEntry {
readonly data: EpgItem[];
readonly timestamp: number;
}
export interface ResolvedLiveCollectionDetail {
readonly playback: ResolvedPortalPlayback;
readonly epgMode: 'm3u' | 'portal';
readonly channel?: Channel;
readonly epgPrograms?: EpgProgram[];
readonly epgItems?: EpgItem[];
}
@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);
private readonly m3uEpgTimeoutMs = 3000;
private readonly portalEpgTimeoutMs = 3000;
private readonly xtreamEpgCache = new Map<string, XtreamEpgCacheEntry>();
private readonly xtreamEpgFailureTimestamps = new Map<string, number>();
private readonly xtreamEpgCacheTtlMs = 60 * 1000;
private readonly xtreamEpgFailureCooldownMs = 60 * 1000;
private async getElectronPlaylist(
playlistId: string
): Promise<Playlist | undefined> {
if (typeof window === 'undefined') {
return undefined;
}
try {
return (
(await window.electron?.dbGetAppPlaylist?.(playlistId)) ??
undefined
);
} catch {
return undefined;
}
}
async resolvePlayback(
item: UnifiedCollectionItem
): Promise<ResolvedPortalPlayback> {
if (item.sourceType === 'm3u') {
return (await this.resolveM3uPlaybackDetail(item)).playback;
}
return (await this.resolveLiveDetail(item)).playback;
}
async resolveLiveDetail(
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
switch (item.sourceType) {
case 'm3u':
return this.resolveM3uDetail(item);
case 'xtream':
return this.resolveXtreamDetail(item);
case 'stalker':
return this.resolveStalkerDetail(item);
}
}
async resolveM3uPlaybackDetail(
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
return this.buildM3uDetail(item, false);
}
async loadM3uProgramsForItem(
item: UnifiedCollectionItem,
channel?: Channel
): Promise<EpgProgram[]> {
const epgLookupKey = channel
? this.getM3uEpgLookupKey(channel, item)
: await this.getM3uEpgLookupKeyForItem(item);
return this.fetchM3uPrograms(epgLookupKey);
}
async loadEpgItems(item: UnifiedCollectionItem): Promise<EpgItem[]> {
const detail = await this.resolveLiveDetail(item);
if (detail.epgItems) {
return detail.epgItems;
}
return this.mapProgramsToEpgItems(detail.epgPrograms ?? []);
}
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[]>();
const stalkerByPlaylist = new Map<string, UnifiedCollectionItem[]>();
for (const item of items) {
if (item.contentType !== 'live') {
continue;
}
if (item.sourceType === 'xtream') {
const list = xtreamByPlaylist.get(item.playlistId) ?? [];
list.push(item);
xtreamByPlaylist.set(item.playlistId, list);
continue;
}
if (item.sourceType === 'stalker') {
const list = stalkerByPlaylist.get(item.playlistId) ?? [];
list.push(item);
stalkerByPlaylist.set(item.playlistId, list);
continue;
}
}
const tasks: Promise<void>[] = [];
for (const item of items) {
if (item.sourceType === 'm3u' && item.contentType === 'live') {
tasks.push(this.loadM3uEpg(item, epgMap, now));
}
}
for (const [playlistId, playlistItems] of xtreamByPlaylist.entries()) {
tasks.push(
this.loadXtreamEpgBatch(playlistId, playlistItems, epgMap, now)
);
}
for (const [playlistId, playlistItems] of stalkerByPlaylist.entries()) {
tasks.push(
this.loadStalkerEpgBatch(playlistId, playlistItems, epgMap, now)
);
}
await Promise.all(tasks.map((task) => task.catch(() => null)));
return epgMap;
}
private async resolveM3uDetail(
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
return this.buildM3uDetail(item, true);
}
private async resolveXtreamDetail(
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
const playback = await this.resolveXtream(item);
const epgItems = await this.withFallbackTimeout(
this.loadXtreamEpgItems(item),
this.portalEpgTimeoutMs,
[]
);
return {
playback,
epgMode: 'portal',
epgItems,
};
}
private async resolveStalkerDetail(
item: UnifiedCollectionItem
): Promise<ResolvedLiveCollectionDetail> {
const playback = await this.resolveStalker(item);
const epgItems = await this.withFallbackTimeout(
this.loadStalkerEpgItems(item, 10),
this.portalEpgTimeoutMs,
[]
);
return {
playback,
epgMode: 'portal',
epgItems,
};
}
private buildM3uPlayback(
channel: Channel,
playlist?: Playlist
): ResolvedPortalPlayback {
const userAgent =
channel.http?.['user-agent']?.trim() || playlist?.userAgent;
const referer = channel.http?.referrer?.trim() || playlist?.referrer;
const origin = channel.http?.origin?.trim() || playlist?.origin;
const headers: Record<string, string> = {};
if (userAgent) {
headers['User-Agent'] = userAgent;
}
if (referer) {
headers['Referer'] = referer;
}
if (origin) {
headers['Origin'] = origin;
}
return {
streamUrl: channel.url ?? '',
title: channel.name,
thumbnail: channel.tvg?.logo ?? null,
headers: Object.keys(headers).length > 0 ? headers : undefined,
userAgent,
referer,
origin,
};
}
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 ?? '';
return {
streamUrl: this.normalizeStalkerCmd(rawCmd),
title: item.name,
thumbnail: item.logo ?? null,
};
}
private async loadXtreamEpgItems(
item: UnifiedCollectionItem
): Promise<EpgItem[]> {
try {
if (item.xtreamId == null) {
return [];
}
const creds = await this.getXtreamCredentials(item.playlistId);
if (!creds) {
return [];
}
return await this.fetchXtreamEpgItems(
item.playlistId,
creds,
item.xtreamId,
10
);
} catch {
return [];
}
}
private async loadStalkerEpgItems(
item: UnifiedCollectionItem,
size: number
): Promise<EpgItem[]> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
)) as Playlist | undefined;
const channelId = String(
item.stalkerId ??
(item.stalkerItem as Record<string, unknown> | undefined)?.[
'id'
] ??
''
).trim();
if (!playlist || !channelId) {
return [];
}
return this.fetchStalkerShortEpg(playlist, channelId, size);
}
private async getXtreamCredentials(playlistId: string) {
const playlist =
(await this.getElectronPlaylist(playlistId)) ??
((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(
item: UnifiedCollectionItem,
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
const epgLookupKey = item.tvgId?.trim() || item.name?.trim();
if (!epgLookupKey) {
return;
}
const programs = await this.fetchM3uPrograms(epgLookupKey);
epgMap.set(epgLookupKey, this.findCurrentProgram(programs, now));
}
private async buildM3uDetail(
item: UnifiedCollectionItem,
includePrograms: boolean
): Promise<ResolvedLiveCollectionDetail> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
)) as PlaylistWithChannels | undefined;
const channel =
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
this.buildFallbackM3uChannel(item);
const epgPrograms = includePrograms
? await this.fetchM3uPrograms(
this.getM3uEpgLookupKey(channel, item)
)
: [];
return {
playback: this.buildM3uPlayback(channel, playlist),
epgMode: 'm3u',
channel,
epgPrograms,
};
}
private async getM3uEpgLookupKeyForItem(
item: UnifiedCollectionItem
): Promise<string | null> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(item.playlistId)
)) as PlaylistWithChannels | undefined;
const channel =
this.findM3uChannel(playlist?.playlist?.items ?? [], item) ??
this.buildFallbackM3uChannel(item);
return this.getM3uEpgLookupKey(channel, item);
}
private async fetchM3uPrograms(
epgLookupKey?: string | null
): Promise<EpgProgram[]> {
if (!window.electron?.getChannelPrograms || !epgLookupKey) {
return [];
}
return this.withFallbackTimeout(
window.electron.getChannelPrograms(epgLookupKey),
this.m3uEpgTimeoutMs,
[]
);
}
private async withFallbackTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
fallback: T
): Promise<T> {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => resolve(fallback), timeoutMs);
promise
.then((value) => {
clearTimeout(timeoutId);
resolve(value);
})
.catch(() => {
clearTimeout(timeoutId);
resolve(fallback);
});
});
}
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 (channel) => {
if (!channel.xtreamId) {
return;
}
try {
const items = await this.fetchXtreamEpgItems(
playlistId,
creds,
channel.xtreamId,
2
);
const nowSeconds = Math.floor(now / 1000);
const currentItem =
items.find(
(item) =>
Number(item.start_timestamp) <= nowSeconds &&
nowSeconds < Number(item.stop_timestamp)
) ?? null;
const epgKey =
channel.tvgId?.trim() || channel.name?.trim();
if (!epgKey) {
return;
}
epgMap.set(
epgKey,
currentItem
? {
start: new Date(
Number(currentItem.start_timestamp) * 1000
).toISOString(),
stop: new Date(
Number(currentItem.stop_timestamp) * 1000
).toISOString(),
channel: String(channel.xtreamId),
title: currentItem.title,
desc: currentItem.description ?? null,
category: null,
}
: null
);
} catch {
const epgKey =
channel.tvgId?.trim() || channel.name?.trim();
if (epgKey) {
epgMap.set(epgKey, null);
}
}
})
);
}
private getXtreamEpgCacheKey(
playlistId: string,
streamId: number,
limit: number
): string {
return `${playlistId}:${streamId}:${limit}`;
}
private getCachedXtreamEpgItems(cacheKey: string): EpgItem[] | null {
const entry = this.xtreamEpgCache.get(cacheKey);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > this.xtreamEpgCacheTtlMs) {
this.xtreamEpgCache.delete(cacheKey);
return null;
}
return entry.data;
}
private isXtreamEpgFailureCoolingDown(cacheKey: string): boolean {
const timestamp = this.xtreamEpgFailureTimestamps.get(cacheKey);
if (timestamp == null) {
return false;
}
if (Date.now() - timestamp > this.xtreamEpgFailureCooldownMs) {
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return false;
}
return true;
}
private async fetchXtreamEpgItems(
playlistId: string,
credentials: {
serverUrl: string;
username: string;
password: string;
},
streamId: number,
limit: number
): Promise<EpgItem[]> {
const cacheKey = this.getXtreamEpgCacheKey(playlistId, streamId, limit);
const cached = this.getCachedXtreamEpgItems(cacheKey);
if (cached !== null) {
return cached;
}
if (this.isXtreamEpgFailureCoolingDown(cacheKey)) {
return [];
}
try {
const items = await this.xtreamApi.getShortEpg(
credentials,
streamId,
limit,
{
suppressErrorLog: true,
}
);
this.xtreamEpgCache.set(cacheKey, {
data: items,
timestamp: Date.now(),
});
this.xtreamEpgFailureTimestamps.delete(cacheKey);
return items;
} catch {
this.xtreamEpgFailureTimestamps.set(cacheKey, Date.now());
return [];
}
}
private async loadStalkerEpgBatch(
playlistId: string,
channels: UnifiedCollectionItem[],
epgMap: Map<string, EpgProgram | null>,
now: number
): Promise<void> {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
if (!playlist) {
return;
}
await Promise.all(
channels.map(async (channel) => {
const channelId = String(channel.stalkerId ?? '').trim();
const epgKey =
channel.tvgId?.trim() || channelId || channel.name?.trim();
if (!channelId || !epgKey) {
return;
}
try {
const items = await this.fetchStalkerShortEpg(
playlist,
channelId,
1
);
epgMap.set(
epgKey,
items.length > 0
? this.toPreviewProgram(items[0], channelId, now)
: null
);
} catch {
epgMap.set(epgKey, null);
}
})
);
}
private async fetchStalkerShortEpg(
playlist: Playlist,
channelId: string,
size: number
): Promise<EpgItem[]> {
const params = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: channelId,
size: String(size),
};
let response: StalkerEpgResponse;
if (playlist.isFullStalkerPortal) {
response = await this.stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
response = await this.dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
});
}
const epgData = Array.isArray(response?.js)
? response.js
: (response?.js?.data ?? []);
return epgData.map((item) => ({
id: String(item.id ?? ''),
epg_id: '',
title: item.name ?? '',
description: item.descr ?? '',
lang: '',
start: item.time ?? '',
end: item.time_to ?? '',
stop: item.time_to ?? '',
channel_id: String(item.ch_id ?? channelId),
start_timestamp: String(item.start_timestamp ?? ''),
stop_timestamp: String(item.stop_timestamp ?? ''),
}));
}
private findM3uChannel(
channels: Channel[],
item: UnifiedCollectionItem
): Channel | undefined {
return channels.find(
(channel) =>
(item.streamUrl && channel.url === item.streamUrl) ||
(item.channelId && channel.id === item.channelId)
);
}
private buildFallbackM3uChannel(item: UnifiedCollectionItem): Channel {
return {
id: item.channelId ?? item.uid.split('::')[2] ?? '',
name: item.name,
url: item.streamUrl ?? '',
tvg: {
id: item.tvgId ?? '',
name: item.name,
url: '',
logo: item.logo ?? '',
rec: '',
},
group: { title: '' },
http: {
referrer: '',
'user-agent': '',
origin: '',
},
radio: item.radio ?? 'false',
epgParams: '',
};
}
private getM3uEpgLookupKey(
channel: Channel | undefined,
item: UnifiedCollectionItem
): string {
return (
channel?.tvg?.id?.trim() ||
item.tvgId?.trim() ||
channel?.tvg?.name?.trim() ||
channel?.name?.trim() ||
item.name?.trim() ||
''
);
}
private findCurrentProgram(
programs: EpgProgram[],
now: number
): EpgProgram | null {
return (
programs.find((program) => {
const start = new Date(program.start).getTime();
const stop = new Date(program.stop).getTime();
return start <= now && now < stop;
}) ?? null
);
}
private toPreviewProgram(
item: EpgItem,
channelId: string | number,
now: number
): EpgProgram | null {
const startTimestamp = Number(item.start_timestamp);
const stopTimestamp = Number(item.stop_timestamp);
if (
Number.isFinite(startTimestamp) &&
Number.isFinite(stopTimestamp) &&
startTimestamp > 0 &&
stopTimestamp > 0
) {
const nowSeconds = Math.floor(now / 1000);
if (nowSeconds < startTimestamp || nowSeconds >= stopTimestamp) {
return null;
}
}
return {
start: item.start,
stop: item.stop || item.end,
channel: String(channelId),
title: item.title,
desc: item.description || null,
category: null,
};
}
private mapProgramsToEpgItems(programs: EpgProgram[]): EpgItem[] {
return programs.map((program, index) => ({
id: String(index),
epg_id: program.channel,
title: program.title,
lang: 'en',
start: program.start,
end: program.stop,
stop: program.stop,
description: program.desc ?? '',
channel_id: program.channel,
start_timestamp: String(
Math.floor(new Date(program.start).getTime() / 1000)
),
stop_timestamp: String(
Math.floor(new Date(program.stop).getTime() / 1000)
),
}));
}
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,96 @@
/**
* 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;
/** Stable live-source EPG lookup key */
tvgId?: string;
/** M3U playlist channel id used to rehydrate the full channel object */
channelId?: string;
/** M3U radio flag used to preserve the dedicated radio player layout */
radio?: string;
/** Xtream numeric stream ID */
xtreamId?: number;
/** Xtream DB content id — used for reorder / remove IPC */
contentId?: number;
/** Stalker cmd for stream resolution */
stalkerId?: string | number;
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}`;
}
export function buildXtreamCollectionUid(
playlistId: string,
contentType: CollectionContentType,
xtreamId: string | number
): string {
return buildCollectionUid('xtream', playlistId, `${contentType}:${xtreamId}`);
}
@@ -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,442 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
Channel,
Playlist,
PlaylistMeta,
StalkerPortalItem,
} from 'shared-interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedFavoritesDataService } from './unified-favorites-data.service';
describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbGetAllGlobalFavorites: jest.Mock;
dbRemoveFavorite: jest.Mock;
};
let databaseService: {
getAllGlobalFavorites: jest.Mock;
getFavorites: jest.Mock;
};
let playlistsService: {
getPlaylistById: jest.Mock;
setFavorites: jest.Mock;
setPortalFavorites: jest.Mock;
};
const m3uChannels: Channel[] = [
{
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/1.m3u8',
group: { title: 'News' },
tvg: {
id: 'one',
name: 'Channel One',
url: '',
logo: 'one.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
{
id: 'channel-2',
name: 'Channel Two',
url: 'https://example.com/2.m3u8',
group: { title: 'Sports' },
tvg: {
id: 'two',
name: 'Channel Two',
url: '',
logo: 'two.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'true',
epgParams: '',
},
];
const stalkerFavorites: StalkerPortalItem[] = [
{
id: '101',
title: 'Stalker One',
category_id: 'itv',
cmd: 'ffmpeg http://stalker/101',
logo: 'one.png',
added_at: '2026-03-26T10:00:00.000Z',
},
{
id: '202',
title: 'Stalker Two',
category_id: 'itv',
cmd: 'ffmpeg http://stalker/202',
logo: 'two.png',
added_at: '2026-03-26T11:00:00.000Z',
},
];
beforeEach(() => {
electronApi = {
dbGetAllGlobalFavorites: jest.fn(),
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
};
Object.defineProperty(window, 'electron', {
value: electronApi as Window['electron'],
configurable: true,
});
playlistsService = {
getPlaylistById: jest.fn(),
setFavorites: jest.fn().mockReturnValue(of({})),
setPortalFavorites: jest.fn().mockReturnValue(of({})),
};
databaseService = {
getAllGlobalFavorites: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
UnifiedFavoritesDataService,
{
provide: Store,
useValue: {
select: jest.fn(() =>
of([
{
_id: 'm3u-1',
title: 'M3U List',
favorites: [
'https://example.com/2.m3u8',
'channel-1',
],
},
{
_id: 'stalker-1',
title: 'Stalker List',
macAddress: '00:11:22:33:44:55',
favorites: stalkerFavorites,
},
] satisfies PlaylistMeta[])
),
},
},
{
provide: DatabaseService,
useValue: databaseService,
},
{
provide: PlaylistsService,
useValue: playlistsService,
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
},
},
],
});
service = TestBed.inject(UnifiedFavoritesDataService);
});
it('maps global Xtream favorites across live, movie, and series content types', async () => {
databaseService.getAllGlobalFavorites.mockResolvedValue([
{
id: 10,
category_id: 1,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 101,
title: 'Live One',
type: 'live',
poster_url: 'live.png',
added_at: '2026-03-26T09:00:00.000Z',
position: 0,
},
{
id: 11,
category_id: 2,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 102,
title: 'Movie One',
type: 'movie',
poster_url: 'movie.png',
added_at: '2026-03-26T08:00:00.000Z',
position: 1,
},
{
id: 12,
category_id: 3,
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 103,
title: 'Series One',
type: 'series',
poster_url: 'series.png',
added_at: '2026-03-26T07:00:00.000Z',
position: 2,
},
]);
const items = await service.getFavorites('all');
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'Live One',
contentType: 'live',
logo: 'live.png',
posterUrl: null,
}),
expect.objectContaining({
name: 'Movie One',
contentType: 'movie',
logo: null,
posterUrl: 'movie.png',
}),
expect.objectContaining({
name: 'Series One',
contentType: 'series',
logo: null,
posterUrl: 'series.png',
}),
])
);
});
it('preserves persisted M3U favorites order when extracting playlist favorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
favorites: [
'https://example.com/2.m3u8',
'channel-1',
],
playlist: {
items: m3uChannels,
},
} satisfies Partial<Playlist>)
);
const items = await service.getFavorites('playlist', 'm3u-1', 'm3u');
expect(items.map((item) => item.streamUrl)).toEqual([
'https://example.com/2.m3u8',
'https://example.com/1.m3u8',
]);
expect(items[1].channelId).toBe('channel-1');
expect(items[0].radio).toBe('true');
});
it('persists M3U playlist reorders through setFavorites', async () => {
const reorderedItems = [
{
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
name: 'Channel Two',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/2.m3u8',
channelId: 'channel-2',
},
{
uid: 'm3u::m3u-1::https://example.com/1.m3u8',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/1.m3u8',
channelId: 'channel-1',
},
] satisfies UnifiedCollectionItem[];
await service.reorder(reorderedItems, {
scope: 'playlist',
playlistId: 'm3u-1',
portalType: 'm3u',
});
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
'https://example.com/2.m3u8',
'https://example.com/1.m3u8',
]);
});
it('persists Stalker playlist reorders through setPortalFavorites', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
macAddress: '00:11:22:33:44:55',
favorites: stalkerFavorites,
} satisfies Partial<Playlist>)
);
await service.reorder(
[
{
uid: 'stalker::stalker-1::202',
name: 'Stalker Two',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker List',
stalkerId: '202',
},
{
uid: 'stalker::stalker-1::101',
name: 'Stalker One',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker List',
stalkerId: '101',
},
] satisfies UnifiedCollectionItem[],
{
scope: 'playlist',
playlistId: 'stalker-1',
portalType: 'stalker',
}
);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
'stalker-1',
[stalkerFavorites[1], stalkerFavorites[0]]
);
});
it('clears M3U favorites once per playlist with the remaining favorites preserved', async () => {
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'm3u-1',
favorites: [
'https://example.com/2.m3u8',
'channel-1',
'https://example.com/3.m3u8',
],
} satisfies Partial<Playlist>)
);
await service.clearFavorites([
{
uid: 'm3u::m3u-1::https://example.com/2.m3u8',
name: 'Channel Two',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/2.m3u8',
channelId: 'channel-2',
},
{
uid: 'm3u::m3u-1::channel-1',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/1.m3u8',
channelId: 'channel-1',
},
] satisfies UnifiedCollectionItem[]);
expect(playlistsService.setFavorites).toHaveBeenCalledTimes(1);
expect(playlistsService.setFavorites).toHaveBeenCalledWith('m3u-1', [
'https://example.com/3.m3u8',
]);
});
it('clears Stalker favorites once per playlist with the remaining favorites preserved', async () => {
const remainingFavorite: StalkerPortalItem = {
id: '303',
title: 'Stalker Three',
category_id: 'itv',
cmd: 'ffmpeg http://stalker/303',
logo: 'three.png',
added_at: '2026-03-26T12:00:00.000Z',
};
playlistsService.getPlaylistById.mockReturnValue(
of({
_id: 'stalker-1',
macAddress: '00:11:22:33:44:55',
favorites: [...stalkerFavorites, remainingFavorite],
} satisfies Partial<Playlist>)
);
await service.clearFavorites([
{
uid: 'stalker::stalker-1::101',
name: 'Stalker One',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker List',
stalkerId: '101',
},
{
uid: 'stalker::stalker-1::202',
name: 'Stalker Two',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker List',
stalkerId: '202',
},
] satisfies UnifiedCollectionItem[]);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledTimes(1);
expect(playlistsService.setPortalFavorites).toHaveBeenCalledWith(
'stalker-1',
[remainingFavorite]
);
});
it('clears Xtream favorites through the bulk removal path', async () => {
await service.clearFavorites([
{
uid: 'xtream::xtream-1::101',
name: 'Xtream One',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 101,
contentId: 10,
},
{
uid: 'xtream::xtream-1::102',
name: 'Xtream Two',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 102,
contentId: 11,
},
] satisfies UnifiedCollectionItem[]);
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledTimes(2);
expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith(
1,
10,
'xtream-1'
);
expect(electronApi.dbRemoveFavorite).toHaveBeenNthCalledWith(
2,
11,
'xtream-1'
);
});
});
@@ -0,0 +1,552 @@
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,
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
normalizeStalkerDate,
Playlist,
PlaylistMeta,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildCollectionUid,
buildXtreamCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import {
isStalkerItem,
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(
(favoriteId) =>
favoriteId !== item.streamUrl &&
favoriteId !== item.channelId
);
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 clearFavorites(items: UnifiedCollectionItem[]): Promise<void> {
if (items.length === 0) {
return;
}
await Promise.all([
this.clearM3uFavorites(
items.filter((item) => item.sourceType === 'm3u')
),
this.clearXtreamFavorites(
items.filter((item) => item.sourceType === 'xtream')
),
this.clearStalkerFavorites(
items.filter((item) => item.sourceType === 'stalker')
),
]);
}
async reorder(
items: UnifiedCollectionItem[],
options?: {
scope: CollectionScope;
playlistId?: string;
portalType?: string;
}
): Promise<void> {
if (
options?.scope === 'playlist' &&
options.playlistId &&
options.portalType === 'm3u'
) {
await firstValueFrom(
this.playlistsService.setFavorites(
options.playlistId,
items
.map((item) => item.streamUrl ?? item.channelId ?? '')
.filter((value) => value.length > 0)
)
);
return;
}
if (
options?.scope === 'playlist' &&
options.playlistId &&
options.portalType === 'stalker'
) {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(options.playlistId)
)) as Playlist | undefined;
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerItem)
: [];
const favoritesById = new Map(
currentFavorites.map((favorite) => [
this.getStalkerFavoriteId(favorite),
favorite,
])
);
const reorderedFavorites = items
.map(
(item) =>
favoritesById.get(this.getStalkerFavoriteId(item)) ?? null
)
.filter(
(favorite): favorite is StalkerPortalItem =>
favorite !== null
);
await firstValueFrom(
this.playlistsService.setPortalFavorites(
options.playlistId,
reorderedFavorites
)
);
return;
}
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
if (
options?.scope === 'playlist' &&
options.playlistId &&
options.portalType === 'xtream'
) {
if (xtreamUpdates.length > 0 && window.electron) {
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
}
return;
}
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(items.map((item) => item.uid)),
]);
}
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 clearM3uFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
const groupedItems = this.groupItemsByPlaylist(items);
await Promise.all(
Array.from(groupedItems.entries()).map(
async ([playlistId, playlistItems]) => {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
const targetIds = new Set<string>();
playlistItems.forEach((item) => {
[item.streamUrl, item.channelId].forEach((value) => {
const normalized = value?.trim();
if (normalized) {
targetIds.add(normalized);
}
});
});
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(
(favorite): favorite is string =>
typeof favorite === 'string'
)
: [];
const nextFavorites = currentFavorites.filter(
(favorite) => !targetIds.has(favorite.trim())
);
await firstValueFrom(
this.playlistsService.setFavorites(
playlistId,
nextFavorites
)
);
}
)
);
}
private async clearXtreamFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
if (!window.electron) {
return;
}
await Promise.all(
items
.filter((item) => item.contentId != null)
.map((item) =>
window.electron!.dbRemoveFavorite(
item.contentId!,
item.playlistId
)
)
);
}
private async clearStalkerFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
const groupedItems = this.groupItemsByPlaylist(items);
await Promise.all(
Array.from(groupedItems.entries()).map(
async ([playlistId, playlistItems]) => {
const playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(playlistId)
)) as Playlist | undefined;
const targetIds = new Set(
playlistItems.map((item) =>
this.getStalkerFavoriteId(item)
)
);
const currentFavorites = Array.isArray(playlist?.favorites)
? playlist.favorites.filter(isStalkerItem)
: [];
const nextFavorites = currentFavorites.filter(
(favorite) =>
!targetIds.has(this.getStalkerFavoriteId(favorite))
);
await firstValueFrom(
this.playlistsService.setPortalFavorites(
playlistId,
nextFavorites
)
);
}
)
);
}
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 favoriteIds = (meta.favorites as string[]).map(String);
let playlist: PlaylistWithChannels | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as PlaylistWithChannels | undefined;
} catch { return []; }
const channels = playlist?.playlist?.items ?? [];
const channelsByFavoriteId = new Map<string, Channel>();
channels.forEach((channel) => {
if (channel.url?.trim()) {
channelsByFavoriteId.set(channel.url.trim(), channel);
}
if (channel.id?.trim()) {
channelsByFavoriteId.set(channel.id.trim(), channel);
}
});
const seenUrls = new Set<string>();
return favoriteIds
.map((favoriteId, index) => {
const channel = channelsByFavoriteId.get(favoriteId.trim());
if (!channel?.url || seenUrls.has(channel.url)) {
return null;
}
seenUrls.add(channel.url);
const sourceItemId = channel.url?.trim() || channel.id?.trim();
if (!sourceItemId) {
return null;
}
return {
uid: buildCollectionUid('m3u', meta._id, sourceItemId),
name: channel.name,
contentType: 'live' as const,
sourceType: 'm3u' as const,
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
logo: channel.tvg?.logo ?? null,
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
tvgId: channel.tvg?.id || channel.tvg?.name || channel.name,
addedAt: new Date(0).toISOString(),
position: index,
} satisfies UnifiedCollectionItem;
})
.filter((channel) => channel !== null) as UnifiedCollectionItem[];
}
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: buildXtreamCollectionUid(row.playlist_id, ct, 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:
normalizeStalkerDate(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, index) => {
const ct = extractStalkerItemType(fav);
const stalkerId = extractStalkerItemId(fav, meta._id, index);
const poster = extractStalkerItemPoster(fav) || null;
return {
uid: buildCollectionUid('stalker', meta._id, stalkerId),
name:
extractStalkerItemTitle(fav) ||
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' ? poster : null,
posterUrl: ct !== 'live' ? poster : null,
tvgId: ct === 'live' ? stalkerId : undefined,
stalkerId,
stalkerCmd: fav.cmd,
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
stalkerMacAddress: playlist?.macAddress,
categoryId: fav.category_id,
stalkerItem: fav,
addedAt:
normalizeStalkerDate(fav.added_at) ||
new Date(0).toISOString(),
position: index,
};
});
}
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;
const 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];
}
private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) {
return items
.filter((item) => item.sourceType === 'xtream' && item.contentId != null)
.map((item, index) => ({
content_id: item.contentId!,
position: index,
}));
}
private groupItemsByPlaylist(
items: UnifiedCollectionItem[]
): Map<string, UnifiedCollectionItem[]> {
return items.reduce((groups, item) => {
const group = groups.get(item.playlistId);
if (group) {
group.push(item);
} else {
groups.set(item.playlistId, [item]);
}
return groups;
}, new Map<string, UnifiedCollectionItem[]>());
}
private getStalkerFavoriteId(
favorite: Pick<UnifiedCollectionItem, 'stalkerId' | 'uid'> | StalkerPortalItem
): string {
if ('uid' in favorite) {
return String(
favorite.stalkerId ?? favorite.uid.split('::')[2] ?? ''
).trim();
}
return extractStalkerItemId(favorite);
}
}
@@ -0,0 +1,579 @@
import {
ChangeDetectionStrategy,
Component,
Directive,
input,
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import {
GlobalFavoritesListComponent,
UnifiedLiveTabComponent,
} from '@iptvnator/portal/shared/ui';
import {
AudioPlayerComponent,
ArtPlayerComponent,
HtmlVideoPlayerComponent,
VjsPlayerComponent,
} from '@iptvnator/ui/playback';
import { EpgListComponent } from '@iptvnator/ui/epg';
import { ResizableDirective } from 'components';
import { SettingsStore } from 'services';
import { Channel, EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent } from 'shared-portals';
import {
DEFAULT_FAVORITES_CHANNEL_SORT_MODE,
PORTAL_PLAYER,
FavoritesChannelSortMode,
StreamResolverService,
UnifiedCollectionItem,
UnifiedFavoriteChannel,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/util';
@Directive({
selector: '[appResizable]',
})
class StubResizableDirective {
readonly minWidth = input<number>();
readonly maxWidth = input<number>();
readonly defaultWidth = input<number>();
readonly storageKey = input<string>('');
}
@Component({
selector: 'app-global-favorites-list',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubGlobalFavoritesListComponent {
readonly channels = input.required<UnifiedFavoriteChannel[]>();
readonly epgMap = input<Map<string, EpgProgram | null>>(new Map());
readonly progressTick = input(0);
readonly activeUid = input<string | null>(null);
readonly searchTermInput = input('');
readonly draggable = input(true);
readonly sortMode = input<FavoritesChannelSortMode>(
DEFAULT_FAVORITES_CHANNEL_SORT_MODE
);
readonly channelSelected = output<UnifiedFavoriteChannel>();
readonly channelsReordered = output<UnifiedFavoriteChannel[]>();
readonly favoriteToggled = output<UnifiedFavoriteChannel>();
}
@Component({
selector: 'app-epg-list',
template: '<div class="stub-epg-list"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubEpgListComponent {
readonly controlledChannel = input<Channel | null>(null);
readonly controlledPrograms = input<EpgProgram[] | null>(null);
readonly archivePlaybackAvailable = input<boolean | null>(null);
}
@Component({
selector: 'app-epg-view',
template: '<div class="stub-epg-view"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubEpgViewComponent {
readonly epgItems = input<EpgItem[]>([]);
}
@Component({
selector: 'app-vjs-player',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubVjsPlayerComponent {
readonly options = input<unknown>();
readonly volume = input(1);
}
@Component({
selector: 'app-html-video-player',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubHtmlVideoPlayerComponent {
readonly channel = input<Channel | null>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
}
@Component({
selector: 'app-art-player',
template: '',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubArtPlayerComponent {
readonly channel = input<Channel | null>(null);
readonly volume = input(1);
readonly showCaptions = input(false);
}
@Component({
selector: 'app-audio-player',
template: '<div class="stub-audio-player"></div>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
class StubAudioPlayerComponent {
readonly icon = input('');
readonly url = input.required<string>();
readonly channelName = input('');
}
describe('UnifiedLiveTabComponent', () => {
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
let component: UnifiedLiveTabComponent;
let streamResolver: {
resolveLiveDetail: jest.Mock;
resolveM3uPlaybackDetail: jest.Mock;
loadM3uProgramsForItem: jest.Mock;
loadEpgForItems: jest.Mock;
};
let recentData: {
recordLivePlayback: jest.Mock;
};
let portalPlayer: {
isEmbeddedPlayer: jest.Mock;
openResolvedPlayback: jest.Mock;
};
beforeEach(async () => {
streamResolver = {
resolveLiveDetail: jest.fn(),
resolveM3uPlaybackDetail: jest.fn(),
loadM3uProgramsForItem: jest.fn().mockResolvedValue([]),
loadEpgForItems: jest.fn().mockResolvedValue(new Map()),
};
recentData = {
recordLivePlayback: jest.fn(),
};
portalPlayer = {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), UnifiedLiveTabComponent],
providers: [
{ provide: StreamResolverService, useValue: streamResolver },
{ provide: UnifiedRecentDataService, useValue: recentData },
{
provide: SettingsStore,
useValue: {
player: signal('videojs'),
},
},
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
],
})
.overrideComponent(UnifiedLiveTabComponent, {
remove: {
imports: [
AudioPlayerComponent,
ArtPlayerComponent,
EpgListComponent,
EpgViewComponent,
GlobalFavoritesListComponent,
HtmlVideoPlayerComponent,
ResizableDirective,
VjsPlayerComponent,
],
},
add: {
imports: [
StubAudioPlayerComponent,
StubArtPlayerComponent,
StubEpgListComponent,
StubEpgViewComponent,
StubGlobalFavoritesListComponent,
StubHtmlVideoPlayerComponent,
StubResizableDirective,
StubVjsPlayerComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(UnifiedLiveTabComponent);
component = fixture.componentInstance;
});
it('renders controlled M3U EPG and records recent history on selection', async () => {
const item = buildLiveItem('m3u');
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: {
id: 'm3u-channel',
name: 'M3U Live',
url: '',
logo: 'm3u.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
epgPrograms: [],
});
streamResolver.loadM3uProgramsForItem.mockResolvedValue([
buildProgram('M3U Show'),
]);
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalledWith(
item,
expect.objectContaining({
id: 'm3u-channel',
})
);
expect(
fixture.nativeElement.querySelector('app-epg-list')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('does not wait for M3U program lookup before opening playback', async () => {
const item = buildLiveItem('m3u');
const pendingPrograms = createDeferred<EpgProgram[]>();
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/m3u.m3u8',
title: 'M3U Live',
},
channel: {
id: 'm3u-channel',
name: 'M3U Live',
url: 'https://example.com/m3u.m3u8',
group: { title: 'News' },
tvg: {
id: 'm3u-channel',
name: 'M3U Live',
url: '',
logo: 'm3u.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
epgPrograms: [buildProgram('M3U Show')],
});
streamResolver.loadM3uProgramsForItem.mockReturnValue(
pendingPrograms.promise
);
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(portalPlayer.openResolvedPlayback).toHaveBeenCalledWith(
expect.objectContaining({
streamUrl: 'https://example.com/m3u.m3u8',
})
);
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(streamResolver.loadM3uProgramsForItem).toHaveBeenCalled();
pendingPrograms.resolve([buildProgram('M3U Show')]);
await fixture.whenStable();
});
it('renders inline audio for M3U radio items and skips external playback', async () => {
const item = {
...buildLiveItem('m3u'),
radio: 'true',
} satisfies UnifiedCollectionItem;
streamResolver.resolveM3uPlaybackDetail.mockResolvedValue({
epgMode: 'm3u',
playback: {
streamUrl: 'https://example.com/radio.m3u8',
title: 'M3U Radio',
thumbnail: 'radio.png',
},
channel: {
id: 'm3u-channel',
name: 'M3U Radio',
url: 'https://example.com/radio.m3u8',
group: { title: 'Radio' },
tvg: {
id: 'm3u-channel',
name: 'M3U Radio',
url: '',
logo: 'radio.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'true',
epgParams: '',
},
epgPrograms: [],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-view')).toBeNull();
});
it('renders shared EPG view for Xtream items and records recent history', async () => {
const item = buildLiveItem('xtream');
streamResolver.resolveLiveDetail.mockResolvedValue({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: 'Xtream Live',
},
epgItems: [buildEpgItem('Xtream Show')],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(
fixture.nativeElement.querySelector('app-epg-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-list')).toBeNull();
});
it('renders shared EPG view for Stalker items and records recent history', async () => {
const item = buildLiveItem('stalker');
streamResolver.resolveLiveDetail.mockResolvedValue({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/stalker.m3u8',
title: 'Stalker Live',
},
epgItems: [buildEpgItem('Stalker Show')],
});
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
await fixture.whenStable();
await component.onChannelSelected(component.channelsForList()[0]);
fixture.detectChanges();
await fixture.whenStable();
expect(recentData.recordLivePlayback).toHaveBeenCalledWith(item);
expect(
fixture.nativeElement.querySelector('app-epg-view')
).not.toBeNull();
});
it('does not restart auto-open while the same live item is still resolving', async () => {
const item = buildLiveItem('xtream');
const pendingDetail = createDeferred<{
epgMode: 'portal';
playback: {
streamUrl: string;
title: string;
};
epgItems: EpgItem[];
}>();
const autoOpenHandledSpy = jest.spyOn(
component.autoOpenHandled,
'emit'
);
streamResolver.resolveLiveDetail.mockReturnValue(pendingDetail.promise);
recentData.recordLivePlayback.mockResolvedValue({
...item,
viewedAt: '2026-03-26T12:00:00.000Z',
});
fixture.componentRef.setInput('items', [item]);
fixture.componentRef.setInput('autoOpenItem', {
contentType: 'live',
sourceType: 'xtream',
playlistId: item.playlistId,
itemId: String(item.xtreamId),
title: item.name,
imageUrl: item.logo,
});
fixture.detectChanges();
await Promise.resolve();
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
expect(component.isSelecting()).toBe(true);
fixture.componentRef.setInput('items', [{ ...item }]);
fixture.detectChanges();
await Promise.resolve();
expect(streamResolver.resolveLiveDetail).toHaveBeenCalledTimes(1);
pendingDetail.resolve({
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: 'Xtream Live',
},
epgItems: [buildEpgItem('Xtream Show')],
});
await fixture.whenStable();
fixture.detectChanges();
expect(component.activeDetail()).toEqual(
expect.objectContaining({
playback: expect.objectContaining({
streamUrl: 'https://example.com/xtream.m3u8',
}),
})
);
expect(autoOpenHandledSpy).toHaveBeenCalledTimes(1);
});
});
function buildLiveItem(
sourceType: 'm3u' | 'xtream' | 'stalker'
): UnifiedCollectionItem {
if (sourceType === 'm3u') {
return {
uid: 'm3u::pl-1::m3u-channel',
name: 'M3U Live',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'pl-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/m3u.m3u8',
channelId: 'm3u-channel',
tvgId: 'm3u-channel',
logo: 'm3u.png',
radio: 'false',
};
}
if (sourceType === 'xtream') {
return {
uid: 'xtream::pl-2::20',
name: 'Xtream Live',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'pl-2',
playlistName: 'Playlist Two',
xtreamId: 20,
tvgId: '20',
logo: 'xtream.png',
};
}
return {
uid: 'stalker::pl-3::30',
name: 'Stalker Live',
contentType: 'live',
sourceType: 'stalker',
playlistId: 'pl-3',
playlistName: 'Playlist Three',
stalkerId: '30',
stalkerCmd: 'ffmpeg http://stalker/30',
tvgId: '30',
logo: 'stalker.png',
};
}
function buildProgram(title: string): EpgProgram {
return {
start: '2026-03-26T11:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
channel: 'test-channel',
title,
desc: `${title} description`,
category: null,
};
}
function buildEpgItem(title: string): EpgItem {
return {
id: '1',
epg_id: '',
title,
description: `${title} description`,
lang: '',
start: '2026-03-26T11:00:00.000Z',
end: '2026-03-26T12:00:00.000Z',
stop: '2026-03-26T12:00:00.000Z',
channel_id: '1',
start_timestamp: '1774522800',
stop_timestamp: '1774526400',
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
@@ -0,0 +1,309 @@
import { TestBed } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
Channel,
M3uRecentlyViewedItem,
Playlist,
PlaylistMeta,
} from 'shared-interfaces';
import { UnifiedCollectionItem } from './unified-collection-item.interface';
import { UnifiedRecentDataService } from './unified-recent-data.service';
describe('UnifiedRecentDataService', () => {
let service: UnifiedRecentDataService;
let store: { select: jest.Mock; dispatch: jest.Mock };
let playlistsService: {
getPlaylistById: jest.Mock;
addM3uRecentlyViewed: jest.Mock;
removeFromM3uRecentlyViewed: jest.Mock;
removeFromPortalRecentlyViewed: jest.Mock;
clearPlaylistRecentlyViewed: jest.Mock;
getAllPlaylists: jest.Mock;
};
let dbService: {
getGlobalRecentlyViewed: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
clearPlaylistRecentItems: jest.Mock;
clearGlobalRecentlyViewed: jest.Mock;
addRecentItem: jest.Mock;
getContentByXtreamId: jest.Mock;
};
const playlistMeta = {
_id: 'm3u-1',
title: 'M3U List',
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/2.m3u8',
url: 'https://example.com/2.m3u8',
title: 'Recent Channel',
channel_id: 'channel-2',
tvg_id: 'recent-two',
category_id: 'live',
added_at: '2026-03-26T11:00:00.000Z',
},
],
} satisfies PlaylistMeta;
const channels: Channel[] = [
{
id: 'channel-1',
name: 'Channel One',
url: 'https://example.com/1.m3u8',
group: { title: 'News' },
tvg: {
id: 'one',
name: 'Channel One',
url: '',
logo: 'one.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'false',
epgParams: '',
},
{
id: 'channel-2',
name: 'Channel Two',
url: 'https://example.com/2.m3u8',
group: { title: 'Sports' },
tvg: {
id: '',
name: 'Channel Two',
url: '',
logo: 'two.png',
rec: '',
},
http: { referrer: '', 'user-agent': '', origin: '' },
radio: 'true',
epgParams: '',
},
];
beforeEach(() => {
store = {
select: jest.fn(() => of([playlistMeta])),
dispatch: jest.fn(),
};
playlistsService = {
getPlaylistById: jest.fn().mockReturnValue(
of({
_id: 'm3u-1',
playlist: { items: channels },
} satisfies Partial<Playlist>)
),
addM3uRecentlyViewed: jest.fn().mockReturnValue(
of({
recentlyViewed: [
{
source: 'm3u',
id: 'https://example.com/1.m3u8',
url: 'https://example.com/1.m3u8',
title: 'Channel One',
category_id: 'live',
added_at: '2026-03-26T12:00:00.000Z',
},
],
})
),
removeFromM3uRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
removeFromPortalRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(of({ recentlyViewed: [] })),
getAllPlaylists: jest.fn().mockReturnValue(of([])),
};
dbService = {
getGlobalRecentlyViewed: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
removeRecentItem: jest.fn().mockResolvedValue(true),
clearPlaylistRecentItems: jest.fn().mockResolvedValue(true),
clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined),
addRecentItem: jest.fn().mockResolvedValue(true),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
UnifiedRecentDataService,
{ provide: Store, useValue: store },
{ provide: PlaylistsService, useValue: playlistsService },
{ provide: DatabaseService, useValue: dbService },
],
});
service = TestBed.inject(UnifiedRecentDataService);
});
it('rehydrates M3U recent items with stream and channel metadata', async () => {
const items = await service.getRecentItems('playlist', 'm3u-1', 'm3u');
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
sourceType: 'm3u',
streamUrl: 'https://example.com/2.m3u8',
channelId: 'channel-2',
name: 'Recent Channel',
tvgId: 'recent-two',
logo: 'two.png',
radio: 'true',
});
});
it('records M3U live playback through playlist recently viewed storage', async () => {
const item = {
uid: 'm3u::m3u-1::https://example.com/1.m3u8',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U List',
streamUrl: 'https://example.com/1.m3u8',
channelId: 'channel-1',
tvgId: 'one',
logo: 'one.png',
} satisfies UnifiedCollectionItem;
const recorded = await service.recordLivePlayback(item);
expect(playlistsService.addM3uRecentlyViewed).toHaveBeenCalledWith(
'm3u-1',
expect.objectContaining<M3uRecentlyViewedItem>({
source: 'm3u',
url: 'https://example.com/1.m3u8',
channel_id: 'channel-1',
tvg_id: 'one',
})
);
expect(store.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: expect.stringContaining('Update Playlist Meta'),
})
);
expect(recorded.viewedAt).toBeTruthy();
});
it('records Xtream playback with a type-aware fallback lookup', async () => {
dbService.getContentByXtreamId.mockResolvedValue({
id: 3867578,
xtream_id: 290,
type: 'live',
title: 'SE: V Film Premiere FHD',
});
const item = {
uid: 'xtream::xtream-1::live:290',
name: 'SE: V Film Premiere FHD',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 290,
} satisfies UnifiedCollectionItem;
const recorded = await service.recordLivePlayback(item);
expect(dbService.getContentByXtreamId).toHaveBeenCalledWith(
290,
'xtream-1',
'live'
);
expect(dbService.addRecentItem).toHaveBeenCalledWith(
3867578,
'xtream-1'
);
expect(recorded).toEqual(
expect.objectContaining({
contentId: 3867578,
viewedAt: expect.any(String),
})
);
});
it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
store.select.mockReturnValue(
of([
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
dbService.getRecentItems.mockResolvedValue([
{
id: 3867578,
category_id: 11,
title: 'SE: V Film Premiere FHD',
poster_url: 'https://example.com/live.png',
xtream_id: 290,
type: 'live',
viewed_at: '2026-04-21T20:42:27.000Z',
},
{
id: 3941697,
category_id: 17,
title: 'Krypton',
poster_url: 'https://example.com/krypton.png',
xtream_id: 290,
type: 'series',
viewed_at: '2026-04-21T20:43:27.000Z',
},
]);
const items = await service.getRecentItems('playlist', 'xtream-1', 'xtream');
expect(items).toEqual(
expect.arrayContaining([
expect.objectContaining({
uid: 'xtream::xtream-1::live:290',
contentType: 'live',
contentId: 3867578,
}),
expect.objectContaining({
uid: 'xtream::xtream-1::series:290',
contentType: 'series',
contentId: 3941697,
}),
])
);
});
it('normalizes SQLite-style Xtream recent timestamps to ISO before exposing them', async () => {
store.select.mockReturnValue(
of([
playlistMeta,
{
_id: 'xtream-1',
title: 'Xtream One',
serverUrl: 'https://example.com',
} satisfies Partial<PlaylistMeta>,
])
);
dbService.getGlobalRecentlyViewed.mockResolvedValue([
{
id: 3940227,
category_id: 18,
title: 'Unter Nachbarn - 2011',
poster_url: 'https://example.com/unter.png',
xtream_id: 815302,
type: 'movie',
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
viewed_at: '2026-04-21 22:49:02',
},
]);
const items = await service.getRecentItems('all');
const xtreamItem = items.find((item) => item.sourceType === 'xtream');
expect(xtreamItem).toEqual(
expect.objectContaining({
name: 'Unter Nachbarn - 2011',
viewedAt: '2026-04-21T22:49:02.000Z',
})
);
});
});
@@ -0,0 +1,511 @@
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import {
PlaylistActions,
selectAllPlaylistsMeta,
} from 'm3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from 'services';
import {
Channel,
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
extractStalkerItemType,
isM3uRecentlyViewedItem,
M3uRecentlyViewedItem,
normalizeStalkerDate,
Playlist,
PlaylistMeta,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildCollectionUid,
buildXtreamCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
import { xtreamContentType } from './collection-helpers';
type PlaylistWithChannels = Playlist & {
readonly playlist?: { readonly items?: Channel[] };
};
@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') {
if (item.contentId == null) {
return;
}
await this.dbService.removeRecentItem(item.contentId, item.playlistId);
return;
}
if (item.sourceType === 'm3u') {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromM3uRecentlyViewed(
item.playlistId,
item.streamUrl ?? item.uid.split('::')[2]
)
);
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
return;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.removeFromPortalRecentlyViewed(
item.playlistId,
item.stalkerId ?? item.uid.split('::')[2]
)
);
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
}
async clearRecentItems(
scope: CollectionScope,
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
await this.dbService.clearPlaylistRecentItems(playlistId);
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(playlistId)
);
this.dispatchPlaylistRecentUpdate(playlistId, updatedPlaylist);
return;
}
await this.dbService.clearGlobalRecentlyViewed();
const playlists = (await firstValueFrom(
this.playlistsService.getAllPlaylists()
)) as Playlist[];
await Promise.all(
playlists
.filter((playlist) => Boolean(playlist.macAddress) || !playlist.serverUrl)
.map(async (playlist) => {
const updatedPlaylist = await firstValueFrom(
this.playlistsService.clearPlaylistRecentlyViewed(
playlist._id
)
);
this.dispatchPlaylistRecentUpdate(
playlist._id,
updatedPlaylist
);
})
);
}
async recordLivePlayback(
item: UnifiedCollectionItem
): Promise<UnifiedCollectionItem> {
const viewedAt = new Date().toISOString();
if (item.sourceType === 'm3u') {
if (!item.streamUrl) {
return item;
}
const updatedPlaylist = await firstValueFrom(
this.playlistsService.addM3uRecentlyViewed(item.playlistId, {
source: 'm3u',
id: item.streamUrl,
url: item.streamUrl,
title: item.name?.trim() || item.streamUrl,
channel_id: item.channelId,
poster_url: item.logo ?? undefined,
tvg_id: item.tvgId,
tvg_name: item.name,
category_id: 'live',
added_at: viewedAt,
} satisfies M3uRecentlyViewedItem)
);
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
return {
...item,
viewedAt,
};
}
if (item.sourceType === 'xtream') {
const contentId =
item.contentId ??
(item.xtreamId != null
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
item.playlistId,
item.contentType
)
)?.id
: null);
if (contentId != null) {
await this.dbService.addRecentItem(contentId, item.playlistId);
}
return {
...item,
contentId: contentId ?? item.contentId,
viewedAt,
};
}
const stalkerItem =
(item.stalkerItem as StalkerPortalItem | undefined) ?? {};
const updatedPlaylist = await firstValueFrom(
this.playlistsService.addPortalRecentlyViewed(item.playlistId, {
...stalkerItem,
id:
item.stalkerId ??
extractStalkerItemId(stalkerItem) ??
item.uid.split('::')[2],
cmd: item.stalkerCmd ?? stalkerItem.cmd,
cover:
stalkerItem.cover ??
item.logo ??
item.posterUrl ??
undefined,
logo:
stalkerItem.logo ??
item.logo ??
item.posterUrl ??
undefined,
title: item.name,
name: stalkerItem.name ?? item.name,
o_name: stalkerItem.o_name ?? item.name,
category_id: 'itv',
added_at: Date.now(),
})
);
this.dispatchPlaylistRecentUpdate(item.playlistId, updatedPlaylist);
return {
...item,
viewedAt,
};
}
private async getAllRecentItems(): Promise<UnifiedCollectionItem[]> {
const [xtream, m3u, stalker] = await Promise.all([
this.getXtreamGlobalRecent(),
this.getM3uGlobalRecent(),
this.getStalkerGlobalRecent(),
]);
return [...xtream, ...m3u, ...stalker].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: buildXtreamCollectionUid(
row.playlist_id,
xtreamContentType(row.type),
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: normalizeStalkerDate(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: buildXtreamCollectionUid(
playlistId,
xtreamContentType(row.type),
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: normalizeStalkerDate(row.viewed_at),
}));
} catch {
return [];
}
}
private async getM3uGlobalRecent(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((playlist) => this.isM3uPlaylist(playlist))) {
results.push(...(await this.extractM3uRecent(meta)));
}
return results;
}
private async getM3uPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(playlistId);
return meta ? this.extractM3uRecent(meta) : [];
}
private async getStalkerGlobalRecent(): Promise<UnifiedCollectionItem[]> {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter((playlist) => Boolean(playlist.macAddress))) {
results.push(...(await this.extractStalkerRecent(meta)));
}
return results;
}
private async getStalkerPlaylistRecent(
playlistId: string
): Promise<UnifiedCollectionItem[]> {
const meta = await this.getPlaylistMeta(playlistId);
return meta ? this.extractStalkerRecent(meta) : [];
}
private async extractM3uRecent(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
const recentItems = this.sortRecentItems(meta.recentlyViewed).filter(
isM3uRecentlyViewedItem
);
if (recentItems.length === 0) {
return [];
}
let playlist: PlaylistWithChannels | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as PlaylistWithChannels | undefined;
} catch {
return [];
}
const channels = playlist?.playlist?.items ?? [];
const channelsByUrl = new Map<string, Channel>(
channels
.filter((channel) => channel.url?.trim())
.map((channel) => [channel.url.trim(), channel] as const)
);
const channelsById = new Map<string, Channel>(
channels
.filter((channel) => channel.id?.trim())
.map((channel) => [channel.id.trim(), channel] as const)
);
const seenUrls = new Set<string>();
return recentItems
.map((recentItem) => {
const channel =
channelsByUrl.get(recentItem.url.trim()) ||
(recentItem.channel_id
? channelsById.get(String(recentItem.channel_id).trim())
: undefined);
const fallbackSourceId =
recentItem.url?.trim() ||
String(recentItem.channel_id ?? '').trim();
if (!channel?.url || seenUrls.has(channel.url)) {
return null;
}
seenUrls.add(channel.url);
return {
uid: buildCollectionUid(
'm3u',
meta._id,
fallbackSourceId || channel.url || channel.id
),
name:
recentItem.title?.trim() ||
channel.name ||
recentItem.tvg_name?.trim() ||
recentItem.url,
contentType: 'live' as const,
sourceType: 'm3u' as const,
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'M3U',
logo: channel.tvg?.logo ?? recentItem.poster_url ?? null,
streamUrl: channel.url,
channelId: channel.id,
radio: channel.radio,
tvgId:
channel.tvg?.id ||
recentItem.tvg_id ||
recentItem.tvg_name ||
channel.tvg?.name ||
channel.name,
categoryId: recentItem.category_id,
viewedAt: normalizeStalkerDate(recentItem.added_at),
} satisfies UnifiedCollectionItem;
})
.filter((item) => item !== null) as UnifiedCollectionItem[];
}
private async extractStalkerRecent(
meta: PlaylistMeta
): Promise<UnifiedCollectionItem[]> {
const recentItems = this.sortRecentItems(meta.recentlyViewed).filter(
(item): item is StalkerPortalItem => !isM3uRecentlyViewedItem(item)
);
if (recentItems.length === 0) {
return [];
}
let playlist: Playlist | undefined;
try {
playlist = (await firstValueFrom(
this.playlistsService.getPlaylistById(meta._id)
)) as Playlist | undefined;
} catch {
return [];
}
return recentItems.map((item, index) => {
const stalkerId = extractStalkerItemId(item, meta._id, index);
const contentType = extractStalkerItemType(item);
const imageUrl = extractStalkerItemPoster(item) || null;
return {
uid: buildCollectionUid('stalker', meta._id, stalkerId),
name: extractStalkerItemTitle(item),
contentType,
sourceType: 'stalker' as const,
playlistId: meta._id,
playlistName: meta.title || meta.filename || 'Stalker Portal',
logo: contentType === 'live' ? imageUrl : null,
posterUrl: contentType !== 'live' ? imageUrl : null,
tvgId: contentType === 'live' ? stalkerId : undefined,
stalkerId,
stalkerCmd: item.cmd,
stalkerPortalUrl: playlist?.portalUrl ?? playlist?.url,
stalkerMacAddress: playlist?.macAddress,
categoryId: item.category_id,
stalkerItem: item,
viewedAt: normalizeStalkerDate(item.added_at),
} satisfies UnifiedCollectionItem;
});
}
private async getPlaylistMeta(
id: string
): Promise<PlaylistMeta | undefined> {
return (await this.getAllMeta()).find((playlist) => playlist._id === id);
}
private async getAllMeta(): Promise<PlaylistMeta[]> {
return firstValueFrom(
this.store
.select(selectAllPlaylistsMeta)
.pipe(map((playlists) => playlists as PlaylistMeta[]))
);
}
private sortRecentItems(
items: PlaylistMeta['recentlyViewed']
): PlaylistRecentlyViewedItem[] {
if (!Array.isArray(items)) {
return [];
}
return [...items].sort(
(a, b) =>
new Date(normalizeStalkerDate(b.added_at ?? '')).getTime() -
new Date(normalizeStalkerDate(a.added_at ?? '')).getTime()
);
}
private dispatchPlaylistRecentUpdate(
playlistId: string,
updatedPlaylist: Partial<Pick<Playlist, 'recentlyViewed'>> | undefined
): void {
this.store.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed: updatedPlaylist?.recentlyViewed ?? [],
updateState: PlaylistUpdateState.UPDATED,
} as PlaylistMeta,
})
);
}
private isM3uPlaylist(
playlist: Pick<PlaylistMeta, 'serverUrl' | 'macAddress'>
): boolean {
return !playlist.serverUrl && !playlist.macAddress;
}
}
@@ -0,0 +1,90 @@
const FAVORITES_SORT_COLLATOR = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
export type FavoritesChannelSortMode =
| 'custom'
| 'name-asc'
| 'name-desc'
| 'date-desc';
export const DEFAULT_FAVORITES_CHANNEL_SORT_MODE: FavoritesChannelSortMode =
'custom';
export const FAVORITES_CHANNEL_SORT_STORAGE_KEY =
'favorites-channel-sort-mode';
export function isFavoritesChannelSortMode(
value: unknown
): value is FavoritesChannelSortMode {
return (
value === 'custom' ||
value === 'name-asc' ||
value === 'name-desc' ||
value === 'date-desc'
);
}
export function restoreFavoritesChannelSortMode(
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY,
fallback: FavoritesChannelSortMode = DEFAULT_FAVORITES_CHANNEL_SORT_MODE
): FavoritesChannelSortMode {
const storedValue = localStorage.getItem(storageKey);
return isFavoritesChannelSortMode(storedValue) ? storedValue : fallback;
}
export function persistFavoritesChannelSortMode(
mode: FavoritesChannelSortMode,
storageKey: string = FAVORITES_CHANNEL_SORT_STORAGE_KEY
): void {
localStorage.setItem(storageKey, mode);
}
export function getFavoritesChannelSortModeTranslationKey(
mode: FavoritesChannelSortMode
): string {
switch (mode) {
case 'name-asc':
return 'WORKSPACE.SORT_NAME_ASC';
case 'name-desc':
return 'WORKSPACE.SORT_NAME_DESC';
case 'date-desc':
return 'WORKSPACE.SORT_DATE_DESC';
default:
return 'WORKSPACE.SORT_CUSTOM';
}
}
export function sortFavoriteChannelItems<T>(
items: readonly T[],
mode: FavoritesChannelSortMode,
accessors: {
getName: (item: T) => string | null | undefined;
getAddedAt?: (item: T) => string | null | undefined;
}
): readonly T[] {
if (mode === 'custom') {
return items;
}
if (mode === 'date-desc') {
const getAddedAt = accessors.getAddedAt;
if (!getAddedAt) {
return items;
}
return [...items].sort((a, b) => {
const timeA = Date.parse(getAddedAt(a) ?? '') || 0;
const timeB = Date.parse(getAddedAt(b) ?? '') || 0;
return timeB - timeA;
});
}
return [...items].sort((a, b) => {
const result = FAVORITES_SORT_COLLATOR.compare(
accessors.getName(a) ?? '',
accessors.getName(b) ?? ''
);
return mode === 'name-asc' ? result : -result;
});
}
@@ -0,0 +1,88 @@
import {
createPortalDebugErrorEvent,
createPortalDebugRequestContext,
createPortalDebugSuccessEvent,
logPortalDebugEvent,
logPortalDebugRequest,
} from './logger';
describe('portal debug logger', () => {
const globalWithNgDevMode = globalThis as typeof globalThis & {
ngDevMode?: boolean;
};
const originalNgDevMode = globalWithNgDevMode.ngDevMode;
beforeEach(() => {
globalWithNgDevMode.ngDevMode = true;
jest.spyOn(console, 'groupCollapsed').mockImplementation(() => undefined);
jest.spyOn(console, 'groupEnd').mockImplementation(() => undefined);
jest.spyOn(console, 'log').mockImplementation(() => undefined);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
globalWithNgDevMode.ngDevMode = originalNgDevMode;
jest.restoreAllMocks();
});
it('does not log when production is enabled', () => {
globalWithNgDevMode.ngDevMode = false;
const context = createPortalDebugRequestContext({
provider: 'xtream',
operation: 'get_account_info',
transport: 'pwa-http',
request: { url: '/xtream' },
});
logPortalDebugRequest(context);
logPortalDebugEvent(createPortalDebugSuccessEvent(context, { ok: true }));
expect(console.groupCollapsed).not.toHaveBeenCalled();
expect(console.log).not.toHaveBeenCalled();
});
it('includes request id and duration in success events', () => {
const nowSpy = jest
.spyOn(performance, 'now')
.mockReturnValueOnce(10)
.mockReturnValueOnce(18);
const context = createPortalDebugRequestContext({
provider: 'stalker',
operation: 'get_ordered_list',
transport: 'electron-renderer',
request: { url: '/stalker' },
});
const event = createPortalDebugSuccessEvent(context, { js: [] });
expect(event.requestId).toBe(context.requestId);
expect(event.durationMs).toBe(8);
expect(event.response).toEqual({ js: [] });
expect(event.status).toBe('success');
nowSpy.mockRestore();
});
it('includes request id and duration in error events', () => {
const nowSpy = jest
.spyOn(performance, 'now')
.mockReturnValueOnce(100)
.mockReturnValueOnce(112.5);
const context = createPortalDebugRequestContext({
provider: 'xtream',
operation: 'get_vod_info',
transport: 'pwa-http',
request: { url: '/xtream' },
});
const event = createPortalDebugErrorEvent(context, new Error('boom'));
expect(event.requestId).toBe(context.requestId);
expect(event.durationMs).toBe(12.5);
expect(event.status).toBe('error');
expect((event.error as Error).message).toBe('boom');
nowSpy.mockRestore();
});
});
+184
View File
@@ -0,0 +1,184 @@
import {
PortalDebugEvent,
PortalDebugProvider,
PortalDebugTransport,
} from 'shared-interfaces';
export interface Logger {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
function isProductionBuild(): boolean {
const globalWithNgDevMode = globalThis as typeof globalThis & {
ngDevMode?: boolean;
};
return globalWithNgDevMode.ngDevMode === false;
}
export function createLogger(scope: string): Logger {
const prefix = `[${scope}]`;
const debugEnabled = !isProductionBuild();
return {
debug: (...args: unknown[]) => {
if (debugEnabled) {
console.debug(prefix, ...args);
}
},
info: (...args: unknown[]) => {
if (debugEnabled) {
console.info(prefix, ...args);
}
},
warn: (...args: unknown[]) => {
console.warn(prefix, ...args);
},
error: (...args: unknown[]) => {
console.error(prefix, ...args);
},
};
}
export interface PortalDebugRequestContext {
requestId: string;
provider: PortalDebugProvider;
operation: string;
transport: PortalDebugTransport;
request: unknown;
startedAt: number;
startedAtIso: string;
}
let portalRequestSequence = 0;
function isPortalDebugEnabled(): boolean {
return !isProductionBuild();
}
function getNow(): number {
return typeof performance !== 'undefined' ? performance.now() : Date.now();
}
function createPortalRequestId(provider: PortalDebugProvider): string {
portalRequestSequence += 1;
return `${provider}-${Date.now().toString(36)}-${portalRequestSequence}`;
}
function formatDuration(durationMs: number): string {
return `${durationMs.toFixed(1)}ms`;
}
function logPortalDebugSection(
label: string,
value: unknown,
method: 'log' | 'error' = 'log'
): void {
if (typeof console[method] === 'function') {
console[method](label, value);
}
}
export function createPortalDebugRequestContext(input: {
provider: PortalDebugProvider;
operation: string;
transport: PortalDebugTransport;
request: unknown;
}): PortalDebugRequestContext {
return {
requestId: createPortalRequestId(input.provider),
provider: input.provider,
operation: input.operation,
transport: input.transport,
request: input.request,
startedAt: getNow(),
startedAtIso: new Date().toISOString(),
};
}
export function createPortalDebugSuccessEvent(
context: PortalDebugRequestContext,
response: unknown
): PortalDebugEvent {
return {
requestId: context.requestId,
provider: context.provider,
operation: context.operation,
transport: context.transport,
request: context.request,
response,
startedAt: context.startedAtIso,
durationMs: getNow() - context.startedAt,
status: 'success',
};
}
export function createPortalDebugErrorEvent(
context: PortalDebugRequestContext,
error: unknown
): PortalDebugEvent {
return {
requestId: context.requestId,
provider: context.provider,
operation: context.operation,
transport: context.transport,
request: context.request,
error,
startedAt: context.startedAtIso,
durationMs: getNow() - context.startedAt,
status: 'error',
};
}
export function logPortalDebugRequest(
context: PortalDebugRequestContext
): void {
if (!isPortalDebugEnabled()) {
return;
}
console.groupCollapsed(
`[PortalDebug:${context.provider}] ${context.operation} request ${context.requestId}`
);
logPortalDebugSection('meta', {
requestId: context.requestId,
provider: context.provider,
operation: context.operation,
transport: context.transport,
startedAt: context.startedAtIso,
});
logPortalDebugSection('request', context.request);
console.groupEnd();
}
export function logPortalDebugEvent(event: PortalDebugEvent): void {
if (!isPortalDebugEnabled()) {
return;
}
const statusLabel = event.status === 'success' ? 'response' : 'error';
console.groupCollapsed(
`[PortalDebug:${event.provider}] ${event.operation} ${statusLabel} ${event.requestId} ${formatDuration(event.durationMs)}`
);
logPortalDebugSection('meta', {
requestId: event.requestId,
provider: event.provider,
operation: event.operation,
transport: event.transport,
startedAt: event.startedAt,
durationMs: event.durationMs,
status: event.status,
});
logPortalDebugSection('request', event.request);
if (event.status === 'success') {
logPortalDebugSection('response', event.response);
} else {
logPortalDebugSection('error', event.error, 'error');
}
console.groupEnd();
}
@@ -0,0 +1,92 @@
import { buildPortalRailLinks } from './portal-rail-links';
describe('buildPortalRailLinks', () => {
it('builds Xtream links with scoped tooltip labels on Electron', () => {
const links = buildPortalRailLinks({
provider: 'xtreams',
playlistId: 'xtream-1',
isElectron: true,
workspace: false,
});
expect(links.primary.map((link) => link.section)).toEqual([
'vod',
'live',
'series',
]);
expect(links.secondary.map((link) => link.section)).toEqual([
'recently-added',
'search',
'downloads',
]);
expect(links.primary[0]?.tooltip).toBe('Movies (this playlist)');
expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)');
});
it('builds workspace Xtream links without downloads on web', () => {
const links = buildPortalRailLinks({
provider: 'xtreams',
playlistId: 'xtream-web',
isElectron: false,
workspace: true,
});
expect(links.primary).toEqual([
{
icon: 'movie',
tooltip: 'Xtream library (this playlist)',
path: ['/workspace', 'xtreams', 'xtream-web'],
exact: true,
section: 'library',
},
]);
expect(links.secondary).toEqual([]);
});
it('builds Stalker links with scoped tooltip labels', () => {
const links = buildPortalRailLinks({
provider: 'stalker',
playlistId: 'portal-1',
isElectron: false,
workspace: false,
});
expect(links.primary.map((link) => link.section)).toEqual([
'vod',
'itv',
'series',
]);
expect(links.secondary.map((link) => link.section)).toEqual(['search']);
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
});
it('builds M3U playlist links with scoped tooltip labels', () => {
const links = buildPortalRailLinks({
provider: 'playlists',
playlistId: 'm3u-1',
isElectron: true,
workspace: true,
});
expect(links.primary).toEqual([
{
icon: 'tv',
tooltip: 'All channels (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'all'],
exact: true,
section: 'all',
},
{
icon: 'folder',
tooltip: 'Groups (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'groups'],
exact: true,
section: 'groups',
},
]);
expect(links.secondary).toEqual([]);
});
});
@@ -0,0 +1,175 @@
export type PortalProvider = 'xtreams' | 'stalker' | 'playlists';
export type PortalRailSection =
| 'all'
| 'downloads'
| 'favorites'
| 'groups'
| 'itv'
| 'library'
| 'live'
| 'recent'
| 'recently-added'
| 'search'
| 'series'
| 'vod';
export interface PortalRailLink {
icon: string;
tooltip: string;
path: (string | number)[];
exact?: boolean;
section?: PortalRailSection;
}
interface BuildPortalRailLinksOptions {
provider: PortalProvider;
playlistId: string;
isElectron: boolean;
workspace: boolean;
}
interface PortalRailLinkGroups {
primary: PortalRailLink[];
secondary: PortalRailLink[];
}
export function buildPortalRailLinks(
options: BuildPortalRailLinksOptions
): PortalRailLinkGroups {
const { provider, playlistId, isElectron, workspace } = options;
const root = workspace
? ['/workspace', provider, playlistId]
: [`/${provider}`, playlistId];
if (provider === 'xtreams') {
const primary: PortalRailLink[] = [];
const secondary: PortalRailLink[] = [];
if (workspace && !isElectron) {
primary.push({
icon: 'movie',
tooltip: 'Xtream library (this playlist)',
path: root,
exact: true,
section: 'library',
});
return { primary, secondary };
}
primary.push(
{
icon: 'movie',
tooltip: 'Movies (this playlist)',
path: [...root, 'vod'],
section: 'vod',
},
{
icon: 'live_tv',
tooltip: 'Live TV (this playlist)',
path: [...root, 'live'],
section: 'live',
},
{
icon: 'tv',
tooltip: 'Series (this playlist)',
path: [...root, 'series'],
section: 'series',
}
);
secondary.push(
{
icon: 'new_releases',
tooltip: 'Recently added (this playlist)',
path: [...root, 'recently-added'],
section: 'recently-added',
},
{
icon: 'search',
tooltip: 'Search (this playlist)',
path: [...root, 'search'],
section: 'search',
}
);
if (isElectron) {
secondary.push({
icon: 'download',
tooltip: 'Downloads (this playlist)',
path: [...root, 'downloads'],
section: 'downloads',
});
}
return { primary, secondary };
}
if (provider === 'stalker') {
const primary: PortalRailLink[] = [
{
icon: 'movie',
tooltip: 'Movies (this playlist)',
path: [...root, 'vod'],
section: 'vod',
},
{
icon: 'live_tv',
tooltip: 'Live TV (this playlist)',
path: [...root, 'itv'],
section: 'itv',
},
{
icon: 'tv',
tooltip: 'Series (this playlist)',
path: [...root, 'series'],
section: 'series',
},
];
const secondary: PortalRailLink[] = [
{
icon: 'search',
tooltip: 'Search (this playlist)',
path: [...root, 'search'],
section: 'search',
},
];
if (isElectron) {
secondary.push({
icon: 'download',
tooltip: 'Downloads (this playlist)',
path: [...root, 'downloads'],
section: 'downloads',
});
}
return { primary, secondary };
}
if (provider === 'playlists') {
const primary: PortalRailLink[] = [
{
icon: 'tv',
tooltip: 'All channels (this playlist)',
path: [...root, 'all'],
exact: true,
section: 'all',
},
{
icon: 'folder',
tooltip: 'Groups (this playlist)',
path: [...root, 'groups'],
exact: true,
section: 'groups',
},
];
return {
primary,
secondary: [],
};
}
return { primary: [], secondary: [] };
}
@@ -0,0 +1,336 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import {
extractPortalPlaylistId,
extractPortalSection,
isWorkspaceLayoutRoute,
queryParamSignal,
routeParamSignal,
resolveCurrentPortalPlaylistId,
resolveCurrentPortalSection,
} from './portal-route.utils';
describe('portal-route.utils', () => {
it('detects workspace layout from route data', () => {
const route = {
snapshot: {
data: {
layout: 'workspace',
},
},
} as unknown as ActivatedRoute;
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'
);
expect(
extractPortalSection('/workspace/xtreams/123/recent', 'xtreams')
).toBe('recent');
});
it('extracts Stalker sections from workspace and local URLs', () => {
expect(extractPortalSection('/stalker/321/series', 'stalker')).toBe(
'series'
);
expect(
extractPortalSection('/workspace/stalker/321/favorites', 'stalker')
).toBe('favorites');
});
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: '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(
routeWithDescendants,
'/workspace/stalker/123/search',
'stalker'
)
).toBe('vod');
expect(
resolveCurrentPortalSection(
routeWithoutChild,
'/workspace/stalker/123/search',
'stalker'
)
).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({});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const queryParams$ = new BehaviorSubject(
convertToParamMap({
q: ' Matrix ',
refresh: '42',
})
);
const route = {
snapshot: {
data: {},
queryParamMap: convertToParamMap({
q: ' Matrix ',
refresh: '42',
}),
},
queryParamMap: queryParams$.asObservable(),
} as unknown as ActivatedRoute;
const searchTerm = runInInjectionContext(childInjector, () =>
queryParamSignal(route, 'q', (value) =>
(value ?? '').trim().toLowerCase()
)
);
const refreshToken = runInInjectionContext(childInjector, () =>
queryParamSignal(route, 'refresh', (value) => value ?? '')
);
expect(searchTerm()).toBe('matrix');
expect(refreshToken()).toBe('42');
queryParams$.next(
convertToParamMap({
q: 'Alien',
refresh: '84',
})
);
expect(searchTerm()).toBe('alien');
expect(refreshToken()).toBe('84');
childInjector.destroy();
});
it('creates route-param signals that react to current route param changes', () => {
TestBed.configureTestingModule({});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const paramMap$ = new BehaviorSubject(
convertToParamMap({
id: 'playlist-1',
})
);
const route = {
snapshot: {
data: {},
paramMap: convertToParamMap({
id: 'playlist-1',
}),
params: {
id: 'playlist-1',
},
},
paramMap: paramMap$.asObservable(),
} as unknown as ActivatedRoute;
const playlistId = runInInjectionContext(childInjector, () =>
routeParamSignal(route, 'id', (value) => value ?? '')
);
expect(playlistId()).toBe('playlist-1');
route.snapshot.paramMap = convertToParamMap({
id: 'playlist-2',
});
route.snapshot.params = {
id: 'playlist-2',
};
paramMap$.next(
convertToParamMap({
id: 'playlist-2',
})
);
expect(playlistId()).toBe('playlist-2');
childInjector.destroy();
});
it('creates route-param signals that react to ancestor route param changes', () => {
TestBed.configureTestingModule({});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const parentParamMap$ = new BehaviorSubject(
convertToParamMap({
id: 'playlist-1',
})
);
const childParamMap$ = new BehaviorSubject(convertToParamMap({}));
const parentRoute = {
snapshot: {
data: {},
paramMap: convertToParamMap({
id: 'playlist-1',
}),
params: {
id: 'playlist-1',
},
},
paramMap: parentParamMap$.asObservable(),
} as ActivatedRoute;
const route = {
snapshot: {
data: {},
paramMap: convertToParamMap({}),
params: {},
},
paramMap: childParamMap$.asObservable(),
pathFromRoot: [] as ActivatedRoute[],
} as ActivatedRoute & {
pathFromRoot: ActivatedRoute[];
};
route.pathFromRoot = [parentRoute, route];
const playlistId = runInInjectionContext(childInjector, () =>
routeParamSignal(route, 'id', (value) => value ?? '')
);
expect(playlistId()).toBe('playlist-1');
(parentRoute.snapshot as ActivatedRoute['snapshot']).paramMap =
convertToParamMap({
id: 'playlist-2',
});
(parentRoute.snapshot as ActivatedRoute['snapshot']).params = {
id: 'playlist-2',
};
parentParamMap$.next(
convertToParamMap({
id: 'playlist-2',
})
);
expect(playlistId()).toBe('playlist-2');
childInjector.destroy();
});
});
@@ -0,0 +1,161 @@
import { Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { combineLatest, map, of } from 'rxjs';
import {
PortalProvider,
PortalRailSection,
} from './portal-rail-links';
type QueryParamNormalizer<T> = (value: string | null) => T;
type RouteParamNormalizer<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 {
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>(
route: ActivatedRoute,
key: string,
normalizer?: QueryParamNormalizer<T>
): Signal<T> {
const normalize =
normalizer ??
(((
value: string | null
) => (value ?? '') as T) as QueryParamNormalizer<T>);
return toSignal(
route.queryParamMap.pipe(map((params) => normalize(params.get(key)))),
{
initialValue: normalize(route.snapshot.queryParamMap.get(key)),
}
);
}
export function routeParamSignal<T = string>(
route: ActivatedRoute,
key: string,
normalizer?: RouteParamNormalizer<T>
): Signal<T> {
const normalize =
normalizer ??
(((
value: string | null
) => (value ?? '') as T) as RouteParamNormalizer<T>);
const routeChain =
Array.isArray(route.pathFromRoot) && route.pathFromRoot.length > 0
? route.pathFromRoot
: [route];
return toSignal(
combineLatest(
routeChain.map((currentRoute) =>
currentRoute.paramMap ??
of(
currentRoute.snapshot.paramMap ??
convertToParamMap(currentRoute.snapshot.params ?? {})
)
)
).pipe(map(() => normalize(resolveCurrentRouteParam(route, key)))),
{
initialValue: normalize(resolveCurrentRouteParam(route, key)),
}
);
}
export function extractPortalSection(
url: string,
provider: PortalProvider
): PortalRailSection | null {
const match = url.match(
new RegExp(`^/(?:workspace/)?${provider}/[^/?]+/([^/?]+)`)
);
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 resolved = resolveCurrentRouteParam(route, 'id');
if (resolved) {
return resolved;
}
return extractPortalPlaylistId(routerUrl, provider);
}
function resolveCurrentRouteParam(
route: ActivatedRoute,
key: string
): 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 value =
currentRoute.snapshot.paramMap?.get(key) ??
currentRoute.snapshot.params?.[key];
if (value) {
return value;
}
}
return null;
}
export function resolveCurrentPortalSection(
route: ActivatedRoute,
routerUrl: string,
provider: PortalProvider
): PortalRailSection | 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;
}
currentRoute = currentRoute.firstChild;
}
return extractPortalSection(routerUrl, provider);
}
@@ -0,0 +1,248 @@
import {
buildCollectionViewState,
buildStalkerStateItem,
buildXtreamItemLink,
COLLECTION_VIEW_STATE_KEY,
getGlobalFavoriteNavigation,
getCollectionViewState,
getOpenCollectionDetailItemState,
getOpenLiveCollectionItemState,
getOpenStalkerItemState,
getRecentItemNavigation,
getStalkerReturnToState,
getUnifiedCollectionNavigation,
OPEN_COLLECTION_DETAIL_STATE_KEY,
matchesOpenLiveCollectionItem,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
OPEN_STALKER_ITEM_STATE_KEY,
STALKER_RETURN_TO_STATE_KEY,
} 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('normalizes collection view state for history persistence', () => {
expect(
buildCollectionViewState({
selectedContentType: 'movie',
scope: 'all',
})
).toEqual({
selectedContentType: 'movie',
scope: 'all',
});
expect(
getCollectionViewState({
[COLLECTION_VIEW_STATE_KEY]: {
selectedContentType: 'series',
scope: 'playlist',
},
})
).toEqual({
selectedContentType: 'series',
scope: 'playlist',
});
});
it('routes live M3U favorites to the playlist favorites collection with auto-open state', () => {
const navigation = 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',
});
expect(navigation).toEqual({
link: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
state: {
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
imageUrl: 'https://example.com/logo.png',
},
},
});
expect(getOpenLiveCollectionItemState(navigation.state)).toEqual(
navigation.state?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]
);
});
it('routes Stalker movie favorites into the global favorites page with inline detail state', () => {
const navigation = getGlobalFavoriteNavigation({
id: 'movie-7',
title: 'Movie Seven',
type: 'movie',
playlist_id: 'stalker-1',
category_id: '17',
xtream_id: 'movie-7',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
added_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'global-favorites'],
state: {
openCollectionDetailItem: {
item: expect.objectContaining({
uid: 'stalker::stalker-1::movie-7',
name: 'Movie Seven',
contentType: 'movie',
sourceType: 'stalker',
playlistId: 'stalker-1',
categoryId: '17',
stalkerId: 'movie-7',
}),
},
},
});
expect(getOpenCollectionDetailItemState(navigation.state)).toEqual(
navigation.state?.[OPEN_COLLECTION_DETAIL_STATE_KEY]
);
});
it('routes Stalker live recents to the collection route with auto-open state', () => {
const navigation = getRecentItemNavigation({
id: 'stalker-live-1',
title: 'Live One',
type: 'live',
playlist_id: 'stalker-1',
category_id: 'itv',
xtream_id: 'stalker-live-1',
source: 'stalker',
poster_url: 'https://example.com/poster.png',
viewed_at: '2026-03-01T00:00:00.000Z',
});
expect(navigation).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'recent'],
state: {
openLiveCollectionItem: {
contentType: 'live',
sourceType: 'stalker',
playlistId: 'stalker-1',
itemId: 'stalker-live-1',
title: 'Live One',
imageUrl: 'https://example.com/poster.png',
},
},
});
});
it('matches collection live state against multiple live item identifiers', () => {
expect(
matchesOpenLiveCollectionItem(
{
uid: 'm3u::m3u-1::https://example.com/live.m3u8',
name: 'Channel One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
playlistName: 'M3U Playlist',
streamUrl: 'https://example.com/live.m3u8',
channelId: 'channel-1',
},
{
contentType: 'live',
sourceType: 'm3u',
playlistId: 'm3u-1',
itemId: 'channel-1',
title: 'Channel One',
}
)
).toBe(true);
});
it('builds unified Stalker grid navigation with detail state', () => {
expect(
getUnifiedCollectionNavigation({
uid: 'stalker::stalker-1::series-9',
name: 'Series Nine',
contentType: 'series',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker Playlist',
stalkerId: 'series-9',
categoryId: '44',
posterUrl: 'https://example.com/poster.png',
})
).toEqual({
link: ['/workspace', 'stalker', 'stalker-1', 'series', '44'],
state: {
openStalkerItem: expect.objectContaining({
category_id: '44',
id: 'series-9',
title: 'Series Nine',
}),
},
});
});
it('preserves concrete Stalker category ids when building detail state', () => {
expect(
buildStalkerStateItem(
{
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
},
{
id: '77',
title: 'Concrete Category Movie',
type: 'movie',
}
)
).toEqual(
expect.objectContaining({
id: '77',
category_id: '101',
title: 'Concrete Category Movie',
})
);
});
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,736 @@
import {
extractStalkerItemId,
PortalActivityType,
PortalFavoriteItem,
PortalRecentItem,
StalkerPortalItem,
} from 'shared-interfaces';
import {
buildCollectionUid,
buildXtreamCollectionUid,
CollectionContentType,
CollectionSourceType,
UnifiedCollectionItem,
} from '../collection/unified-collection-item.interface';
import { CollectionScope } from '../collection/scope-toggle.service';
export interface WorkspaceNavigationTarget {
link: string[];
state?: Record<string, unknown>;
}
export const OPEN_LIVE_COLLECTION_ITEM_STATE_KEY = 'openLiveCollectionItem';
export const OPEN_COLLECTION_DETAIL_STATE_KEY = 'openCollectionDetailItem';
export const OPEN_STALKER_ITEM_STATE_KEY = 'openStalkerItem';
export const STALKER_RETURN_TO_STATE_KEY = 'stalkerReturnTo';
export const COLLECTION_VIEW_STATE_KEY = 'collectionViewState';
export interface OpenLiveCollectionItemState {
contentType: 'live';
sourceType: CollectionSourceType;
playlistId: string;
itemId: string;
title?: string;
imageUrl?: string | null;
}
export interface OpenCollectionDetailItemState {
item: UnifiedCollectionItem;
}
export interface CollectionViewState {
selectedContentType?: CollectionContentType;
scope?: CollectionScope;
}
export function getRecentItemNavigation(
item: PortalRecentItem
): WorkspaceNavigationTarget {
if (item.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'recent',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
const collectionItem = buildDashboardCollectionDetailItem(item);
if (collectionItem) {
return buildGlobalCollectionDetailNavigationTarget(
'recent',
collectionItem
);
}
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
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.type === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.source,
playlistId: item.playlist_id,
itemId: item.xtream_id ?? item.id,
title: item.title,
imageUrl: item.poster_url,
});
}
const collectionItem = buildDashboardCollectionDetailItem(item);
if (collectionItem) {
return buildGlobalCollectionDetailNavigationTarget(
'favorites',
collectionItem
);
}
if (item.source === 'stalker') {
return buildStalkerDetailNavigationTarget({
playlistId: item.playlist_id,
type: item.type,
categoryId: item.category_id,
item: buildStalkerStateItem(item.stalker_item, item),
returnTo: '/workspace/dashboard',
});
}
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 buildLiveCollectionNavigationTarget(params: {
mode: 'favorites' | 'recent';
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): WorkspaceNavigationTarget {
return {
link: buildCollectionRoute(
params.sourceType,
params.playlistId,
params.mode
),
state: {
[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY]:
buildOpenLiveCollectionItemState(params),
},
};
}
export function buildGlobalCollectionDetailNavigationTarget(
mode: 'favorites' | 'recent',
item: UnifiedCollectionItem
): WorkspaceNavigationTarget {
return {
link: [
'/workspace',
mode === 'favorites' ? 'global-favorites' : 'global-recent',
],
state: {
[OPEN_COLLECTION_DETAIL_STATE_KEY]:
buildOpenCollectionDetailItemState(item),
},
};
}
export function buildCollectionViewState(params: {
selectedContentType?: CollectionContentType | null;
scope?: CollectionScope | null;
}): CollectionViewState | null {
const selectedContentType = normalizeCollectionContentType(
params.selectedContentType
);
const scope = normalizeCollectionScope(params.scope);
if (!selectedContentType && !scope) {
return null;
}
return {
...(selectedContentType ? { selectedContentType } : {}),
...(scope ? { scope } : {}),
};
}
export function getCollectionViewState(
state: unknown
): CollectionViewState | null {
const record = toStateRecord(state);
const candidate = toStateRecord(record?.[COLLECTION_VIEW_STATE_KEY]);
return buildCollectionViewState({
selectedContentType: candidate?.[
'selectedContentType'
] as CollectionContentType | null,
scope: candidate?.['scope'] as CollectionScope | null,
});
}
export function buildOpenCollectionDetailItemState(
item: UnifiedCollectionItem
): OpenCollectionDetailItemState {
return {
item: normalizeCollectionDetailItem(item),
};
}
export function getOpenCollectionDetailItemState(
state: unknown
): OpenCollectionDetailItemState | null {
const record = toStateRecord(state);
const candidate = toStateRecord(record?.[OPEN_COLLECTION_DETAIL_STATE_KEY]);
const item = normalizeCollectionDetailItem(candidate?.['item']);
return item ? { item } : null;
}
export function buildOpenLiveCollectionItemState(params: {
sourceType: CollectionSourceType;
playlistId: string;
itemId?: string | number | null;
title?: string;
imageUrl?: string | null;
}): OpenLiveCollectionItemState {
return {
contentType: 'live',
sourceType: params.sourceType,
playlistId: params.playlistId,
itemId: toPathSegment(params.itemId),
title: params.title,
imageUrl: params.imageUrl,
};
}
export function getOpenLiveCollectionItemState(
state: unknown
): OpenLiveCollectionItemState | null {
const record = toStateRecord(state);
const candidate = record?.[OPEN_LIVE_COLLECTION_ITEM_STATE_KEY];
if (!candidate || typeof candidate !== 'object') {
return null;
}
const sourceType = toPathSegment(
(candidate as Record<string, unknown>)['sourceType']
) as CollectionSourceType;
const playlistId = toPathSegment(
(candidate as Record<string, unknown>)['playlistId']
);
const itemId = toPathSegment(
(candidate as Record<string, unknown>)['itemId']
);
if (
!playlistId ||
!itemId ||
(sourceType !== 'm3u' &&
sourceType !== 'xtream' &&
sourceType !== 'stalker')
) {
return null;
}
return {
contentType: 'live',
sourceType,
playlistId,
itemId,
title: toOptionalPathSegment(
(candidate as Record<string, unknown>)['title']
),
imageUrl: toOptionalPathSegment(
(candidate as Record<string, unknown>)['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') {
if (item.contentType === 'live') {
return buildLiveCollectionNavigationTarget({
mode: 'favorites',
sourceType: item.sourceType,
playlistId: item.playlistId,
itemId: item.stalkerId,
title: item.name,
imageUrl: item.logo ?? item.posterUrl ?? null,
});
}
return buildStalkerDetailNavigationTarget({
playlistId: item.playlistId,
type: item.contentType,
categoryId: item.categoryId,
item: buildStalkerStateItem(
item.stalkerItem as StalkerPortalItem | undefined,
{
id: item.stalkerId ?? getLastSegment(item.uid, '::') ?? '',
title: item.name,
type: item.contentType,
category_id: item.categoryId,
poster_url: item.posterUrl ?? item.logo ?? undefined,
}
),
});
}
return null;
}
export function buildStalkerDetailNavigationTarget(params: {
playlistId: string;
type: Exclude<PortalActivityType, 'live'>;
categoryId?: string | number | null;
item: Record<string, unknown>;
returnTo?: string | string[] | null;
}): WorkspaceNavigationTarget {
const section = params.type === 'movie' ? 'vod' : 'series';
const normalizedCategoryId = toPathSegment(
params.categoryId ?? toStalkerCategoryId(params.type)
);
const link = normalizedCategoryId
? [
'/workspace',
'stalker',
params.playlistId,
section,
normalizedCategoryId,
]
: ['/workspace', 'stalker', params.playlistId, section];
const state: Record<string, unknown> = {
[OPEN_STALKER_ITEM_STATE_KEY]: params.item,
};
const returnTo = normalizeReturnToState(params.returnTo);
if (returnTo) {
state[STALKER_RETURN_TO_STATE_KEY] = returnTo;
}
return {
link,
state,
};
}
export function getOpenStalkerItemState(
state: unknown
): Record<string, unknown> | null {
const record = toStateRecord(state);
const candidate =
record?.[OPEN_STALKER_ITEM_STATE_KEY] ??
record?.['openFavoriteItem'] ??
record?.['openRecentItem'];
return candidate && typeof candidate === 'object'
? (candidate as Record<string, unknown>)
: null;
}
export function getStalkerReturnToState(state: unknown): string | null {
const record = toStateRecord(state);
const candidate = record?.[STALKER_RETURN_TO_STATE_KEY];
return typeof candidate === 'string' && candidate.trim().length > 0
? candidate.trim()
: null;
}
export function clearNavigationStateKeys(keys: string[]): void {
try {
const state = toStateRecord(window.history.state);
if (!state) {
return;
}
const nextState = { ...state };
let changed = false;
keys.forEach((key) => {
if (key in nextState) {
delete nextState[key];
changed = true;
}
});
if (changed) {
window.history.replaceState(nextState, document.title);
}
} catch {
// no-op
}
}
export function matchesOpenLiveCollectionItem(
item: UnifiedCollectionItem,
target: OpenLiveCollectionItemState
): boolean {
if (
item.contentType !== 'live' ||
item.sourceType !== target.sourceType ||
item.playlistId !== target.playlistId
) {
return false;
}
const targetId = toPathSegment(target.itemId);
const sourceItemId = getLastSegment(item.uid, '::') ?? '';
const candidates = [
sourceItemId,
item.streamUrl,
item.channelId,
item.xtreamId,
item.stalkerId,
item.contentId,
]
.map((value) => toPathSegment(value))
.filter(Boolean);
if (candidates.includes(targetId)) {
return true;
}
const normalizedTargetTitle = target.title?.trim().toLowerCase();
return Boolean(
normalizedTargetTitle &&
item.name.trim().toLowerCase() === normalizedTargetTitle
);
}
function getLastSegment(value: string, separator: string): string | undefined {
const segments = value.split(separator);
return segments.length > 0 ? segments[segments.length - 1] : undefined;
}
function normalizeReturnToState(
value: string | string[] | null | undefined
): string | null {
if (Array.isArray(value)) {
const normalized = value.join('/');
return normalized.trim() ? normalized : null;
}
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
return null;
}
function normalizeCollectionContentType(
value: CollectionContentType | null | undefined
): CollectionContentType | null {
return value === 'live' || value === 'movie' || value === 'series'
? value
: null;
}
function normalizeCollectionScope(
value: CollectionScope | null | undefined
): CollectionScope | null {
return value === 'playlist' || value === 'all' ? value : null;
}
function buildDashboardCollectionDetailItem(
item: PortalFavoriteItem | PortalRecentItem
): UnifiedCollectionItem | null {
if (
item.type === 'live' ||
(item.source !== 'xtream' && item.source !== 'stalker')
) {
return null;
}
if (item.source === 'xtream') {
return normalizeCollectionDetailItem({
uid: buildXtreamCollectionUid(
item.playlist_id,
item.type,
item.xtream_id
),
name: item.title,
contentType: item.type,
sourceType: 'xtream',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? '',
posterUrl: item.poster_url ?? null,
xtreamId: toOptionalNumber(item.xtream_id),
contentId: toOptionalNumber(item.id),
categoryId: item.category_id,
addedAt: 'added_at' in item ? item.added_at : undefined,
viewedAt: 'viewed_at' in item ? item.viewed_at : undefined,
});
}
const stalkerItem = buildStalkerStateItem(item.stalker_item, {
id: item.id,
title: item.title,
type: item.type,
category_id: item.category_id,
poster_url: item.poster_url,
});
const stalkerId = extractStalkerItemId(stalkerItem, item.playlist_id);
return normalizeCollectionDetailItem({
uid: buildCollectionUid('stalker', item.playlist_id, stalkerId),
name: item.title,
contentType: item.type,
sourceType: 'stalker',
playlistId: item.playlist_id,
playlistName: item.playlist_name ?? '',
posterUrl: item.poster_url ?? null,
stalkerId,
categoryId: item.category_id,
stalkerItem,
addedAt: 'added_at' in item ? item.added_at : undefined,
viewedAt: 'viewed_at' in item ? item.viewed_at : undefined,
});
}
export function buildStalkerStateItem(
rawItem: StalkerPortalItem | undefined,
fallback: {
id: string | number;
title: string;
type: PortalActivityType;
category_id?: string | number | null;
poster_url?: string;
}
): Record<string, unknown> {
const normalizedCategory =
toOptionalPathSegment(rawItem?.category_id) ??
toOptionalPathSegment(fallback.category_id) ??
toStalkerCategoryId(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();
}
function toOptionalPathSegment(value: unknown): string | undefined {
const normalized = toPathSegment(value);
return normalized ? normalized : undefined;
}
function toOptionalNumber(value: unknown): number | undefined {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return undefined;
}
const normalized = Number(rawValue);
return Number.isFinite(normalized) ? normalized : undefined;
}
function normalizeCollectionDetailItem(
candidate: unknown
): UnifiedCollectionItem | null {
const record = toStateRecord(candidate);
if (!record) {
return null;
}
const contentType = toPathSegment(record['contentType']);
const sourceType = toPathSegment(
record['sourceType']
) as CollectionSourceType;
const uid = toPathSegment(record['uid']);
const name = toPathSegment(record['name']);
const playlistId = toPathSegment(record['playlistId']);
if (
!uid ||
!name ||
!playlistId ||
(contentType !== 'movie' && contentType !== 'series') ||
(sourceType !== 'xtream' && sourceType !== 'stalker')
) {
return null;
}
return {
uid,
name,
contentType,
sourceType,
playlistId,
playlistName: toPathSegment(record['playlistName']),
posterUrl:
toOptionalPathSegment(record['posterUrl']) ??
toOptionalPathSegment(record['logo']) ??
null,
logo: toOptionalPathSegment(record['logo']) ?? null,
xtreamId: toOptionalNumber(record['xtreamId']),
contentId: toOptionalNumber(record['contentId']),
stalkerId: toOptionalPathSegment(record['stalkerId']),
stalkerCmd: toOptionalPathSegment(record['stalkerCmd']),
stalkerPortalUrl: toOptionalPathSegment(record['stalkerPortalUrl']),
stalkerMacAddress: toOptionalPathSegment(record['stalkerMacAddress']),
categoryId: toOptionalPathSegment(record['categoryId']),
rating: toOptionalPathSegment(record['rating']),
addedAt: toOptionalPathSegment(record['addedAt']),
viewedAt: toOptionalPathSegment(record['viewedAt']),
position: toOptionalNumber(record['position']),
stalkerItem: toStateRecord(record['stalkerItem']) ?? undefined,
};
}
function buildCollectionRoute(
sourceType: CollectionSourceType,
playlistId: string,
mode: 'favorites' | 'recent'
): string[] {
if (sourceType === 'xtream') {
return ['/workspace', 'xtreams', playlistId, mode];
}
if (sourceType === 'stalker') {
return ['/workspace', 'stalker', playlistId, mode];
}
return ['/workspace', 'playlists', playlistId, mode];
}
function toStateRecord(state: unknown): Record<string, unknown> | null {
return state && typeof state === 'object'
? (state as Record<string, unknown>)
: null;
}
@@ -0,0 +1,6 @@
import { InjectionToken, Type } from '@angular/core';
export const PORTAL_CATALOG_DETAIL_COMPONENT =
new InjectionToken<Type<unknown> | null>('PORTAL_CATALOG_DETAIL_COMPONENT', {
factory: () => null,
});
@@ -0,0 +1,94 @@
import { InjectionToken, Signal } from '@angular/core';
import { ResolvedPortalPlayback } from 'shared-interfaces';
export type PortalCatalogProvider = 'xtream' | 'stalker';
export type PortalCatalogSortMode =
| 'date-desc'
| 'date-asc'
| 'name-asc'
| 'name-desc';
export interface PortalCatalogPlaylistMeta {
id: string;
title?: string;
portalUrl?: string;
macAddress?: string;
userAgent?: string;
referer?: string;
origin?: string;
}
export interface PortalCatalogItemProgress {
progress?: number;
isWatched?: boolean;
hasSeriesProgress?: boolean;
}
export interface PortalCatalogFacade<
TCategory = unknown,
TItem = unknown,
TSelectedItem = unknown,
> {
readonly provider: PortalCatalogProvider;
readonly pageSizeOptions: readonly number[];
readonly contentType: Signal<string | null | undefined>;
readonly limit: Signal<number>;
readonly pageIndex: Signal<number>;
readonly selectedCategory: Signal<TCategory | null | undefined>;
readonly paginatedContent: Signal<readonly TItem[] | undefined>;
readonly selectedItem: Signal<TSelectedItem | null | undefined>;
readonly totalPages: Signal<number>;
readonly isPaginatedContentLoading: Signal<boolean>;
readonly selectedCategoryTitle: Signal<string>;
readonly categoryItemCount: Signal<number>;
readonly contentSortMode: Signal<PortalCatalogSortMode | null>;
readonly playlist: Signal<PortalCatalogPlaylistMeta | null>;
initialize(categoryId?: string | null): void;
setSearchQuery?(query: string): void;
clearSelectedItem(): void;
setPage(page: number): void;
setLimit(limit: number): void;
setContentSortMode(mode: PortalCatalogSortMode): void;
selectItem(item: TItem): string[] | null;
getItemProgress(item: TItem): PortalCatalogItemProgress;
}
export interface StalkerPortalCatalogFacade<
TCategory = unknown,
TItem = unknown,
TSelectedItem = unknown,
> extends PortalCatalogFacade<TCategory, TItem, TSelectedItem> {
readonly provider: 'stalker';
createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string
): Promise<void>;
addToFavorites(item: Record<string, unknown>, onDone?: () => void): void;
removeFromFavorites(favoriteId: string, onDone?: () => void): void;
fetchMovieFileId(itemId: string): Promise<string | null>;
fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string
): Promise<string>;
resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
startTime?: number
): Promise<ResolvedPortalPlayback>;
}
export const PORTAL_CATALOG_FACADE = new InjectionToken<PortalCatalogFacade>(
'PORTAL_CATALOG_FACADE'
);
export function isStalkerPortalCatalogFacade(
facade: PortalCatalogFacade
): facade is StalkerPortalCatalogFacade {
return facade.provider === 'stalker';
}
@@ -0,0 +1,35 @@
import { Injectable, signal } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
/**
* 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 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 PortalCollectionContextService {
/** Category list managed by the active routed component (Favorites / RecentlyViewed). */
readonly categories = signal<XtreamCategory[]>([]);
/** Currently selected category id — shared between panel and content. */
readonly selectedCategoryId = signal<string>('all');
/** Called by the context panel when the user clicks a category. */
setCategoryId(id: string): void {
this.selectedCategoryId.set(id);
}
/** Called by the routed component to push its category list into the panel. */
setCategories(categories: XtreamCategory[]): void {
this.categories.set(categories);
}
/** Reset to defaults when navigating away from favorites/recent sections. */
reset(): void {
this.categories.set([]);
this.selectedCategoryId.set('all');
}
}
@@ -0,0 +1,129 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { createPortalCollectionContext } from './portal-collection-context';
import { PortalCollectionContextService } from './portal-collection-context.service';
describe('portal-collection-context', () => {
it('syncs categories into the shared collection context', () => {
TestBed.configureTestingModule({
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 2,
parent_id: 0,
},
]);
const bridge = runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
TestBed.flushEffects();
bridge.setCategoryId('all');
expect(ctx.categories()).toEqual(categories());
expect(ctx.selectedCategoryId()).toBe('all');
childInjector.destroy();
});
it('does not mutate the shared context when disabled', () => {
TestBed.configureTestingModule({
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
category_id: 'movie',
category_name: 'Movies',
count: 1,
parent_id: 0,
},
]);
ctx.setCategories([
{
id: 99,
category_id: 'all',
category_name: 'Existing',
count: 5,
parent_id: 0,
},
]);
ctx.setCategoryId('series');
const bridge = runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
enabled: () => false,
})
);
TestBed.flushEffects();
bridge.setCategoryId('movie');
expect(ctx.categories()[0]?.category_name).toBe('Existing');
expect(ctx.selectedCategoryId()).toBe('series');
childInjector.destroy();
expect(ctx.categories()[0]?.category_name).toBe('Existing');
expect(ctx.selectedCategoryId()).toBe('series');
});
it('resets managed context state on destroy', () => {
TestBed.configureTestingModule({
providers: [PortalCollectionContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(PortalCollectionContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 3,
parent_id: 0,
},
]);
runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
TestBed.flushEffects();
ctx.setCategoryId('movie');
childInjector.destroy();
TestBed.flushEffects();
expect(ctx.categories()).toEqual([]);
expect(ctx.selectedCategoryId()).toBe('all');
});
});
@@ -0,0 +1,46 @@
import { DestroyRef, Signal, effect, inject } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { PortalCollectionContextService } from './portal-collection-context.service';
export interface PortalCollectionContextBridge {
readonly selectedCategoryId: Signal<string>;
setCategoryId(id: string): void;
}
interface CreatePortalCollectionContextOptions {
ctx: PortalCollectionContextService;
categories: () => XtreamCategory[];
enabled?: () => boolean;
}
export function createPortalCollectionContext(
options: CreatePortalCollectionContextOptions
): PortalCollectionContextBridge {
const destroyRef = inject(DestroyRef);
const isEnabled = options.enabled ?? (() => true);
let managesContext = false;
effect(() => {
if (!isEnabled()) {
return;
}
managesContext = true;
options.ctx.setCategories(options.categories());
});
destroyRef.onDestroy(() => {
if (managesContext && isEnabled()) {
options.ctx.reset();
}
});
return {
selectedCategoryId: options.ctx.selectedCategoryId,
setCategoryId(id: string): void {
if (isEnabled()) {
options.ctx.setCategoryId(id);
}
},
};
}
@@ -0,0 +1,138 @@
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from './portal-collection-items';
describe('portal-collection-items', () => {
it('builds 4-category collections when live content is included', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 10,
movie: 4,
live: 3,
series: 3,
},
includeLive: true,
});
expect(categories.map((category) => category.category_id)).toEqual([
'all',
'movie',
'live',
'series',
]);
expect(categories[2]?.count).toBe(3);
});
it('supports alternate live category ids for stalker collections', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 3,
movie: 1,
live: 1,
series: 1,
},
includeLive: true,
liveCategoryId: 'itv',
});
expect(categories[2]?.category_id).toBe('itv');
});
it('builds 3-category collections when live content is excluded', () => {
const categories = buildStandardCollectionCategories({
labels: {
all: 'All',
movie: 'Movies',
live: 'Live TV',
series: 'Series',
},
counts: {
all: 5,
movie: 2,
series: 3,
},
includeLive: false,
});
expect(categories.map((category) => category.category_id)).toEqual([
'all',
'movie',
'series',
]);
expect(categories[2]?.id).toBe(3);
});
it('filters items by selected category', () => {
const items = [
{ title: 'Movie 1' },
{ title: 'Series 1' },
{ title: 'Live 1' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'series',
allItems: items,
buckets: {
movie: [items[0]],
live: [items[2]],
series: [items[1]],
},
searchTerm: '',
textOf: (item) => item.title,
});
expect(filtered).toEqual([items[1]]);
});
it('filters alternate live buckets without changing stalker category ids', () => {
const items = [
{ name: 'Movie' },
{ name: 'Live' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'itv',
allItems: items,
buckets: {
movie: [items[0]],
live: [items[1]],
},
liveCategoryId: 'itv',
textOf: (item) => item.name,
});
expect(filtered).toEqual([items[1]]);
});
it('filters items by search term using the provided text selector', () => {
const items = [
{ name: 'Alien', o_name: 'Extended Cut' },
{ name: 'Matrix', o_name: 'Neo' },
];
const filtered = filterCollectionBucket({
selectedCategoryId: 'all',
allItems: items,
buckets: {
movie: items,
},
searchTerm: 'neo',
textOf: (item) => `${item.name} ${item.o_name}`,
});
expect(filtered).toEqual([items[1]]);
});
});
@@ -0,0 +1,96 @@
import { XtreamCategory } from 'shared-interfaces';
type StandardCollectionBucket = 'all' | 'movie' | 'live' | 'series';
interface BuildStandardCollectionCategoriesOptions {
labels: Record<StandardCollectionBucket, string>;
counts: Partial<Record<StandardCollectionBucket, number>>;
includeLive?: boolean;
liveCategoryId?: 'itv' | 'live';
}
interface FilterCollectionBucketOptions<T> {
selectedCategoryId: string | null | undefined;
allItems: readonly T[] | null | undefined;
buckets: Partial<Record<'movie' | 'live' | 'series', readonly T[] | null | undefined>>;
searchTerm?: string | null | undefined;
liveCategoryId?: string;
textOf: (item: T) => string;
}
export function buildStandardCollectionCategories(
options: BuildStandardCollectionCategoriesOptions
): XtreamCategory[] {
const {
labels,
counts,
includeLive = false,
liveCategoryId = 'live',
} = options;
const categories: XtreamCategory[] = [
{
id: 1,
category_id: 'all',
category_name: labels.all,
count: counts.all ?? 0,
parent_id: 0,
},
{
id: 2,
category_id: 'movie',
category_name: labels.movie,
count: counts.movie ?? 0,
parent_id: 0,
},
];
if (includeLive) {
categories.push({
id: 3,
category_id: liveCategoryId,
category_name: labels.live,
count: counts.live ?? 0,
parent_id: 0,
});
}
categories.push({
id: includeLive ? 4 : 3,
category_id: 'series',
category_name: labels.series,
count: counts.series ?? 0,
parent_id: 0,
});
return categories;
}
export function filterCollectionBucket<T>(
options: FilterCollectionBucketOptions<T>
): T[] {
const {
selectedCategoryId,
allItems,
buckets,
searchTerm,
liveCategoryId = 'live',
textOf,
} = options;
const baseItems =
selectedCategoryId === 'movie'
? buckets.movie ?? []
: selectedCategoryId === liveCategoryId
? buckets.live ?? []
: selectedCategoryId === 'series'
? buckets.series ?? []
: allItems ?? [];
const normalizedTerm = (searchTerm ?? '').trim().toLowerCase();
if (!normalizedTerm) {
return [...baseItems];
}
return baseItems.filter((item) =>
String(textOf(item)).toLowerCase().includes(normalizedTerm)
);
}
@@ -0,0 +1,14 @@
import { InjectionToken, Signal } from '@angular/core';
import { ExternalPlayerSession } from 'shared-interfaces';
export interface PortalExternalPlayback {
readonly activeSession: Signal<ExternalPlayerSession | null>;
readonly visibleSession: Signal<ExternalPlayerSession | null>;
dismissActiveSession(): void;
closeSession(
session: ExternalPlayerSession | null | undefined
): Promise<void>;
}
export const PORTAL_EXTERNAL_PLAYBACK =
new InjectionToken<PortalExternalPlayback>('PORTAL_EXTERNAL_PLAYBACK');
@@ -0,0 +1,11 @@
import { InjectionToken } from '@angular/core';
import { Playlist } from 'shared-interfaces';
export interface PortalNavigationActions {
openAccountInfo(): void;
openPlaylistInfo(playlist: Playlist | null | undefined): void;
openSettings(): void;
}
export const PORTAL_NAVIGATION_ACTIONS =
new InjectionToken<PortalNavigationActions>('PORTAL_NAVIGATION_ACTIONS');
@@ -0,0 +1,60 @@
import { InjectionToken } from '@angular/core';
import { PlaybackPositionData } from 'shared-interfaces';
export interface PortalPlaybackPositions {
savePlaybackPosition(
playlistId: string,
data: PlaybackPositionData
): Promise<void>;
getPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<PlaybackPositionData | null>;
getSeriesPlaybackPositions(
playlistId: string,
seriesXtreamId: number
): Promise<PlaybackPositionData[]>;
getAllPlaybackPositions(playlistId: string): Promise<PlaybackPositionData[]>;
clearPlaybackPosition(
playlistId: string,
contentXtreamId: number,
contentType: 'vod' | 'episode'
): Promise<void>;
}
export const PORTAL_PLAYBACK_POSITIONS =
new InjectionToken<PortalPlaybackPositions>('PORTAL_PLAYBACK_POSITIONS');
export function getPortalPlaybackProgressPercent(
position: PlaybackPositionData | null | undefined
): number {
if (!position || !position.durationSeconds) {
return 0;
}
const percent = (position.positionSeconds / position.durationSeconds) * 100;
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
export function isPortalPlaybackWatched(
position: PlaybackPositionData | null | undefined
): boolean {
return getPortalPlaybackProgressPercent(position) >= 90;
}
export function isPortalPlaybackInProgress(
position: PlaybackPositionData | null | undefined
): boolean {
if (!position) {
return false;
}
const percent = getPortalPlaybackProgressPercent(position);
return position.positionSeconds > 10 && percent < 90;
}
@@ -0,0 +1,30 @@
import { InjectionToken } from '@angular/core';
import {
ExternalPlayerSession,
ResolvedPortalPlayback,
} from 'shared-interfaces';
export interface PortalPlayer {
isEmbeddedPlayer(): boolean;
openPlayer(
streamUrl: string,
title: string,
thumbnail?: string | null,
hideExternalInfoDialog?: boolean,
isLiveContent?: boolean,
userAgent?: string,
referer?: string,
origin?: string,
contentInfo?: unknown,
startTime?: number,
headers?: Record<string, string>
): Promise<ExternalPlayerSession | void>;
openResolvedPlayback(
playback: ResolvedPortalPlayback,
hideExternalInfoDialog?: boolean
): Promise<ExternalPlayerSession | void>;
}
export const PORTAL_PLAYER = new InjectionToken<PortalPlayer>(
'PORTAL_PLAYER'
);
@@ -0,0 +1,57 @@
export type RemoteChannelDirection = 'up' | 'down';
/**
* Returns the adjacent item in the list for remote channel navigation.
* Uses wraparound when reaching the start or end of the list.
*/
export function getAdjacentChannelItem<T>(
items: T[],
activeId: string | number | null | undefined,
direction: RemoteChannelDirection,
getId: (item: T) => string | number | null | undefined
): T | null {
if (!Array.isArray(items) || items.length === 0 || activeId == null) {
return null;
}
const activeIdAsString = String(activeId);
const currentIndex = items.findIndex((item) => {
const itemId = getId(item);
return itemId != null && String(itemId) === activeIdAsString;
});
if (currentIndex === -1) {
return null;
}
const length = items.length;
const nextIndex =
direction === 'up'
? (currentIndex - 1 + length) % length
: (currentIndex + 1) % length;
return items[nextIndex] ?? null;
}
/**
* Returns the Nth item in a list (1-based index), or null if out of range.
*/
export function getChannelItemByNumber<T>(
items: T[],
channelNumber: number
): T | null {
if (
!Array.isArray(items) ||
items.length === 0 ||
!Number.isFinite(channelNumber)
) {
return null;
}
const index = Math.floor(channelNumber) - 1;
if (index < 0 || index >= items.length) {
return null;
}
return items[index] ?? null;
}
@@ -0,0 +1,28 @@
import { Injectable, signal } from '@angular/core';
import { WorkspaceHeaderCommandMetadata } from './workspace-view-command.types';
export interface WorkspaceHeaderAction {
id: string;
icon: string;
tooltipKey: string;
ariaLabelKey: string;
run: () => void;
palette?: WorkspaceHeaderCommandMetadata;
}
@Injectable({ providedIn: 'root' })
export class WorkspaceHeaderContextService {
readonly action = signal<WorkspaceHeaderAction | null>(null);
setAction(action: WorkspaceHeaderAction): void {
this.action.set(action);
}
clearAction(id?: string): void {
if (id && this.action()?.id !== id) {
return;
}
this.action.set(null);
}
}
@@ -0,0 +1,63 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import {
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from './workspace-header-context.service';
import { WorkspaceCommandContribution } from './workspace-view-command.types';
interface RegisteredWorkspaceCommand {
token: symbol;
command: WorkspaceCommandContribution;
}
@Injectable({ providedIn: 'root' })
export class WorkspaceViewCommandService {
private readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly registeredCommands = signal<RegisteredWorkspaceCommand[]>(
[]
);
readonly commands = computed<readonly WorkspaceCommandContribution[]>(() => {
const commands = this.registeredCommands().map(
(entry) => entry.command
);
const headerAction = this.headerContext.action();
const headerCommand = headerAction
? this.toHeaderCommand(headerAction)
: null;
return headerCommand ? [headerCommand, ...commands] : commands;
});
registerCommand(command: WorkspaceCommandContribution): () => void {
const token = Symbol(command.id);
this.registeredCommands.update((entries) => [...entries, { token, command }]);
return () => {
this.registeredCommands.update((entries) =>
entries.filter((entry) => entry.token !== token)
);
};
}
private toHeaderCommand(
action: WorkspaceHeaderAction
): WorkspaceCommandContribution {
const palette = action.palette;
return {
id: action.id,
group: palette?.group ?? 'view',
icon: action.icon,
labelKey: palette?.labelKey ?? action.tooltipKey,
labelParams: palette?.labelParams,
descriptionKey: palette?.descriptionKey,
descriptionParams: palette?.descriptionParams,
priority: palette?.priority ?? 0,
keywords: palette?.keywords,
visible: palette?.visible,
enabled: palette?.enabled,
run: () => action.run(),
};
}
}
@@ -0,0 +1,53 @@
export type WorkspaceCommandGroup = 'view' | 'playlist' | 'global';
export interface WorkspaceCommandActionContext {
query: string;
}
export type WorkspaceCommandParams = Record<string, string | number>;
export type WorkspaceCommandValue<T> = T | (() => T);
export interface WorkspaceCommandMetadata {
labelKey: string;
labelParams?:
| WorkspaceCommandParams
| (() => WorkspaceCommandParams | undefined);
descriptionKey?: string;
descriptionParams?:
| WorkspaceCommandParams
| (() => WorkspaceCommandParams | undefined);
priority?: number;
keywords?: readonly string[] | (() => readonly string[] | undefined);
visible?: boolean | (() => boolean | undefined);
enabled?: boolean | (() => boolean | undefined);
}
export interface WorkspaceCommandContribution extends WorkspaceCommandMetadata {
id: string;
group: WorkspaceCommandGroup;
icon: string;
run: (context: WorkspaceCommandActionContext) => void;
}
export interface WorkspaceHeaderCommandMetadata extends WorkspaceCommandMetadata {
group?: WorkspaceCommandGroup;
}
export interface WorkspaceResolvedCommandItem {
id: string;
group: WorkspaceCommandGroup;
icon: string;
label: string;
description: string;
keywords: readonly string[];
priority: number;
visible: boolean;
enabled: boolean;
run: (context: WorkspaceCommandActionContext) => void;
}
export interface WorkspaceCommandSelection {
commandId: string;
query: string;
}
@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});
+31
View File
@@ -0,0 +1,31 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"],
"moduleResolution": "node10"
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}