Proyecto App Linux
This commit is contained in:
@@ -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',
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user