feat(groups-view): add search functionality to filter grouped channels

feat(recent-view): implement search term filtering for recent items
fix(dashboard-data): update return type for navigation state methods
chore(jest): add jest configuration for workspace shell feature
refactor(global-favorites): migrate global favorites list to shared UI
refactor(global-favorites): remove global favorites page component and its styles
refactor(global-favorites): update unified favorite channel interface to use shared util
test(workspace-shell): setup test environment for workspace shell feature
Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-25 19:16:03 +01:00
1 parent aa5e4097f1
commit 8874e4a17e
80 files changed
+2396 -4316

No files matched your search

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