refactor(nx): update repository structure

Introduce new libraries to improve the boundaries and dependencies in mono repo
This commit is contained in:
4gray committed 2026-03-08 20:01:42 +01:00
1 parent 1780bb0e53
commit 256f0e0136
198 files changed
+1384 -815

No files matched your search

+7 -1
View File
@@ -20,13 +20,15 @@ import { TranslateHttpLoader } from '@ngx-translate/http-loader';
import { PlaylistEffects, playlistReducer } from 'm3u-state';
import { NgxIndexedDBModule, NgxIndexedDBService } from 'ngx-indexed-db';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access';
import { DataService } from 'services';
import { dbConfig } from 'shared-interfaces';
import { AppConfig } from '../environments/environment';
import { routes } from './app.routes';
import { ElectronService } from './services/electron.service';
import { PlayerService } from './services/player.service';
import { PwaService } from './services/pwa.service';
import { provideXtreamDataSource } from './xtream-electron/data-sources';
// AoT requires an exported function for factories
export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader {
@@ -85,6 +87,10 @@ export const appConfig: ApplicationConfig = {
useFactory: DataFactory,
deps: [NgxIndexedDBService, HttpClient],
},
{
provide: PORTAL_PLAYER,
useExisting: PlayerService,
},
...provideXtreamDataSource(),
],
};
+1 -1
View File
@@ -76,7 +76,7 @@ export const routes: Routes = [
path: 'downloads',
data: { layout: 'workspace' },
loadComponent: () =>
import('./xtream-electron/downloads/downloads.component').then(
import('@iptvnator/portal/downloads/feature').then(
(c) => c.DownloadsComponent
),
},
@@ -28,9 +28,9 @@ import { DownloadsService } from '../../services/downloads.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PlayerService } from '../../services/player.service';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { createLogger } from '../../shared/utils/logger';
import { StalkerVodSource } from '../../stalker/models';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { createLogger } from '@iptvnator/portal/shared/util';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import {
buildStalkerSelectedVodItem,
@@ -40,12 +40,12 @@ import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
import { StalkerStore } from '../../stalker/stalker.store';
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { XtreamCategorySortMode } from '../stores/features/with-selection.feature';
import { XtreamStore } from '../stores/xtream.store';
import { VodDetailsComponent } from '../vod-details/vod-details.component';
} from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import { XtreamCategorySortMode } from '@iptvnator/portal/xtream/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
@@ -10,10 +10,10 @@ import { PlaylistInfoComponent } from 'components';
import { selectPlaylistById } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
import { SettingsComponent } from '../../settings/settings.component';
import { AccountInfoComponent } from '../account-info/account-info.component';
import { XtreamStore } from '../stores/xtream.store';
import { buildPortalRailLinks } from '../../shared/navigation/portal-rail-links';
import { PortalRailLinksComponent } from '../../shared/navigation/portal-rail-links.component';
import { AccountInfoComponent } from '../../xtream-electron/account-info/account-info.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { buildPortalRailLinks } from '@iptvnator/portal/shared/util';
import { PortalRailLinksComponent } from '@iptvnator/portal/shared/ui';
@Component({
selector: 'app-navigation',
+5 -424
View File
@@ -1,424 +1,5 @@
import { computed, inject, Injectable, OnDestroy, signal } from '@angular/core';
import { SettingsStore } from './settings-store.service';
export type DownloadStatus =
| 'queued'
| 'downloading'
| 'completed'
| 'failed'
| 'canceled';
export interface DownloadItem {
id: number;
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
title: string;
url: string;
fileName?: string;
filePath?: string;
posterUrl?: string;
status: DownloadStatus;
bytesDownloaded?: number;
totalBytes?: number;
errorMessage?: string;
createdAt?: string;
updatedAt?: string;
}
@Injectable({ providedIn: 'root' })
export class DownloadsService implements OnDestroy {
private readonly settingsStore = inject(SettingsStore);
private unsubscribe?: () => void;
/** Signal for the list of downloads */
readonly downloads = signal<DownloadItem[]>([]);
/** Whether the download feature is available (Electron only) */
readonly isAvailable = computed(() => !!window.electron?.downloadsGetList);
/** Whether there are any downloads */
readonly hasDownloads = computed(() => this.downloads().length > 0);
/** Active downloads count */
readonly activeCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'queued' || d.status === 'downloading'
).length
);
/** Completed downloads count */
readonly completedCount = computed(
() => this.downloads().filter((d) => d.status === 'completed').length
);
/** Failed downloads count */
readonly failedCount = computed(
() =>
this.downloads().filter(
(d) => d.status === 'failed' || d.status === 'canceled'
).length
);
/** Current download folder */
readonly downloadFolder = signal<string>('');
constructor() {
this.init();
}
private async init() {
if (!this.isAvailable()) {
return;
}
// Load initial download list
await this.loadDownloads();
// Load download folder
await this.loadDownloadFolder();
// Subscribe to download updates
this.unsubscribe = window.electron.onDownloadsUpdate(() => {
this.loadDownloads();
});
}
ngOnDestroy() {
this.unsubscribe?.();
}
/**
* Load downloads from the backend
*/
async loadDownloads(playlistId?: string): Promise<void> {
if (!this.isAvailable()) return;
try {
const list = await window.electron.downloadsGetList(playlistId);
this.downloads.set(list);
} catch (error) {
console.error('[DownloadsService] Error loading downloads:', error);
}
}
/**
* Load or resolve download folder
*/
async loadDownloadFolder(): Promise<string> {
if (!this.isAvailable()) return '';
// First check settings
const storedFolder = this.settingsStore.getDownloadFolder?.();
if (storedFolder) {
this.downloadFolder.set(storedFolder);
return storedFolder;
}
// Fall back to default
try {
const defaultFolder =
await window.electron.downloadsGetDefaultFolder();
this.downloadFolder.set(defaultFolder);
return defaultFolder;
} catch (error) {
console.error(
'[DownloadsService] Error getting default folder:',
error
);
return '';
}
}
/**
* Start a new download
*/
async startDownload(data: {
playlistId: string;
xtreamId: number;
contentType: 'vod' | 'episode';
title: string;
url: string;
posterUrl?: string;
headers?: { userAgent?: string; referer?: string; origin?: string };
seriesXtreamId?: number;
seasonNumber?: number;
episodeNumber?: number;
// Playlist info for auto-creation if needed (Stalker playlists)
playlistName?: string;
playlistType?: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url';
serverUrl?: string;
portalUrl?: string;
macAddress?: string;
}): Promise<{ success: boolean; id?: number; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
const result = await window.electron.downloadsStart({
...data,
downloadFolder: folder,
});
return result;
} catch (error) {
console.error('[DownloadsService] Error starting download:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Cancel a download
*/
async cancelDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsCancel(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error canceling download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Retry a failed download
*/
async retryDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
const folder = await this.loadDownloadFolder();
if (!folder) {
return { success: false, error: 'No download folder configured' };
}
try {
return await window.electron.downloadsRetry(downloadId, folder);
} catch (error) {
console.error(
'[DownloadsService] Error retrying download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Remove a download from the list
*/
async removeDownload(
downloadId: number
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRemove(downloadId);
} catch (error) {
console.error(
'[DownloadsService] Error removing download:',
error
);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Play a downloaded file
*/
async playDownload(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsPlayFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error playing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Reveal file in system file manager
*/
async revealFile(
filePath: string
): Promise<{ success: boolean; error?: string }> {
if (!this.isAvailable()) {
return { success: false, error: 'Downloads not available' };
}
try {
return await window.electron.downloadsRevealFile(filePath);
} catch (error) {
console.error('[DownloadsService] Error revealing file:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Select a new download folder
*/
async selectFolder(): Promise<string | null> {
if (!this.isAvailable()) {
return null;
}
try {
const folder = await window.electron.downloadsSelectFolder();
if (folder) {
this.downloadFolder.set(folder);
// Save to settings
await this.settingsStore.updateSettings({ downloadFolder: folder });
}
return folder;
} catch (error) {
console.error(
'[DownloadsService] Error selecting folder:',
error
);
return null;
}
}
/**
* Clear completed/failed downloads
*/
async clearCompleted(
playlistId?: string
): Promise<{ success: boolean }> {
if (!this.isAvailable()) {
return { success: false };
}
try {
return await window.electron.downloadsClearCompleted(playlistId);
} catch (error) {
console.error(
'[DownloadsService] Error clearing completed:',
error
);
return { success: false };
}
}
/**
* Get download progress as percentage
*/
getProgressPercent(item: DownloadItem): number {
if (!item.totalBytes || item.totalBytes === 0) {
return 0;
}
return Math.round(
((item.bytesDownloaded || 0) / item.totalBytes) * 100
);
}
/**
* Get download item by xtreamId and playlistId
*/
getDownloadByContent(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): DownloadItem | undefined {
return this.downloads().find(
(d) =>
d.xtreamId === xtreamId &&
d.playlistId === playlistId &&
d.contentType === contentType
);
}
/**
* Check if content is downloaded (completed status)
*/
isDownloaded(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'completed' && !!download.filePath;
}
/**
* Check if content is currently downloading or queued
*/
isDownloading(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): boolean {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
return download?.status === 'downloading' || download?.status === 'queued';
}
/**
* Get the file path for a downloaded content item
*/
getDownloadedFilePath(
xtreamId: number,
playlistId: string,
contentType: 'vod' | 'episode'
): string | undefined {
const download = this.getDownloadByContent(xtreamId, playlistId, contentType);
if (download?.status === 'completed') {
return download.filePath;
}
return undefined;
}
/**
* Format bytes to human readable string
*/
formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
}
export {
DownloadsService,
type DownloadItem,
type DownloadStatus,
} from 'services';
@@ -18,7 +18,7 @@ import { AppConfig } from '../../environments/environment';
import {
createPortalDebugRequestContext,
logPortalDebugEvent,
} from '../shared/utils/logger';
} from '@iptvnator/portal/shared/util';
@Injectable({
providedIn: 'root',
+1 -1
View File
@@ -25,7 +25,7 @@ import {
createPortalDebugSuccessEvent,
logPortalDebugEvent,
logPortalDebugRequest,
} from '../shared/utils/logger';
} from '@iptvnator/portal/shared/util';
@Injectable({
providedIn: 'root',
@@ -1,109 +1 @@
import { inject } from '@angular/core';
import {
patchState,
signalStore,
withHooks,
withMethods,
withState,
} from '@ngrx/signals';
import { StorageMap } from '@ngx-pwa/local-storage';
import { firstValueFrom } from 'rxjs';
import {
Language,
Settings,
STORE_KEY,
StreamFormat,
Theme,
VideoPlayer,
} from 'shared-interfaces';
const DEFAULT_SETTINGS: Settings = {
player: VideoPlayer.VideoJs,
streamFormat: StreamFormat.M3u8StreamFormat,
language: Language.ENGLISH,
showCaptions: false,
showExternalPlaybackBar: true,
theme: Theme.LightTheme,
mpvPlayerPath: '',
mpvReuseInstance: false,
vlcPlayerPath: '',
remoteControl: false,
remoteControlPort: 8765,
epgUrl: [],
downloadFolder: '',
};
export const SettingsStore = signalStore(
{ providedIn: 'root' },
withState<Settings>(DEFAULT_SETTINGS),
withMethods((store, storage = inject(StorageMap)) => ({
async loadSettings() {
try {
const stored = await firstValueFrom(
storage.get(STORE_KEY.Settings)
);
if (stored) {
patchState(store, {
...DEFAULT_SETTINGS,
...(stored as Settings),
});
}
} catch (error) {
console.error('Failed to load settings:', error);
// Keep default settings if loading fails
}
},
async updateSettings(settings: Partial<Settings>) {
patchState(store, settings);
// Save the complete settings object, not just the partial update
const completeSettings = this.getSettings();
try {
await firstValueFrom(
storage.set(STORE_KEY.Settings, completeSettings)
);
} catch (error) {
console.error('Failed to save settings:', error);
throw error;
}
},
getSettings() {
return {
player: store.player(),
streamFormat: store.streamFormat(),
language: store.language(),
showCaptions: store.showCaptions(),
showExternalPlaybackBar: store.showExternalPlaybackBar(),
theme: store.theme(),
mpvPlayerPath: store.mpvPlayerPath(),
mpvReuseInstance: store.mpvReuseInstance(),
vlcPlayerPath: store.vlcPlayerPath(),
remoteControl: store.remoteControl(),
remoteControlPort: store.remoteControlPort(),
epgUrl: store.epgUrl(),
downloadFolder: store.downloadFolder(),
};
},
getDownloadFolder() {
return store.downloadFolder();
},
getPlayer() {
return store.player();
},
isEmbeddedPlayer() {
return (
store.player() === VideoPlayer.VideoJs ||
store.player() === VideoPlayer.Html5Player
);
},
})),
withHooks({
onInit(store) {
store.loadSettings();
},
})
);
export { SettingsStore } from 'services';
@@ -1,49 +0,0 @@
@if (items()?.length > 0) {
<div class="nav-list">
@for (item of items(); track $index) {
<a
class="nav-item category-item"
[attr.data-category-id]="$any(item).category_id ?? item.id"
[class.selected]="isSelected(item)"
[class.category-item--selected]="isSelected(item)"
[attr.aria-current]="isSelected(item) ? 'true' : null"
(click)="categoryClicked.emit(item)"
>
<span class="nav-item-label">
{{
item.category_name ||
$any(item).name ||
'No category name'
}}
</span>
@if (showCounts()) {
<div class="nav-item-meta item-count">
{{ getItemCount(item) }}
</div>
} @else if ($any(item).count !== undefined) {
<div class="nav-item-meta item-count">
{{ $any(item).count }}
</div>
}
</a>
} @empty {
<app-playlist-error-view
[title]="'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate"
[description]="
'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate
"
[showActionButtons]="false"
[viewType]="'NO_SEARCH_RESULTS'"
/>
}
</div>
} @else {
<div class="category-empty-state">
<app-playlist-error-view
[title]="'No categories available'"
[showIllustration]="false"
[showActionButtons]="false"
[viewType]="'EMPTY_CATEGORY'"
/>
</div>
}
@@ -1,72 +0,0 @@
@use '../../../../nav-list.scss';
:host {
display: block;
height: 100%;
min-height: 0;
overflow-y: auto;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.category-item {
cursor: pointer;
width: auto;
.item-count {
opacity: 0.6;
font-size: 0.85em;
margin-left: 4px;
}
&:hover:not(.category-item--selected) {
background: var(--app-selection-surface);
}
}
.category-item--selected .item-count {
color: var(--app-selection-color) !important;
opacity: 0.95;
}
mat-card-content {
overflow-wrap: break-word;
padding: 12px;
}
.category-empty-state {
height: 100%;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(14px, 3vh, 28px) 12px clamp(20px, 4vh, 34px);
box-sizing: border-box;
app-playlist-error-view {
--error-empty-illustration-size: clamp(132px, 15vw, 180px);
--error-empty-max-width: min(270px, 100%);
--error-empty-title-size: clamp(1.15rem, 1.35vw, 1.4rem);
--error-empty-title-weight: 610;
--error-empty-title-spacing: -0.006em;
--error-empty-copy-gap: 4px;
--error-empty-description-opacity: 0.82;
}
}
@media (max-width: 800px) {
.category-item {
width: 100%;
margin: 4px;
}
.category-empty-state {
app-playlist-error-view {
--error-empty-illustration-size: 142px;
--error-empty-max-width: 230px;
--error-empty-title-size: 1.05rem;
--error-empty-title-weight: 600;
}
}
}
@@ -1,67 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
output,
} from '@angular/core';
import { MatListModule } from '@angular/material/list';
import { TranslatePipe } from '@ngx-translate/core';
import { XtreamCategory } from 'shared-interfaces';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
@Component({
selector: 'app-category-view',
imports: [MatListModule, PlaylistErrorViewComponent, TranslatePipe],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './category-view.component.html',
styleUrls: ['./category-view.component.scss'],
})
export class CategoryViewComponent {
readonly items = input([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
readonly itemCounts = input<Map<number, number>>(new Map());
readonly showCounts = input<boolean>(false);
private readonly hostEl = inject(ElementRef<HTMLElement>);
readonly categoryClicked = output<XtreamCategory>();
constructor() {
effect(() => {
const selectedCategory = this.selectedCategoryId();
if (selectedCategory == null) return;
queueMicrotask(() => {
const container = this.hostEl.nativeElement as HTMLElement;
const candidates = Array.from(
container.querySelectorAll('[data-category-id]')
) as HTMLElement[];
const selected = candidates.find(
(el) => el.dataset.categoryId === String(selectedCategory)
);
selected?.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
});
});
}
isSelected(item: XtreamCategory): boolean {
const selectedCategory = this.selectedCategoryId();
const itemId = (item as any).category_id ?? item.id;
// Compare as strings to handle both numeric and string category IDs.
return selectedCategory != null && String(selectedCategory) === String(itemId);
}
getItemCount(item: XtreamCategory): number {
// content.category_id references categories.id (internal DB id)
// For DB format categories, use id; for API format, use category_id
const itemId = Number(item.id ?? (item as any).category_id);
return this.itemCounts().get(itemId) ?? 0;
}
}
@@ -1,48 +0,0 @@
<div class="content-card" (click)="onCardClick()">
<div class="thumbnail-wrapper">
@if (showRemoveButton()) {
<button
mat-icon-button
class="remove-button"
[matTooltip]="removeTooltip()"
(click)="onRemoveClick($event)"
>
<mat-icon>close</mat-icon>
</button>
}
@if (posterUrl()) {
<img
[src]="posterUrl()"
[alt]="title()"
(error)="onImageError($event)"
class="poster"
/>
} @else if (showPlaceholder()) {
<div class="poster-placeholder">
<mat-icon>{{ getPlaceholderIcon() }}</mat-icon>
</div>
} @else {
<img
src="./assets/images/default-poster.png"
[alt]="title()"
class="poster"
/>
}
@if (type()) {
<span class="type-badge" [class]="type()">
{{ type() }}
</span>
}
</div>
<div class="card-info">
<h3>{{ title() }}</h3>
@if (date()) {
<p class="date-info">
{{ date() | date: 'd MMM, HH:mm' }}
</p>
}
</div>
</div>
@@ -1,21 +0,0 @@
@use '../../styles/content-grid' as grid;
.content-card {
@include grid.content-card;
.thumbnail-wrapper {
@include grid.thumbnail-wrapper;
.remove-button {
@include grid.remove-button;
}
.type-badge {
@include grid.type-badge;
}
}
.card-info {
@include grid.card-info;
}
}
@@ -1,73 +0,0 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
@Component({
selector: 'app-content-card',
standalone: true,
imports: [DatePipe, MatIcon, MatIconButton, MatTooltip],
templateUrl: './content-card.component.html',
styleUrl: './content-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContentCardComponent {
/** URL of the poster image */
readonly posterUrl = input<string>();
/** Title to display */
readonly title = input.required<string>();
/** Content type (live, movie, series) */
readonly type = input<string>();
/** Optional date to display (Date, string, or timestamp number) */
readonly date = input<Date | string | number>();
/** Whether to show the remove button */
readonly showRemoveButton = input<boolean>(false);
/** Tooltip text for the remove button */
readonly removeTooltip = input<string>('Remove');
/** Whether to show placeholder when no poster */
readonly showPlaceholder = input<boolean>(true);
/** Emitted when the card is clicked */
readonly cardClick = output<void>();
/** Emitted when the remove button is clicked */
readonly remove = output<void>();
/** Get the icon for the placeholder based on type */
getPlaceholderIcon(): string {
switch (this.type()) {
case 'live':
return 'live_tv';
case 'series':
return 'tv';
default:
return 'movie';
}
}
onCardClick(): void {
this.cardClick.emit();
}
onRemoveClick(event: Event): void {
event.stopPropagation();
this.remove.emit();
}
onImageError(event: Event): void {
(event.target as HTMLImageElement).src =
'./assets/images/default-poster.png';
}
}
@@ -1,77 +0,0 @@
@if (!isWorkspaceLayout) {
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header">
<app-playlist-switcher
class="switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
/>
</div>
<app-category-view
[items]="categories()"
[selectedCategoryId]="$any(selectedCategoryId())"
(categoryClicked)="setCategoryId($event.category_id)"
/>
</div>
}
<div class="main-content">
<div class="content-header">
<div class="category-meta">
<h2 class="category-title">
{{ titleTranslationString() | translate }}
</h2>
@if (selectedCategoryId()) {
<div class="category-subtitle">{{ selectedCategoryId() }}</div>
}
</div>
@if (showHeaderAction() && !isWorkspaceLayout) {
<button
type="button"
mat-icon-button
[matTooltip]="headerActionTooltip()"
(click)="onHeaderActionClick()"
>
<mat-icon>{{ headerActionIcon() }}</mat-icon>
</button>
}
</div>
<div class="scrollable-content">
@if (favorites().length) {
<div class="items-grid">
@for (item of favorites(); track $index) {
<app-content-card
[posterUrl]="item.poster_url || item.cover"
[title]="
item?.title ||
item?.details?.info?.name ||
'Unknown'
"
[type]="item.type"
[date]="item.added_at"
[showRemoveButton]="true"
[removeTooltip]="
removeTooltip() ||
('CHANNELS.REMOVE_FAVORITE' | translate)
"
(cardClick)="openFavorite(item)"
(remove)="removeFromFavorites(item)"
/>
}
</div>
} @else {
<div class="no-items">
<mat-icon>{{ emptyIcon() }}</mat-icon>
<p>No items in the selected category</p>
</div>
}
</div>
<!-- <app-mpv-player-bar /> -->
</div>
@@ -1,119 +0,0 @@
@use '@angular/material' as mat;
@use '../../../shared/styles/content-grid' as grid;
:host {
display: flex;
flex-direction: row;
overflow: hidden;
height: 100%;
width: 100%;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; // Important for proper flex behavior
}
.content-header {
padding: 8px 12px;
min-height: 48px;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.category-meta {
min-width: 0;
flex: 1;
display: flex;
align-items: baseline;
gap: 8px;
}
.category-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Informational badge — primary-tinted pill with definition ring
.category-subtitle {
white-space: nowrap;
flex-shrink: 0;
font-size: 0.70rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: capitalize;
line-height: 1;
color: var(--mat-sys-primary);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
padding: 4px 10px;
border-radius: 999px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
0 1px 3px rgba(0, 0, 0, 0.18);
}
.scrollable-content {
flex: 1;
overflow-y: auto;
}
.favorites-container {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.items-grid {
@include grid.content-grid(140px, 20px);
padding: 16px;
width: 100%;
box-sizing: border-box;
margin: 0;
}
.no-items {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 1rem;
}
p {
font-size: 1.1rem;
margin: 0;
}
}
.sidebar-header {
display: flex;
flex-direction: row;
align-items: center;
button {
@include mat.form-field-density(-5);
}
}
@@ -1,67 +0,0 @@
import { Component, inject, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { isWorkspaceLayoutRoute } from '../../navigation/portal-route.utils';
import { CategoryViewComponent } from '../category-view/category-view.component';
import { ContentCardComponent } from '../content-card/content-card.component';
@Component({
selector: 'app-favorites-layout',
templateUrl: './favorites-layout.component.html',
styleUrls: [
'./favorites-layout.component.scss',
'../../styles/portal-sidebar.scss',
],
imports: [
CategoryViewComponent,
ContentCardComponent,
MatIcon,
MatIconButton,
PlaylistSwitcherComponent,
ResizableDirective,
TranslatePipe,
MatTooltip,
],
})
export class FavoritesLayoutComponent {
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(
inject(ActivatedRoute)
);
readonly categories = input<any[]>([]);
readonly favorites = input<any[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('movie');
readonly titleTranslationString = input<string>('CHANNELS.FAVORITES');
readonly removeTooltip = input<string>('');
readonly emptyIcon = input<string>('favorite_border');
readonly showHeaderAction = input<boolean>(false);
readonly headerActionIcon = input<string>('delete_sweep');
readonly headerActionTooltip = input<string>('');
readonly categoryClicked = output<any>();
readonly removeFavorite = output<any>();
readonly openItem = output<any>();
readonly headerActionClicked = output<void>();
setCategoryId(categoryId: any) {
this.categoryClicked.emit({ category_id: categoryId });
}
removeFromFavorites(item: any) {
this.removeFavorite.emit(item);
}
openFavorite(item: any) {
this.openItem.emit(item);
}
onHeaderActionClick() {
this.headerActionClicked.emit();
}
}
@@ -4,7 +4,7 @@ import { MatIcon } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { ProgressCapsuleComponent } from '../../../xtream-electron/shared/progress-capsule/progress-capsule.component';
import { WatchedBadgeComponent } from '../../../xtream-electron/shared/watched-badge/watched-badge.component';
+11 -6
View File
@@ -1,7 +1,12 @@
export * from './category-view/category-view.component';
export * from './content-card/content-card.component';
export {
CategoryViewComponent,
ContentCardComponent,
FavoritesLayoutComponent,
PlaylistErrorViewComponent,
PortalCollectionLiveShellComponent,
PortalCollectionShellComponent,
PortalEmptyStateComponent,
SearchFormComponent,
SearchLayoutComponent,
} from '@iptvnator/portal/shared/ui';
export * from './header/header.component';
export * from './playlist-error-view/playlist-error-view.component';
export * from './portal-collection-shell/portal-collection-shell.component';
export * from './portal-empty-state/portal-empty-state.component';
export * from './stalker-inline-detail/stalker-inline-detail.component';
@@ -1,45 +0,0 @@
<div
class="error-view"
[class.error-view--empty-category]="viewType() === 'EMPTY_CATEGORY'"
[class.error-view--text-only]="!showIllustration()"
>
@if (showIllustration()) {
@if (viewType() === 'ERROR') {
<img
alt="Something went wrong"
src="./assets/images/broken-tv.svg"
/>
} @else if (viewType() === 'EMPTY_CATEGORY') {
<img
alt="empty category"
class="error-view__illustration"
src="./assets/images/empty-category.svg"
/>
} @else if (viewType() === 'NO_SEARCH_RESULTS') {
<mat-icon class="icon">search</mat-icon>
}
}
<div class="error-view__copy">
<h3 class="error-view__title">{{ title() }}</h3>
@if (description()) {
<p class="error-view__description">{{ description() }}</p>
}
</div>
@if (showActionButtons()) {
<div class="error-view__actions">
<button mat-stroked-button routerLink="/">
<mat-icon>home</mat-icon>
{{ 'PORTALS.ERROR_VIEW.HOME' | translate }}
</button>
<button mat-stroked-button (click)="openPlaylistDetails()">
<mat-icon>settings</mat-icon>
{{ 'PORTALS.ERROR_VIEW.PLAYLIST_SETTINGS' | translate }}
</button>
<button mat-stroked-button color="warn" (click)="removeClicked()">
<mat-icon>delete</mat-icon>
{{ 'PORTALS.ERROR_VIEW.DELETE' | translate }}
</button>
</div>
}
</div>
@@ -1,110 +0,0 @@
:host {
display: block;
width: 100%;
min-height: 0;
}
.error-view {
--error-view-illustration-size: var(
--error-empty-illustration-size,
clamp(170px, 28vw, 260px)
);
--error-view-copy-width: var(--error-empty-max-width, 420px);
--error-view-copy-gap: var(--error-empty-copy-gap, 8px);
--error-view-title-size: var(--error-empty-title-size, 1.55rem);
--error-view-title-weight: var(--error-empty-title-weight, 640);
--error-view-title-spacing: var(--error-empty-title-spacing, -0.012em);
--error-view-description-size: var(--error-empty-description-size, 0.98rem);
--error-view-description-line-height: var(
--error-empty-description-line-height,
1.4
);
--error-view-description-opacity: var(
--error-empty-description-opacity,
0.88
);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
min-height: 100%;
padding: clamp(12px, 2.2vw, 26px);
gap: 14px;
text-align: center;
box-sizing: border-box;
color: var(--app-body-color, var(--mat-sys-on-surface-variant));
img {
width: min(100%, var(--error-view-illustration-size));
height: auto;
filter: invert(80%);
}
}
.icon {
font-size: 64px;
height: 64px;
width: 64px;
}
.error-view--empty-category .error-view__illustration {
margin-top: clamp(0px, 1vh, 8px);
}
.error-view--text-only {
gap: 6px;
}
.error-view__copy {
max-width: var(--error-view-copy-width);
display: grid;
gap: var(--error-view-copy-gap);
}
.error-view__title {
margin: 0;
font-size: var(--error-view-title-size);
font-weight: var(--error-view-title-weight);
line-height: 1.12;
color: var(--app-heading-color, var(--mat-sys-on-surface));
letter-spacing: var(--error-view-title-spacing);
text-wrap: balance;
}
.error-view__description {
margin: 0;
font-size: var(--error-view-description-size);
line-height: var(--error-view-description-line-height);
color: inherit;
opacity: var(--error-view-description-opacity);
text-wrap: balance;
}
.error-view__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
@media (max-width: 1024px) {
.error-view {
--error-view-title-size: var(--error-empty-title-size, 1.4rem);
--error-view-description-size: var(
--error-empty-description-size,
0.92rem
);
}
}
::ng-deep {
.dark-theme {
.error-view {
img {
filter: invert(10%) !important;
}
}
}
}
@@ -1,59 +0,0 @@
import { Component, inject, input } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIconModule } from '@angular/material/icon';
import { Router, RouterLink } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { DialogService, PlaylistInfoComponent } from 'components';
import { PlaylistActions } from 'm3u-state';
import { selectCurrentPlaylist } from 'm3u-state';
import { Playlist } from 'shared-interfaces';
@Component({
selector: 'app-playlist-error-view',
templateUrl: './playlist-error-view.component.html',
styleUrls: ['./playlist-error-view.component.scss'],
imports: [MatButtonModule, MatIconModule, RouterLink, TranslateModule],
})
export class PlaylistErrorViewComponent {
private dialog = inject(MatDialog);
private dialogService = inject(DialogService);
private router = inject(Router);
private store = inject(Store);
private translate = inject(TranslateService);
private readonly currentPlaylist = this.store.selectSignal(
selectCurrentPlaylist
);
readonly description = input<string>(undefined);
readonly showIllustration = input(true);
readonly showActionButtons = input(true);
readonly title = input<string>(undefined);
readonly viewType = input<'ERROR' | 'EMPTY_CATEGORY' | 'NO_SEARCH_RESULTS'>(
'ERROR'
);
openPlaylistDetails() {
this.dialog.open(PlaylistInfoComponent, {
data: this.currentPlaylist(),
});
}
removeClicked(): void {
this.dialogService.openConfirmDialog({
title: this.translate.instant('HOME.PLAYLISTS.REMOVE_DIALOG.TITLE'),
message: this.translate.instant(
'HOME.PLAYLISTS.REMOVE_DIALOG.MESSAGE'
),
onConfirm: (): void =>
this.removePlaylist((this.currentPlaylist() as Playlist)._id),
});
}
removePlaylist(playlistId: string): void {
this.store.dispatch(PlaylistActions.removePlaylist({ playlistId }));
this.router.navigate(['/']);
}
}
@@ -1,55 +0,0 @@
<div
class="sidebar"
appResizable
[minWidth]="250"
[maxWidth]="600"
[defaultWidth]="400"
storageKey="sidebar-width"
>
<div class="sidebar-header category-content-header">
<div class="category-meta">
<h2 class="category-title">{{ title() }}</h2>
<div class="category-subtitle">{{ subtitle() }}</div>
</div>
<ng-content select="[liveHeaderActions]" />
</div>
<ng-content select="[liveChannels]" />
</div>
<div class="content-container">
@if (hasSelection()) {
@if (streamUrl() && isEmbeddedPlayer()) {
<div class="video-player">
<app-web-player-view [streamUrl]="streamUrl()" />
</div>
}
<div class="epg">
<div class="epg-content">
@if (epgItems().length > 0) {
<app-epg-view [epgItems]="epgItems()" />
}
@if (isLoadingEpg()) {
<div class="loading-indicator">
<mat-spinner diameter="24" />
</div>
}
@if (hasMoreEpg() && !isLoadingEpg()) {
<div class="load-more-epg">
<button mat-button (click)="onLoadMoreEpg()">
{{ 'CHANNELS.LOAD_MORE' | translate }}
</button>
</div>
}
</div>
</div>
} @else {
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -1,81 +0,0 @@
@use '@angular/material' as mat;
@use '../../styles/portal-layout' as portal;
@include portal.live-layout;
// ─── Sidebar panel header ─────────────────────────────────────────────────────
.category-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 8px 12px;
min-height: 48px;
flex-shrink: 0;
background: var(--app-widget-header-bg, var(--mat-sys-surface-container-high));
border-bottom: 1px solid var(--app-separator);
app-region: drag;
// Own buttons (direct children scoped to this component)
button {
@include mat.form-field-density(-5);
app-region: no-drag;
}
// Projected buttons (liveHeaderActions slot) — view encapsulation
// prevents the rule above from reaching them, so ng-deep is required.
::ng-deep button {
app-region: no-drag;
}
}
.category-meta {
min-width: 0;
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
}
// ─── Title + count chip — now aligned with category-content-view ─────────────
.category-title {
margin: 0;
font-size: 0.94rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--mat-sys-on-surface);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Informational badge — primary-tinted pill with definition ring
.category-subtitle {
white-space: nowrap;
flex-shrink: 0;
font-size: 0.70rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: capitalize;
line-height: 1;
color: var(--mat-sys-primary);
background: color-mix(in srgb, var(--mat-sys-primary) 10%, var(--mat-sys-surface-container-high));
padding: 4px 10px;
border-radius: 999px;
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent),
0 1px 3px rgba(0, 0, 0, 0.18);
}
// ─── EPG controls ─────────────────────────────────────────────────────────────
.loading-indicator {
display: flex;
justify-content: center;
padding: 16px;
}
.load-more-epg {
display: flex;
justify-content: center;
padding: 8px 0;
}
@@ -1,46 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { EpgItem } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
@Component({
selector: 'app-portal-collection-live-shell',
imports: [
EpgViewComponent,
MatButtonModule,
MatProgressSpinnerModule,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
templateUrl: './portal-collection-live-shell.component.html',
styleUrl: './portal-collection-live-shell.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PortalCollectionLiveShellComponent {
readonly title = input<string>('Favorites');
readonly subtitle = input<string>('Live TV');
readonly count = input<number>(0);
readonly streamUrl = input<string>('');
readonly isEmbeddedPlayer = input<boolean>(true);
readonly hasSelection = input<boolean>(false);
readonly epgItems = input<EpgItem[]>([]);
readonly isLoadingEpg = input<boolean>(false);
readonly hasMoreEpg = input<boolean>(false);
readonly loadMoreEpgClicked = output<void>();
onLoadMoreEpg(): void {
this.loadMoreEpgClicked.emit();
}
}
@@ -1,29 +0,0 @@
@switch (mode()) {
@case ('detail') {
<ng-content select="[details]" />
}
@case ('live') {
<ng-content select="[live]" />
}
@default {
<app-favorites-layout
[categories]="categories()"
[favorites]="items()"
[playlistSubtitle]="playlistSubtitle()"
[playlistTitle]="playlistTitle()"
[selectedCategoryId]="selectedCategoryId()"
[titleTranslationString]="
layout().titleTranslationKey || 'CHANNELS.FAVORITES'
"
[removeTooltip]="layout().removeTooltip || ''"
[emptyIcon]="layout().emptyIcon || 'favorite_border'"
[showHeaderAction]="layout().showHeaderAction || false"
[headerActionIcon]="layout().headerActionIcon || 'delete_sweep'"
[headerActionTooltip]="layout().headerActionTooltip || ''"
(categoryClicked)="onCategoryClicked($event)"
(removeFavorite)="removeItem.emit($event)"
(headerActionClicked)="headerActionClicked.emit()"
(openItem)="openItem.emit($event)"
/>
}
}
@@ -1,52 +0,0 @@
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesLayoutComponent } from '../favorites-layout/favorites-layout.component';
export interface PortalCollectionShellLayout {
titleTranslationKey?: string;
removeTooltip?: string;
emptyIcon?: string;
showHeaderAction?: boolean;
headerActionIcon?: string;
headerActionTooltip?: string;
}
export type PortalCollectionMode = 'grid' | 'detail' | 'live';
@Component({
selector: 'app-portal-collection-shell',
imports: [FavoritesLayoutComponent],
templateUrl: './portal-collection-shell.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [
`
:host {
display: block;
height: 100%;
width: 100%;
}
`,
],
})
export class PortalCollectionShellComponent {
readonly categories = input<XtreamCategory[]>([]);
readonly items = input<any[]>([]);
readonly playlistSubtitle = input<string>('');
readonly playlistTitle = input<string>('Playlist');
readonly selectedCategoryId = input<string>('all');
readonly layout = input<PortalCollectionShellLayout>({});
readonly mode = input<PortalCollectionMode>('grid');
readonly categoryClicked = output<string>();
readonly removeItem = output<any>();
readonly openItem = output<any>();
readonly headerActionClicked = output<void>();
onCategoryClicked(event: { category_id?: string }) {
if (!event?.category_id) {
return;
}
this.categoryClicked.emit(event.category_id);
}
}
@@ -1,4 +0,0 @@
<div class="empty-state-container">
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
<h2 class="empty-state-title">{{ message() }}</h2>
</div>
@@ -1,55 +0,0 @@
:host {
display: flex;
flex: 1;
min-height: 0;
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 0;
text-align: center;
color: var(--mat-sys-on-surface-variant);
animation: portal-empty-state-fade-in 220ms ease-out;
}
.empty-state-icon {
font-size: 72px;
width: 72px;
height: 72px;
margin-bottom: 24px;
opacity: 0.2;
animation: portal-empty-state-float 2.8s ease-in-out infinite;
}
.empty-state-title {
font-size: 1.1rem;
font-weight: 500;
letter-spacing: -0.01em;
margin: 0;
opacity: 0.5;
}
@keyframes portal-empty-state-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes portal-empty-state-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@@ -1,14 +0,0 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
@Component({
selector: 'app-portal-empty-state',
templateUrl: './portal-empty-state.component.html',
styleUrl: './portal-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon],
})
export class PortalEmptyStateComponent {
readonly icon = input<string>('live_tv');
readonly message = input.required<string>();
}
@@ -1,36 +0,0 @@
<div class="search-container">
<div class="recent-search">
<mat-icon>search</mat-icon>
<input
#searchInput
type="search"
spellcheck="false"
autocomplete="off"
[placeholder]="placeholder"
[ngModel]="searchTerm()"
(ngModelChange)="onSearchTermChange()"
/>
@if (searchTerm()) {
<button mat-icon-button class="clear-btn" (click)="clearSearch()">
<mat-icon>close</mat-icon>
</button>
}
</div>
@if (filterConfig.length > 0) {
<div class="type-filters">
@for (filter of filterConfig; track filter.key) {
<mat-checkbox
[(ngModel)]="filters[filter.key]"
(ngModelChange)="onFilterChange(filter.key)"
>
@if (filter.translationKey) {
{{ filter.translationKey | translate }}
} @else {
{{ filter.label }}
}
</mat-checkbox>
}
</div>
}
</div>
@@ -1,126 +0,0 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
model,
Output,
ViewChild,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { TranslatePipe } from '@ngx-translate/core';
export interface SearchFilters {
[key: string]: boolean;
}
export interface SearchFilter {
key: string;
label: string;
translationKey?: string;
}
@Component({
selector: 'app-search-form',
standalone: true,
imports: [
FormsModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatCheckboxModule,
MatButtonModule,
TranslatePipe,
],
templateUrl: './search-form.component.html',
styles: [
`
.search-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.recent-search {
display: flex;
align-items: center;
gap: 10px;
padding: 0 12px;
height: 42px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.recent-search mat-icon {
opacity: 0.6;
font-size: 18px;
width: 18px;
height: 18px;
}
.recent-search input {
width: 100%;
border: 0;
background: transparent;
color: inherit;
font: inherit;
opacity: 0.95;
}
.recent-search input::placeholder {
opacity: 0.5;
}
.clear-btn {
width: 30px;
height: 30px;
min-width: 30px;
}
`,
],
})
export class SearchFormComponent {
@ViewChild('searchInput') searchInput!: ElementRef;
@Input() placeholder = 'Search';
@Input() filters: SearchFilters = {};
@Input() filterConfig: SearchFilter[] = [];
@Input() singleSelection = false; // Add this new input
searchTerm = model<string>('');
@Output() filtersChange = new EventEmitter<SearchFilters>();
@Output() search = new EventEmitter<void>();
onFilterChange(changedKey: string) {
if (this.singleSelection) {
// Reset all other filters when in single selection mode
Object.keys(this.filters).forEach((key) => {
if (key !== changedKey) {
this.filters[key] = false;
}
});
}
this.filtersChange.emit(this.filters);
this.search.emit();
}
onSearchTermChange() {
setTimeout(() => {
this.searchTerm.set(this.searchInput.nativeElement.value);
}, 500);
}
clearSearch() {
this.searchInput.nativeElement.value = '';
this.searchTerm.set('');
}
focusSearchInput() {
this.searchInput.nativeElement.focus();
}
}
@@ -1,86 +0,0 @@
<div class="search-page">
<!-- Inline details slot (for stalker) - shown above everything when active -->
<ng-content select="[details]" />
@if (!showDetails()) {
<div class="search-content">
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<h2>{{ title() | translate }}</h2>
<!-- Filters slot -->
<div class="type-filters">
<ng-content select="[filters]" />
</div>
</div>
<div class="header-right">
@if (showResultsCount() && resultsCount() > 0) {
<div class="results-count">
{{
'PORTALS.SEARCH_VIEW.RESULTS_FOUND'
| translate: { count: resultsCount() }
}}
</div>
}
@if (showCloseButton()) {
<button mat-icon-button (click)="onCloseClick()">
<mat-icon>close</mat-icon>
</button>
}
<!-- Extra header actions slot -->
<ng-content select="[header-actions]" />
</div>
</div>
@if (showSearchInput()) {
<div class="search-input-container">
<app-search-form
[searchTerm]="searchTerm()"
(searchTermChange)="onSearchTermChange($event)"
/>
</div>
}
<!-- Subheader slot (info notes, secondary controls) -->
<ng-content select="[subheader]" />
</div>
<div class="results-container">
@if (isLoading()) {
<div class="loading-state">
<mat-spinner diameter="48" />
</div>
} @else if (showResults) {
<!-- Results slot -->
<ng-content select="[results]" />
} @else if (showNoResults) {
<div class="no-items">
<mat-icon>search_off</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.EMPTY_LIST_VIEW.TITLE' | translate }}</h3>
<p>{{ 'PORTALS.EMPTY_LIST_VIEW.NO_SEARCH_RESULTS' | translate }}</p>
<p class="search-term-note">
{{
'PORTALS.SEARCH_VIEW.NO_RESULTS_FOR'
| translate: { term: searchTerm() }
}}
</p>
</div>
</div>
} @else if (showInitialState) {
<div class="no-items initial-state">
<mat-icon>search</mat-icon>
<div class="no-items-content">
<h3>{{ 'PORTALS.SEARCH_VIEW.INITIAL_TITLE' | translate }}</h3>
<p>{{ 'PORTALS.SEARCH_VIEW.INITIAL_DESCRIPTION' | translate }}</p>
</div>
</div>
}
<!-- Custom empty state slot (optional override) -->
<ng-content select="[empty]" />
</div>
</div>
}
</div>
@@ -1,167 +0,0 @@
@use '../../styles/content-grid' as grid;
:host {
display: flex;
flex-direction: column;
overflow-y: auto;
height: 100%;
width: 100%;
}
.search-page {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.search-content {
display: flex;
flex-direction: column;
flex: 1;
overflow-y: auto;
padding: 0 20px 0 20px;
}
.header {
position: sticky;
top: 0;
z-index: 100;
padding: 20px 0 10px 0;
margin-bottom: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.header-left {
display: flex;
align-items: center;
gap: 32px;
flex: 1;
flex-wrap: wrap;
h2 {
margin: 0;
white-space: nowrap;
}
.type-filters {
display: flex;
gap: 20px;
align-items: center;
::ng-deep mat-checkbox {
margin: 0;
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.results-count {
font-size: 0.85rem;
padding: 6px 12px;
white-space: nowrap;
display: flex;
align-items: center;
}
}
.search-input-container {
margin-bottom: 0;
}
}
.results-container {
flex: 1;
overflow-y: auto;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 2rem;
}
.no-items {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
margin-bottom: 1.5rem;
opacity: 0.5;
}
.no-items-content {
h3 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 500;
color: var(--text-color);
}
p {
font-size: 1.1rem;
margin: 0;
opacity: 0.8;
}
.search-term-note {
margin-top: 1rem;
font-size: 0.9rem;
font-style: italic;
opacity: 0.6;
}
}
&.initial-state {
opacity: 0.8;
mat-icon {
color: var(--primary-color);
opacity: 0.6;
}
}
}
// Results grid mixin for consumers
::ng-deep .results-grid {
@include grid.content-grid(140px, 20px);
padding-bottom: 1rem;
}
// Global search overlay context
:host-context(.global-search-overlay) {
// Prevent Electron's app-region: drag from bleeding through the dialog overlay
app-region: no-drag;
.search-page {
background: var(--background);
height: 100vh;
}
.header {
background: var(--background);
}
}
@@ -1,100 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
input,
output,
viewChild,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { SearchFormComponent } from '../search-form/search-form.component';
@Component({
selector: 'app-search-layout',
standalone: true,
imports: [
MatIcon,
MatIconButton,
MatProgressSpinner,
SearchFormComponent,
TranslatePipe,
],
templateUrl: './search-layout.component.html',
styleUrl: './search-layout.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SearchLayoutComponent {
private readonly searchFormComponent = viewChild(SearchFormComponent);
/** Page title translation key */
readonly title = input<string>('PORTALS.SIDEBAR.SEARCH');
/** Current search term */
readonly searchTerm = input<string>('');
/** Number of results found */
readonly resultsCount = input<number>(0);
/** Whether search is in progress */
readonly isLoading = input<boolean>(false);
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
/** Whether to show the inline search input */
readonly showSearchInput = input<boolean>(true);
/** Whether inline details are active and should replace the search body */
readonly showDetails = input<boolean>(false);
/** Minimum characters required for search */
readonly minSearchLength = input<number>(3);
/** Emitted when search term changes */
readonly searchTermChange = output<string>();
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Focus the search input */
focusSearchInput(): void {
this.searchFormComponent()?.focusSearchInput();
}
onSearchTermChange(term: string): void {
this.searchTermChange.emit(term);
}
onCloseClick(): void {
this.closeClick.emit();
}
/** Check if we should show the "no results" state */
get showNoResults(): boolean {
return (
this.searchTerm().length >= this.minSearchLength() &&
this.resultsCount() === 0 &&
!this.isLoading()
);
}
/** Check if we should show the initial state */
get showInitialState(): boolean {
return (
this.searchTerm().length < this.minSearchLength() &&
this.resultsCount() === 0 &&
!this.isLoading()
);
}
/** Check if we should show results */
get showResults(): boolean {
return this.resultsCount() > 0 && !this.isLoading();
}
}
@@ -1,44 +0,0 @@
@if (variant() === 'list') {
<mat-nav-list class="p-0 m-0">
@for (link of links(); track link.path.join('/')) {
<a
class="portal-rail-link"
mat-list-item
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
matTooltipPosition="right"
[attr.aria-label]="link.tooltip"
[attr.aria-current]="isActive(link) ? 'page' : null"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
"
>
<mat-icon matListItemIcon>{{ resolveIcon(link) }}</mat-icon>
</a>
}
</mat-nav-list>
} @else {
<nav class="flex flex-col items-center gap-2 w-full">
@for (link of links(); track link.path.join('/')) {
<a
class="portal-rail-link"
[routerLink]="link.path"
[routerLinkActive]="activeClass()"
[routerLinkActiveOptions]="{ exact: link.exact ?? false }"
[matTooltip]="link.tooltip"
[matTooltipPosition]="'right'"
[attr.aria-label]="link.tooltip"
[attr.aria-current]="isActive(link) ? 'page' : null"
[class.active]="activeClass() === 'active' && isActive(link)"
[class.is-active]="
activeClass() === 'is-active' && isActive(link)
"
>
<mat-icon>{{ resolveIcon(link) }}</mat-icon>
</a>
}
</nav>
}
@@ -1,121 +0,0 @@
:host {
display: block;
width: 100%;
--rail-link-color: var(--mat-sys-on-surface-variant);
--rail-link-hover-color: var(--mat-sys-on-surface);
--rail-link-hover-bg: var(--mat-sys-surface-container);
--rail-link-active-bg: var(--app-selection-surface);
--rail-link-active-bg-strong: var(--app-selection-surface-strong);
--rail-link-active-color: var(--app-selection-color);
--rail-link-active-icon: var(--app-selection-color);
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
}
.p-0.m-0 {
.mdc-list-item {
min-height: 28px;
height: 28px;
padding: 0 !important;
border-radius: 10px;
margin: 2px 0;
display: flex;
justify-content: center;
color: var(--rail-link-color);
transition:
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
.mdc-list-item__start {
margin-inline-end: 0 !important;
margin-inline-start: 0 !important;
}
.mdc-list-item__content {
display: none;
}
}
mat-icon.mat-mdc-list-item-icon,
mat-icon {
font-size: 18px !important;
width: 18px !important;
height: 18px !important;
}
.mdc-list-item.active,
.mdc-list-item.is-active {
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
color: var(--rail-link-active-color);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 8px 18px -14px var(--rail-link-active-glow);
transform: translateY(-1px);
mat-icon {
color: var(--rail-link-active-icon);
}
}
}
nav.flex .portal-rail-link {
width: 44px;
height: 44px;
border-radius: 14px;
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--rail-link-color);
text-decoration: none;
transition:
background-color 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
app-region: no-drag;
}
nav.flex .portal-rail-link:hover {
color: var(--rail-link-hover-color);
background: var(--rail-link-hover-bg);
}
nav.flex .portal-rail-link.is-active,
nav.flex .portal-rail-link.active {
color: var(--rail-link-active-color);
background: linear-gradient(
145deg,
var(--rail-link-active-bg-strong),
var(--rail-link-active-bg)
);
box-shadow:
inset 0 0 0 1px var(--rail-link-active-border),
0 10px 20px -16px var(--rail-link-active-glow);
transform: translateY(-1px);
}
nav.flex .portal-rail-link.is-active mat-icon,
nav.flex .portal-rail-link.active mat-icon {
color: var(--rail-link-active-icon);
}
nav.flex .portal-rail-link.is-active::after,
nav.flex .portal-rail-link.active::after {
content: '';
position: absolute;
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 20px;
border-radius: 999px;
background: var(--rail-link-active-icon);
box-shadow: 0 0 10px var(--rail-link-active-glow);
}
@@ -1,54 +0,0 @@
import { Component, inject, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatTooltip } from '@angular/material/tooltip';
import {
IsActiveMatchOptions,
Router,
RouterLink,
RouterLinkActive,
} from '@angular/router';
import { PortalRailLink, PortalRailSection } from './portal-rail-links';
@Component({
selector: 'app-portal-rail-links',
imports: [MatIcon, MatListModule, MatTooltip, RouterLink, RouterLinkActive],
templateUrl: './portal-rail-links.component.html',
styleUrl: './portal-rail-links.component.scss',
})
export class PortalRailLinksComponent {
private readonly router = inject(Router);
readonly links = input<PortalRailLink[]>([]);
readonly selectedSection = input<
PortalRailSection | string | null | undefined
>(null);
readonly activeClass = input<'active' | 'is-active'>('active');
readonly variant = input<'list' | 'rail'>('list');
resolveIcon(link: PortalRailLink): string {
const normalizedPath = link.path.map((segment) => String(segment)).join('/');
if (normalizedPath.includes('/workspace/sources')) {
return 'playlist_play';
}
return link.icon;
}
isActive(link: PortalRailLink): boolean {
const selectedSection = this.selectedSection();
if (selectedSection && link.section) {
return selectedSection === link.section;
}
const commands = link.path.map((segment) => String(segment));
const tree = this.router.createUrlTree(commands);
const matchOptions: IsActiveMatchOptions = {
paths: link.exact ? 'exact' : 'subset',
queryParams: 'ignored',
fragment: 'ignored',
matrixParams: 'ignored',
};
return this.router.isActive(tree, matchOptions);
}
}
@@ -1,107 +0,0 @@
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',
'recent',
'favorites',
'downloads',
]);
expect(links.primary[0]?.tooltip).toBe('Movies (this playlist)');
expect(links.secondary[3]?.tooltip).toBe('Favorites (this playlist)');
expect(links.secondary[4]?.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',
'recent',
'favorites',
]);
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
expect(links.secondary[2]?.tooltip).toBe('Favorites (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: 'list',
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',
},
{
icon: 'star',
tooltip: 'Favorites (this playlist)',
path: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
exact: true,
section: 'favorites',
},
]);
expect(links.secondary).toEqual([]);
});
});
@@ -1,206 +0,0 @@
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',
},
{
icon: 'history',
tooltip: 'Recently viewed (this playlist)',
path: [...root, 'recent'],
section: 'recent',
},
{
icon: 'favorite',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
section: 'favorites',
}
);
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',
},
{
icon: 'history',
tooltip: 'Recently viewed (this playlist)',
path: [...root, 'recent'],
section: 'recent',
},
{
icon: 'favorite',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
section: 'favorites',
},
];
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: 'list',
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',
},
{
icon: 'star',
tooltip: 'Favorites (this playlist)',
path: [...root, 'favorites'],
exact: true,
section: 'favorites',
},
];
return {
primary,
secondary: [],
};
}
return { primary: [], secondary: [] };
}
@@ -1,121 +0,0 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import {
extractPortalSection,
isWorkspaceLayoutRoute,
queryParamSignal,
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('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('prefers the child snapshot section and falls back to URL parsing', () => {
const routeWithChild = {
firstChild: {
snapshot: {
url: [{ path: 'vod' }],
},
},
} as unknown as ActivatedRoute;
const routeWithoutChild = {
firstChild: null,
} as unknown as ActivatedRoute;
expect(
resolveCurrentPortalSection(
routeWithChild,
'/workspace/stalker/123/search',
'stalker'
)
).toBe('vod');
expect(
resolveCurrentPortalSection(
routeWithoutChild,
'/workspace/stalker/123/search',
'stalker'
)
).toBe('search');
});
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();
});
});
@@ -1,57 +0,0 @@
import { Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ActivatedRoute } from '@angular/router';
import { map } from 'rxjs';
import {
PortalProvider,
PortalRailSection,
} from './portal-rail-links';
type QueryParamNormalizer<T> = (value: string | null) => T;
export function isWorkspaceLayoutRoute(route: ActivatedRoute): boolean {
return route.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 extractPortalSection(
url: string,
provider: PortalProvider
): PortalRailSection | null {
const match = url.match(
new RegExp(`^/(?:workspace/)?${provider}/[^/?]+/([^/?]+)`)
);
return (match?.[1] as PortalRailSection | undefined) ?? null;
}
export function resolveCurrentPortalSection(
route: ActivatedRoute,
routerUrl: string,
provider: PortalProvider
): PortalRailSection | null {
const sectionFromSnapshot =
route.firstChild?.snapshot?.url?.[0]?.path ?? null;
return (
(sectionFromSnapshot as PortalRailSection | null) ??
extractPortalSection(routerUrl, provider)
);
}
@@ -1,3 +1 @@
// Canonical source: libs/ui/styles/_content-grid.scss
// This stub forwards the mixins for components that @use this file directly.
@forward '../../../../../../libs/ui/styles/content-grid';
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/content-grid';
@@ -1,3 +1 @@
// Canonical source: libs/ui/styles/_portal-layout.scss
// This stub forwards the mixin for components that @use this file directly.
@forward '../../../../../../libs/ui/styles/portal-layout';
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-layout';
@@ -1,3 +1 @@
// Canonical source: libs/ui/styles/_portal-sidebar.scss
// This stub forwards the rules for components that @use this file directly.
@forward '../../../../../../libs/ui/styles/portal-sidebar';
@forward '../../../../../../libs/portal/shared/ui/src/lib/styles/portal-sidebar';
@@ -1,86 +0,0 @@
import { AppConfig } from '../../../environments/environment';
import {
createPortalDebugErrorEvent,
createPortalDebugRequestContext,
createPortalDebugSuccessEvent,
logPortalDebugEvent,
logPortalDebugRequest,
} from './logger';
describe('portal debug logger', () => {
const originalProduction = AppConfig.production;
beforeEach(() => {
AppConfig.production = false;
jest.spyOn(console, 'groupCollapsed').mockImplementation(() => {});
jest.spyOn(console, 'groupEnd').mockImplementation(() => {});
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
AppConfig.production = originalProduction;
jest.restoreAllMocks();
});
it('does not log when production is enabled', () => {
AppConfig.production = true;
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();
});
});
-177
View File
@@ -1,177 +0,0 @@
import { AppConfig } from '../../../environments/environment';
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;
}
export function createLogger(scope: string): Logger {
const prefix = `[${scope}]`;
const debugEnabled = !AppConfig.production;
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 !AppConfig.production;
}
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();
}
@@ -1,125 +0,0 @@
import {
createEnvironmentInjector,
EnvironmentInjector,
runInInjectionContext,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { createPortalCollectionContext } from './portal-collection-context';
describe('portal-collection-context', () => {
it('syncs categories into the favorites context', () => {
TestBed.configureTestingModule({
providers: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 2,
parent_id: 0,
},
]);
const bridge = runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
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: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
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,
})
);
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: [FavoritesContextService],
});
const parentInjector = TestBed.inject(EnvironmentInjector);
const childInjector = createEnvironmentInjector([], parentInjector);
const ctx = TestBed.inject(FavoritesContextService);
const categories = signal([
{
id: 1,
category_id: 'all',
category_name: 'All',
count: 3,
parent_id: 0,
},
]);
runInInjectionContext(childInjector, () =>
createPortalCollectionContext({
ctx,
categories,
})
);
ctx.setCategoryId('movie');
childInjector.destroy();
expect(ctx.categories()).toEqual([]);
expect(ctx.selectedCategoryId()).toBe('all');
});
});
@@ -1,46 +0,0 @@
import { DestroyRef, Signal, effect, inject } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
export interface PortalCollectionContextBridge {
readonly selectedCategoryId: Signal<string>;
setCategoryId(id: string): void;
}
interface CreatePortalCollectionContextOptions {
ctx: FavoritesContextService;
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);
}
},
};
}
@@ -1,138 +0,0 @@
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]]);
});
});
@@ -1,96 +0,0 @@
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)
);
}
@@ -4,8 +4,8 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { BehaviorSubject, map, switchMap } from 'rxjs';
import { PlaylistsService } from 'services';
import { StalkerStore } from '../stalker.store';
import { matchesFavoriteById } from '../stalker-vod.utils';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { matchesFavoriteById } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-favorites-button',
-8
View File
@@ -1,8 +0,0 @@
export * from './stalker-favorite-item.interface';
export * from './stalker-category-item.interface';
export * from './stalker-content-item.interface';
export * from './stalker-itv-channel.interface';
export * from './stalker-season.interface';
export * from './stalker-serial-details.interface';
export * from './stalker-vod-details.interface';
export * from './stalker-vod-series.interface';
@@ -1,4 +0,0 @@
export interface StalkerCategoryItem {
category_id: string;
category_name: string;
}
@@ -1,9 +0,0 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* Generic content item returned by Stalker category content endpoints.
* Used for VOD, series, and ITV category lists.
*/
export interface StalkerContentItem extends StalkerVodSource {
cover?: string;
}
@@ -1,67 +0,0 @@
import {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
export type StalkerSeriesFlag = true | 1 | '1';
/**
* Loose source shape coming from Stalker responses / favorites payloads.
* Keep optional to model heterogeneous portal responses.
*/
export interface StalkerVodSource {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
cmd?: string;
series?: unknown[];
has_files?: number;
is_series?: boolean | number | string | null | undefined;
video_id?: string;
category_id?: string;
title?: string;
cover?: string;
logo?: string;
screenshot_uri?: string;
name?: string;
o_name?: string;
description?: string;
actors?: string;
director?: string;
releasedate?: string;
year?: string;
genre?: string;
genres_str?: string;
rating_imdb?: string | number;
rating_kinopoisk?: string | number;
info?: Partial<StalkerVodInfo> | null;
}
/**
* Normalized VOD item used by Stalker detail/favorites flows.
*/
export interface StalkerSelectedVodItem extends StalkerVodDetails {
series?: unknown[];
has_files?: number;
is_series?: true;
video_id?: string;
category_id?: string;
}
/**
* Favorite item persisted for Stalker portal.
*/
export interface StalkerFavoriteItem {
id?: string | number;
stream_id?: string | number;
movie_id?: string | number;
series_id?: string | number;
category_id?: string;
cmd?: string;
name?: string;
o_name?: string;
logo?: string;
details?: StalkerSelectedVodItem;
[key: string]: unknown;
}
@@ -1,13 +0,0 @@
import { StalkerVodSource } from './stalker-favorite-item.interface';
/**
* ITV/live channel shape used by Stalker live TV flow.
* Stalker channel payloads are heterogeneous, so this extends the shared source shape.
*/
export interface StalkerItvChannel extends StalkerVodSource {
id: string | number;
cmd: string;
name?: string;
o_name?: string;
logo?: string;
}
@@ -1,17 +0,0 @@
export interface StalkerSeason {
id: string;
series: string[];
name: string;
cmd: string;
description: string;
director: string;
actors: string;
year: string;
genres_str: string;
age: string;
rating_imdb: string;
rating_kinopoisk: string;
screenshot_uri: string;
added: string;
}
@@ -1,5 +0,0 @@
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export { StalkerSerialDetails } from 'shared-interfaces';
@@ -1,8 +0,0 @@
/**
* @deprecated Import from 'shared-interfaces' instead.
* This re-export exists for backward compatibility.
*/
export {
StalkerVodDetails,
StalkerVodInfo,
} from 'shared-interfaces';
@@ -1,24 +0,0 @@
/**
* Season item returned by Stalker VOD-series (Ministra is_series=1) endpoints.
*/
export interface StalkerVodSeriesSeason {
id: string;
video_id: string;
season_number?: string;
name?: string;
is_season?: boolean;
}
/**
* Episode item returned by Stalker VOD-series season endpoints.
*/
export interface StalkerVodSeriesEpisode {
id: string;
series_number?: number;
episode_num?: number;
name?: string;
cover?: string;
description?: string;
duration?: number | string;
is_episode?: boolean;
}
@@ -11,25 +11,25 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
} from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { createLogger } from '../../shared/utils/logger';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
@@ -40,9 +40,9 @@ import {
isSelectedStalkerVodFavorite,
normalizeStalkerVodDetailsItem,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '../stalker.store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_RECENT_LAYOUT: Omit<
PortalCollectionShellLayout,
@@ -13,9 +13,9 @@ import { MatIconModule } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ChannelListItemComponent } from 'components';
import { EpgItem, EpgProgram } from 'shared-interfaces';
import { StalkerVodSource } from '../models';
import { normalizeStalkerEntityId } from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
import { StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-collection-channels-list',
@@ -1,32 +0,0 @@
import { StalkerPortalActions } from 'shared-interfaces';
export const StalkerContentTypes = {
stb: {
doAuth: 'do_auth',
handshake: 'handshake',
},
itv: {
title: 'Live streams',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetGenres,
getLink: StalkerPortalActions.CreateLink,
},
vod: {
title: 'VOD streams',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
},
series: {
title: 'Series',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetCategories,
getLink: StalkerPortalActions.CreateLink,
},
/* radio: {
title: 'Radio',
getContentAction: StalkerPortalActions.GetOrderedList,
getCategoryAction: StalkerPortalActions.GetOrderedList,
getLink: StalkerPortalActions.CreateLink,
}, */
};
@@ -11,22 +11,22 @@ import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import { EpgItem, VodDetailsItem } from 'shared-interfaces';
import { PlayerService } from '../../services/player.service';
import { PortalCollectionLiveShellComponent } from '../../shared/components/portal-collection-live-shell/portal-collection-live-shell.component';
import { PortalCollectionLiveShellComponent } from '@iptvnator/portal/shared/ui';
import {
PortalCollectionMode,
PortalCollectionShellComponent,
PortalCollectionShellLayout,
} from '../../shared/components/portal-collection-shell/portal-collection-shell.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
import { queryParamSignal } from '../../shared/navigation/portal-route.utils';
import { createPortalCollectionContext } from '../../shared/utils/portal-collection-context';
} from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { queryParamSignal } from '@iptvnator/portal/shared/util';
import { createPortalCollectionContext } from '@iptvnator/portal/shared/util';
import {
buildStandardCollectionCategories,
filterCollectionBucket,
} from '../../shared/utils/portal-collection-items';
import { createLogger } from '../../shared/utils/logger';
import { FavoritesContextService } from '../../workspace/favorites-context.service';
import { StalkerFavoriteItem } from '../models';
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { FavoritesContextService } from '@iptvnator/portal/shared/util';
import { StalkerFavoriteItem } from '@iptvnator/portal/stalker/data-access';
import {
clearStalkerDetailViewState,
createStalkerInlineDetailState,
@@ -38,9 +38,9 @@ import {
normalizeStalkerEntityId,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
} from '@iptvnator/portal/stalker/data-access';
import { StalkerCollectionChannelsListComponent } from '../stalker-collection-channels-list/stalker-collection-channels-list.component';
import { StalkerStore } from '../stalker.store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
const STALKER_FAVORITES_LAYOUT: PortalCollectionShellLayout = {};
@@ -5,11 +5,11 @@ import {
input,
output,
} from '@angular/core';
import { ExternalPlaybackService } from '../../../services/external-playback.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { VodDetailsItem } from 'shared-interfaces';
import { VodDetailsComponent } from '../../../xtream-electron/vod-details/vod-details.component';
import { StalkerSelectedVodItem } from '../../../stalker/models/stalker-favorite-item.interface';
import { StalkerSeriesViewComponent } from '../../../stalker/stalker-series-view/stalker-series-view.component';
import { VodDetailsComponent } from '../../xtream-electron/vod-details/vod-details.component';
import { StalkerSelectedVodItem } from '@iptvnator/portal/stalker/data-access';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
@Component({
selector: 'app-stalker-inline-detail',
@@ -1,94 +0,0 @@
import { Playlist } from 'shared-interfaces';
export const STALKER_MAG_USER_AGENT =
'Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250';
export const STALKER_STREAM_USER_AGENT = 'KSPlayer';
export function getStalkerPortalOrigin(
playlist: Playlist | undefined | null
): string | undefined {
const portalUrl = playlist?.portalUrl;
if (!portalUrl) {
return undefined;
}
try {
return new URL(portalUrl).origin;
} catch {
return undefined;
}
}
export function isCrossOriginStalkerStream(
playlist: Playlist | undefined | null,
streamUrl?: string
): boolean {
const portalOrigin = getStalkerPortalOrigin(playlist);
if (!portalOrigin || !streamUrl) {
return false;
}
try {
return new URL(streamUrl).origin !== portalOrigin;
} catch {
return false;
}
}
export function buildStalkerExternalPlaybackHeaders(
playlist: Playlist | undefined | null,
token?: string | null,
streamUrl?: string
): Record<string, string> {
if (!playlist?.macAddress) {
return {};
}
if (isCrossOriginStalkerStream(playlist, streamUrl)) {
return {
'User-Agent': STALKER_STREAM_USER_AGENT,
Accept: '*/*',
Range: 'bytes=0-',
Connection: 'keep-alive',
'Icy-MetaData': '1',
};
}
const cookieParts = [
`mac=${playlist.macAddress}`,
'stb_lang=en_US@rg=dezzzz',
'timezone=Europe/Berlin',
];
if (playlist.stalkerSerialNumber) {
cookieParts.push(
`__cfduid=${playlist.stalkerSerialNumber.toLowerCase()}e030245495acd6ebfc1`
);
}
const headers: Record<string, string> = {
Cookie: cookieParts.join('; '),
'X-User-Agent': STALKER_MAG_USER_AGENT,
SN: playlist.stalkerSerialNumber || '',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const origin = getStalkerPortalOrigin(playlist);
if (origin) {
headers['Origin'] = origin;
headers['Referer'] = origin;
}
return Object.entries(headers).reduce<Record<string, string>>(
(acc, [name, value]) => {
if (value?.trim()) {
acc[name] = value;
}
return acc;
},
{}
);
}
@@ -31,22 +31,22 @@ import { EpgItem, EpgProgram } from 'shared-interfaces';
import { EpgViewComponent, WebPlayerViewComponent } from 'shared-portals';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { CategoryViewComponent } from '../../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../../shared/components/playlist-error-view/playlist-error-view.component';
import { PortalEmptyStateComponent } from '../../shared/components/portal-empty-state/portal-empty-state.component';
import { isWorkspaceLayoutRoute } from '../../shared/navigation/portal-route.utils';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import {
getAdjacentChannelItem,
getChannelItemByNumber,
} from '../../shared/services/remote-channel-navigation.util';
import { createLogger } from '../../shared/utils/logger';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerCategoryItem,
StalkerFavoriteItem,
StalkerItvChannel,
} from '../models';
import { normalizeStalkerEntityId } from '../stalker-vod.utils';
import { StalkerStore } from '../stalker.store';
} from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerEntityId } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-live-stream-layout',
@@ -5,10 +5,10 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
import { isWorkspaceLayoutRoute } from '../shared/navigation/portal-route.utils';
import { StalkerStore } from './stalker.store';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { isWorkspaceLayoutRoute } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-main-container',
@@ -12,20 +12,20 @@ import {
StalkerPortalActions,
VodDetailsItem,
} from 'shared-interfaces';
import { ContentCardComponent } from '../../shared/components/content-card/content-card.component';
import { SearchLayoutComponent } from '../../shared/components/search-layout/search-layout.component';
import { StalkerInlineDetailComponent } from '../../shared/components/stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '../stalker-content-types';
import { StalkerStore } from '../stalker.store';
import { ContentCardComponent } from '@iptvnator/portal/shared/ui';
import { SearchLayoutComponent } from '@iptvnator/portal/shared/ui';
import { StalkerInlineDetailComponent } from '../stalker-inline-detail/stalker-inline-detail.component';
import { StalkerContentTypes } from '@iptvnator/portal/stalker/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
isWorkspaceLayoutRoute,
queryParamSignal,
} from '../../shared/navigation/portal-route.utils';
import { createLogger } from '../../shared/utils/logger';
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
StalkerSelectedVodItem,
StalkerVodSource,
} from '../models';
} from '@iptvnator/portal/stalker/data-access';
import {
buildStalkerSelectedVodItem,
clearStalkerDetailViewState,
@@ -36,7 +36,7 @@ import {
isSelectedStalkerVodFavorite,
isStalkerSeriesFlag,
toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
} from '@iptvnator/portal/stalker/data-access';
interface StalkerFilter {
key: string;
@@ -11,14 +11,14 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
import { ContentHeroComponent } from 'components';
import { StalkerStore } from '../stalker.store';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { SeasonContainerComponent } from '../../xtream-electron/season-container/season-container.component';
import {
ResolvedPortalPlayback,
XtreamSerieEpisode,
} from 'shared-interfaces';
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
import { createLogger } from '../../shared/utils/logger';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { createLogger } from '@iptvnator/portal/shared/util';
import {
getVodSeriesSeasonKey,
isVodSeriesItem,
@@ -29,9 +29,9 @@ import {
StalkerMappedEpisode,
StalkerSeriesSeasonVm,
VodSeriesSeasonVm,
} from '../stalker-series.adapters';
import { StalkerSelectedVodItem, StalkerVodSource } from '../models';
import { normalizeStalkerVodDetailsItem } from '../stalker-vod.utils';
} from '@iptvnator/portal/stalker/data-access';
import { StalkerSelectedVodItem, StalkerVodSource } from '@iptvnator/portal/stalker/data-access';
import { normalizeStalkerVodDetailsItem } from '@iptvnator/portal/stalker/data-access';
import { PlayerService } from '../../services/player.service';
import { ExternalPlaybackService } from '../../services/external-playback.service';
import { PortalInlinePlayerComponent } from '../../shared/components/portal-inline-player/portal-inline-player.component';
@@ -1,90 +0,0 @@
import {
isVodSeriesItem,
mapRegularSeriesEpisodes,
mapRegularSeriesSeasons,
mapVodSeriesEpisodes,
mapVodSeriesSeasonsToVm,
} from './stalker-series.adapters';
import { StalkerSelectedVodItem } from './models';
describe('stalker-series.adapters', () => {
it('detects vod-series flags from heterogeneous payloads', () => {
expect(isVodSeriesItem({ is_series: true })).toBe(true);
expect(isVodSeriesItem({ is_series: 1 })).toBe(true);
expect(isVodSeriesItem({ is_series: '1' })).toBe(true);
expect(isVodSeriesItem({ is_series: false })).toBe(false);
});
it('maps VOD-series seasons to VM shape with defaults', () => {
const seasons = mapVodSeriesSeasonsToVm([
{
id: '10',
video_id: '20',
season_number: '2',
},
]);
expect(seasons).toEqual([
{
id: '10',
video_id: '20',
season_number: '2',
name: 'Season 2',
episodes: [],
isLoading: false,
isExpanded: false,
},
]);
});
it('maps VOD-series episodes with stable tracking IDs and originalId metadata', () => {
const mapped = mapVodSeriesEpisodes(
[
{
id: 's1',
video_id: 'v1',
name: 'Season 1',
season_number: '1',
episodes: [
{
id: 'api-shared-id',
series_number: 1,
name: 'Episode 1',
},
{
id: 'api-shared-id',
series_number: 2,
name: 'Episode 2',
},
],
isLoading: false,
isExpanded: false,
},
],
'poster.jpg'
);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('vod-series');
expect((mapped['1'][0] as any).originalId).toBe('api-shared-id');
expect(mapped['1'][0].id).not.toBe(mapped['1'][1].id);
});
it('maps embedded series payload into regular season episodes', () => {
const regularSeasons = mapRegularSeriesSeasons(
{
id: '100',
cmd: '/media/file_100.mpg',
series: ['1', 2, 'x'],
info: { name: 'Embedded Series' },
} as StalkerSelectedVodItem,
[]
);
const mapped = mapRegularSeriesEpisodes(regularSeasons, 'poster.jpg');
expect(regularSeasons[0].series).toEqual([1, 2]);
expect(mapped['1']).toHaveLength(2);
expect(mapped['1'][0].custom_sid).toBe('regular-series');
expect((mapped['1'][0] as any).originalCmd).toBe('/media/file_100.mpg');
});
});
@@ -1,232 +0,0 @@
import { XtreamSerieEpisode } from 'shared-interfaces';
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
StalkerVodSource,
} from './models';
import { isStalkerSeriesFlag } from './stalker-vod.utils';
export interface VodSeriesSeasonVm {
id: string;
video_id: string;
name: string;
season_number: string;
episodes: StalkerVodSeriesEpisode[];
isLoading: boolean;
isExpanded: boolean;
}
export interface StalkerSeriesSeasonVm {
id: string;
name: string;
cmd?: string;
series: number[];
}
export interface StalkerMappedEpisode extends XtreamSerieEpisode {
originalId?: string;
originalCmd?: string;
}
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash);
}
function generateEpisodeId(
seed: string,
episodeNum: number,
seasonKey: string,
isVodSeries: boolean
): number {
if (isVodSeries) {
return hashString(`vod_${seasonKey}_${episodeNum}`);
}
return hashString(`${seed}_ep_${episodeNum}`);
}
function toEpisodeNumber(value: unknown): number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : 0;
}
if (typeof value === 'string') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
return 0;
}
function toNonEmptyString(value: unknown, fallback: string): string {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
return fallback;
}
function toSeriesNumbers(values: unknown[] | undefined): number[] {
if (!Array.isArray(values)) {
return [];
}
return values
.map((value) => toEpisodeNumber(value))
.filter((value) => value > 0);
}
export function isVodSeriesItem(
item: Pick<StalkerVodSource, 'is_series'> | null | undefined
): boolean {
return isStalkerSeriesFlag(item?.is_series);
}
export function mapVodSeriesSeasonsToVm(
seasons: StalkerVodSeriesSeason[] | undefined
): VodSeriesSeasonVm[] {
return (seasons ?? []).map((season) => ({
id: String(season.id),
video_id: String(season.video_id),
name: toNonEmptyString(
season.name,
`Season ${toNonEmptyString(season.season_number, '0')}`
),
season_number: toNonEmptyString(season.season_number, ''),
episodes: [],
isLoading: false,
isExpanded: false,
}));
}
export function mapRegularSeriesSeasons(
vodWithSeries: StalkerVodSource | null,
serialSeasons: StalkerSeason[] | undefined
): StalkerSeriesSeasonVm[] {
if (vodWithSeries?.series && vodWithSeries.series.length > 0) {
return [
{
id: String(vodWithSeries.id),
name: vodWithSeries.info?.name || 'Episodes',
cmd: vodWithSeries.cmd,
series: toSeriesNumbers(vodWithSeries.series),
},
];
}
return (serialSeasons ?? []).map((season) => ({
id: String(season.id),
name: season.name,
cmd: season.cmd,
series: toSeriesNumbers(season.series),
}));
}
function createBaseEpisode(
id: number,
episodeNum: number,
title: string,
containerExtension: string,
customSid: string,
season: number,
info: XtreamSerieEpisode['info']
): XtreamSerieEpisode {
return {
id: String(id),
episode_num: episodeNum,
title,
container_extension: containerExtension,
info,
custom_sid: customSid,
added: '',
season,
direct_source: '',
};
}
export function mapVodSeriesEpisodes(
seasons: ReadonlyArray<VodSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season) => {
const seasonKey = season.season_number || season.name || season.id;
const seasonNum = toEpisodeNumber(seasonKey) || 1;
mapped[seasonKey] = (season.episodes ?? []).map((episode) => {
const episodeNum =
toEpisodeNumber(episode.series_number) ||
toEpisodeNumber(episode.episode_num);
const trackingId = generateEpisodeId(
String(episode.id ?? ''),
episodeNum,
seasonKey,
true
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
episode.name || `Episode ${episodeNum}`,
'mpg',
'vod-series',
seasonNum,
{
movie_image: episode.cover || fallbackPoster,
plot: episode.description || '',
duration: episode.duration
? `${episode.duration} min`
: '',
}
),
originalId: String(episode.id ?? ''),
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function mapRegularSeriesEpisodes(
seasons: ReadonlyArray<StalkerSeriesSeasonVm>,
fallbackPoster?: string
): Record<string, XtreamSerieEpisode[]> {
const mapped: Record<string, XtreamSerieEpisode[]> = {};
seasons.forEach((season, index) => {
const seasonKey = String(index + 1);
mapped[seasonKey] = (season.series ?? []).map((episodeNum) => {
const trackingId = generateEpisodeId(
String(season.cmd ?? ''),
episodeNum,
seasonKey,
false
);
return {
...createBaseEpisode(
trackingId,
episodeNum,
`Episode ${episodeNum}`,
'',
'regular-series',
Number(seasonKey),
{
movie_image: fallbackPoster,
}
),
originalCmd: season.cmd,
} as StalkerMappedEpisode;
});
});
return mapped;
}
export function getVodSeriesSeasonKey(season: VodSeriesSeasonVm): string {
return season.season_number || season.name || season.id;
}
@@ -9,15 +9,15 @@ import {
import { Store } from '@ngrx/store';
import { PlaylistActions, selectPlaylistById } from 'm3u-state';
import { map, switchMap } from 'rxjs';
import { PortalRailSection } from '../../shared/navigation/portal-rail-links';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
isWorkspaceLayoutRoute,
resolveCurrentPortalSection,
} from '../../shared/navigation/portal-route.utils';
import { createLogger } from '../../shared/utils/logger';
import { NavigationComponent } from '../../xtream-electron/navigation/navigation.component';
import { XtreamStore } from '../../xtream-electron/stores/xtream.store';
import { StalkerStore } from '../stalker.store';
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { NavigationComponent } from '../../portal-shared/navigation/navigation.component';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
@Component({
selector: 'app-stalker-shell',
@@ -1,168 +0,0 @@
import { VodDetailsItem } from 'shared-interfaces';
import {
createStalkerInlineDetailState,
createStalkerDetailViewState,
normalizeStalkerFavoriteItem,
toggleStalkerVodFavorite,
} from './stalker-vod.utils';
describe('stalker-vod.utils regressions', () => {
it('routes embedded series[] items to series view state', () => {
const state = createStalkerDetailViewState(
{
id: '101',
cmd: '/media/file_101.mpg',
series: [1, 2, 3],
info: {
name: 'Series in VOD',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.series?.length).toBe(3);
expect(state.vodDetailsItem).toBeNull();
});
it('routes is_series=1 items to series view state (lazy season flow)', () => {
const state = createStalkerDetailViewState(
{
id: '1507',
cmd: '/media/file_1507.mpg',
is_series: true,
info: {
name: 'Flagged Series',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.is_series).toBe(true);
expect(state.vodDetailsItem).toBeNull();
});
it('favorite toggle uses completion callback path without delayed state update', () => {
const addToFavorites = jest.fn(
(_item: Record<string, unknown>, onDone?: () => void) => {
onDone?.();
}
);
const removeFromFavorites = jest.fn(
(_id: string, onDone?: () => void) => {
onDone?.();
}
);
const onComplete = jest.fn();
const item = {
type: 'stalker',
data: {
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
playlistId: 'playlist-1',
vodId: 42,
cmd: '/media/file_42.mpg',
} as unknown as VodDetailsItem;
toggleStalkerVodFavorite(
{ item, isFavorite: true },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(addToFavorites).toHaveBeenCalledTimes(1);
expect(removeFromFavorites).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalledTimes(1);
onComplete.mockClear();
toggleStalkerVodFavorite(
{ item, isFavorite: false },
{ addToFavorites, removeFromFavorites, onComplete }
);
expect(removeFromFavorites).toHaveBeenCalledTimes(1);
expect(onComplete).toHaveBeenCalledTimes(1);
});
it('normalizes nested favorite details for the shared inline detail shell', () => {
const detailState = createStalkerInlineDetailState(
normalizeStalkerFavoriteItem({
id: '7',
category_id: 'vod',
name: 'Nested Movie',
details: {
id: '7',
cmd: '/media/file_7.mpg',
series: [11, 12],
info: {
name: 'Nested Movie',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
} as any),
null
);
expect(detailState.categoryId).toBe('vod');
expect(detailState.seriesItem?.series).toEqual([11, 12]);
expect(detailState.isSeries).toBe(false);
expect(detailState.vodDetailsItem).toBeNull();
});
it('allows search to force series mode when results omit category metadata', () => {
const detailState = createStalkerInlineDetailState(
{
id: '9',
cmd: '/media/file_9.mpg',
info: {
name: 'Series Result',
movie_image: '',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
null,
'series'
);
expect(detailState.categoryId).toBe('series');
expect(detailState.seriesItem?.id).toBe('9');
});
});
@@ -1,373 +0,0 @@
import {
signal,
WritableSignal,
} from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { PlaylistsService } from 'services';
import {
createStalkerVodItem,
StalkerVodDetails,
StalkerVodInfo,
VodDetailsItem,
} from 'shared-interfaces';
import {
StalkerFavoriteItem,
StalkerSelectedVodItem,
StalkerSeriesFlag,
StalkerVodSource,
} from './models/stalker-favorite-item.interface';
export interface StalkerDetailViewState {
itemDetails: StalkerSelectedVodItem | null;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerInlineDetailState {
categoryId: 'vod' | 'series' | null;
seriesItem: StalkerSelectedVodItem | null;
isSeries: boolean;
vodDetailsItem: VodDetailsItem | null;
}
export interface StalkerFavoriteToggleEvent {
item: VodDetailsItem;
isFavorite: boolean;
}
export interface StalkerFavoriteToggleOptions {
addToFavorites: (
item: Record<string, unknown>,
onComplete?: () => void
) => void;
removeFromFavorites: (
favoriteId: string,
onComplete?: () => void
) => void;
onComplete?: () => void;
}
export function isStalkerSeriesFlag(value: unknown): boolean {
return value === true || value === 1 || value === '1';
}
export function normalizeStalkerSeriesFlag(
value: unknown
): StalkerSeriesFlag | undefined {
if (value === true || value === 1 || value === '1') {
return true;
}
return undefined;
}
export function isStalkerSeriesItem(item: {
is_series?: unknown;
series?: unknown;
}): boolean {
return (
isStalkerSeriesFlag(item?.is_series) ||
(Array.isArray(item?.series) && item.series.length > 0)
);
}
export function buildStalkerFavoritePayload(
data: StalkerVodDetails
): Record<string, unknown> {
return {
...data,
category_id: 'vod',
title: data.info?.name,
cover: data.info?.movie_image,
added_at: new Date().toISOString(),
};
}
export function matchesFavoriteById(
favorite: Partial<StalkerFavoriteItem>,
itemId: string | number
): boolean {
const expectedId = normalizeStalkerEntityId(itemId);
return (
normalizeStalkerEntityId(favorite?.id) === expectedId ||
normalizeStalkerEntityId(favorite?.movie_id) === expectedId ||
normalizeStalkerEntityId(favorite?.stream_id) === expectedId ||
normalizeStalkerEntityId(favorite?.series_id) === expectedId
);
}
export function normalizeStalkerEntityId(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
return String(value).trim();
}
export function normalizeStalkerEntityIdAsNumber(
value: unknown
): number | null {
const normalized = normalizeStalkerEntityId(value);
if (!normalized) {
return null;
}
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function toStringOrFallback(value: unknown, fallback = ''): string {
if (typeof value === 'string') return value;
if (value === null || value === undefined) return fallback;
return String(value);
}
function toOptionalString(value: unknown): string | undefined {
if (value === null || value === undefined || value === '') {
return undefined;
}
return String(value);
}
export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo {
const info = item.info ?? {};
return {
name: toStringOrFallback(
info.name ?? info.o_name ?? item.o_name ?? item.name ?? item.title,
'Unknown'
),
o_name: toOptionalString(info.o_name ?? item.o_name),
movie_image: toStringOrFallback(
info.movie_image ?? item.cover ?? item.screenshot_uri ?? item.logo
),
description: toStringOrFallback(info.description ?? item.description),
actors: toStringOrFallback(info.actors ?? item.actors),
director: toStringOrFallback(info.director ?? item.director),
releasedate: toStringOrFallback(
info.releasedate ?? item.releasedate ?? item.year
),
genre: toStringOrFallback(info.genre ?? item.genre ?? item.genres_str),
rating_imdb: toStringOrFallback(info.rating_imdb ?? item.rating_imdb),
rating_kinopoisk: toStringOrFallback(
info.rating_kinopoisk ?? item.rating_kinopoisk
),
};
}
export function buildStalkerSelectedVodItem(
item: StalkerVodSource,
forceSeries = false
): StalkerSelectedVodItem {
return {
id: toStringOrFallback(item.id ?? item.stream_id),
cmd: toStringOrFallback(item.cmd),
series: item.series,
has_files: item.has_files,
is_series:
forceSeries || isStalkerSeriesFlag(item?.is_series)
? true
: undefined,
video_id: item.video_id,
category_id:
typeof item.category_id === 'string'
? item.category_id
: undefined,
info: createStalkerInfo(item),
};
}
export interface NormalizedStalkerFavoriteItem extends StalkerFavoriteItem {
details: StalkerSelectedVodItem;
}
export function normalizeStalkerFavoriteItem(
item: StalkerFavoriteItem
): NormalizedStalkerFavoriteItem {
if (item.details && item.details.info) {
return item as NormalizedStalkerFavoriteItem;
}
const source = (item.details || item) as StalkerVodSource;
const info = createStalkerInfo({
...item,
...source,
});
const normalizedIsSeries = normalizeStalkerSeriesFlag(
(item as StalkerVodSource).is_series ?? source.is_series
);
return {
...item,
details: {
...source,
info,
cmd: toStringOrFallback(
(item as StalkerVodSource).cmd ?? source.cmd
),
id: toStringOrFallback(item.stream_id ?? item.id ?? source.id),
series: (item as StalkerVodSource).series ?? source.series,
is_series: normalizedIsSeries ? true : undefined,
video_id:
(item as StalkerVodSource).video_id ?? source.video_id,
category_id:
typeof (item as StalkerVodSource).category_id === 'string'
? (item as StalkerVodSource).category_id
: typeof source.category_id === 'string'
? source.category_id
: undefined,
},
};
}
export function normalizeStalkerVodDetailsItem(
item: StalkerVodSource
): StalkerSelectedVodItem {
return {
...buildStalkerSelectedVodItem(item),
info: createStalkerInfo(item),
is_series: normalizeStalkerSeriesFlag(item?.is_series)
? true
: undefined,
};
}
export function createStalkerInlineDetailState(
itemDetails: StalkerSelectedVodItem | NormalizedStalkerFavoriteItem | null,
vodDetailsItem: VodDetailsItem | null,
categoryOverride?: 'vod' | 'series' | null
): StalkerInlineDetailState {
if (!itemDetails) {
return {
categoryId: null,
seriesItem: null,
isSeries: false,
vodDetailsItem: null,
};
}
const seriesItem =
'details' in itemDetails ? itemDetails.details : itemDetails;
const rawCategoryId =
categoryOverride ??
(typeof itemDetails.category_id === 'string'
? itemDetails.category_id
: typeof seriesItem.category_id === 'string'
? seriesItem.category_id
: undefined);
const categoryId =
rawCategoryId === 'vod' || rawCategoryId === 'movie'
? 'vod'
: rawCategoryId === 'series'
? 'series'
: null;
return {
categoryId,
seriesItem,
isSeries: isStalkerSeriesFlag(seriesItem?.is_series),
vodDetailsItem,
};
}
export function createStalkerDetailViewState(
normalizedItem: StalkerSelectedVodItem,
playlistId: string
): StalkerDetailViewState {
if (isStalkerSeriesItem(normalizedItem)) {
return {
itemDetails: normalizedItem,
vodDetailsItem: null,
};
}
return {
itemDetails: normalizedItem,
vodDetailsItem: createStalkerVodItem(
normalizedItem as StalkerVodDetails,
playlistId
),
};
}
export function clearStalkerDetailViewState(): StalkerDetailViewState {
return {
itemDetails: null,
vodDetailsItem: null,
};
}
export function createRefreshTrigger(): {
refreshVersion: WritableSignal<number>;
refresh: () => void;
} {
const refreshVersion = signal<number>(Date.now());
return {
refreshVersion,
refresh: () => refreshVersion.set(Date.now()),
};
}
export function createPortalFavoritesResource(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number
) {
return createPortalCollectionResource(
playlistService,
getPortalId,
getRefreshVersion,
(service, portalId) => service.getPortalFavorites(portalId)
);
}
export function createPortalCollectionResource<T>(
playlistService: PlaylistsService,
getPortalId: () => string | undefined,
getRefreshVersion: () => number,
streamFactory: (
playlistService: PlaylistsService,
portalId: string
) => Observable<T[]>
) {
return rxResource({
params: () => {
const portalId = getPortalId();
if (!portalId) return undefined;
return {
portalId,
refreshVersion: getRefreshVersion(),
};
},
stream: ({ params }) =>
streamFactory(playlistService, params.portalId),
});
}
export function isSelectedStalkerVodFavorite(
item: VodDetailsItem | null,
favorites: ReadonlyArray<StalkerFavoriteItem> | undefined
): boolean {
if (!item || item.type !== 'stalker') {
return false;
}
return (favorites ?? []).some((favorite) =>
matchesFavoriteById(favorite, item.data.id)
);
}
export function toggleStalkerVodFavorite(
event: StalkerFavoriteToggleEvent,
options: StalkerFavoriteToggleOptions
): void {
if (event.item.type !== 'stalker') {
return;
}
if (event.isFavorite) {
options.addToFavorites(
buildStalkerFavoritePayload(event.item.data),
options.onComplete
);
} else {
options.removeFromFavorites(event.item.data.id, options.onComplete);
}
}
+5 -5
View File
@@ -28,7 +28,7 @@ export const stalkerRoutes: Route[] = [
},
loadComponent: () =>
import(
'../xtream-electron/category-content-view/category-content-view.component'
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
{
@@ -39,7 +39,7 @@ export const stalkerRoutes: Route[] = [
},
loadComponent: () =>
import(
'../xtream-electron/category-content-view/category-content-view.component'
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
},
],
@@ -68,7 +68,7 @@ export const stalkerRoutes: Route[] = [
path: '',
loadComponent: () =>
import(
'../xtream-electron/category-content-view/category-content-view.component'
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
data: {
api: 'stalker',
@@ -79,7 +79,7 @@ export const stalkerRoutes: Route[] = [
path: ':categoryId',
loadComponent: () =>
import(
'../xtream-electron/category-content-view/category-content-view.component'
'../portal-shared/category-content-view/category-content-view.component'
).then((c) => c.CategoryContentViewComponent),
data: {
api: 'stalker',
@@ -113,7 +113,7 @@ export const stalkerRoutes: Route[] = [
path: 'downloads',
loadComponent: () =>
import(
'../xtream-electron/downloads/downloads.component'
'@iptvnator/portal/downloads/feature'
).then((c) => c.DownloadsComponent),
},
],
@@ -1,163 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
DataService,
PlaylistsService,
StalkerSessionService,
} from 'services';
import { PlayerService } from '../services/player.service';
import { StalkerStore } from './stalker.store';
describe('StalkerStore API compatibility smoke', () => {
let store: any;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
StalkerStore,
{
provide: DataService,
useValue: {
isElectron: false,
sendIpcEvent: jest.fn(),
},
},
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
},
},
{
provide: PlayerService,
useValue: {
openPlayer: jest.fn(),
openResolvedPlayback: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: {
addPortalFavorite: jest.fn(() => of(undefined)),
removeFromPortalFavorites: jest.fn(() => of(undefined)),
addPortalRecentlyViewed: jest.fn(() => of(undefined)),
removeFromPortalRecentlyViewed: jest.fn(() =>
of(undefined)
),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: jest.fn((key: string) => key),
},
},
],
});
store = TestBed.inject(StalkerStore);
});
it('exposes compatibility state signals', () => {
const expectedSignals = [
'selectedContentType',
'selectedCategoryId',
'selectedVodId',
'selectedSerialId',
'selectedItvId',
'limit',
'page',
'searchPhrase',
'currentPlaylist',
'totalCount',
'selectedItem',
'vodCategories',
'seriesCategories',
'itvCategories',
'hasMoreChannels',
'itvChannels',
'vodSeriesSeasons',
'vodSeriesEpisodes',
'selectedVodSeriesSeasonId',
];
for (const signalName of expectedSignals) {
expect(typeof store[signalName]).toBe('function');
}
});
it('exposes compatibility computed selectors', () => {
const expectedComputed = [
'getTotalPages',
'getPaginatedContent',
'isPaginatedContentLoading',
'isPaginatedContentFailed',
'getSerialSeasonsResource',
'isSerialSeasonsLoading',
'getVodSeriesSeasonsResource',
'isVodSeriesSeasonsLoading',
'getCategoryResource',
'isCategoryResourceLoading',
'isCategoryResourceFailed',
'getSelectedCategoryName',
];
for (const computedName of expectedComputed) {
expect(typeof store[computedName]).toBe('function');
}
});
it('exposes compatibility methods and internal resources', () => {
const expectedMethods = [
'setSelectedContentType',
'setSelectedCategory',
'setSelectedSerialId',
'setSelectedVodId',
'setSelectedItvId',
'setLimit',
'setPage',
'setCurrentPlaylist',
'setSelectedItem',
'clearSelectedItem',
'setCategories',
'resetCategories',
'setItvChannels',
'setSearchPhrase',
'fetchVodSeriesEpisodes',
'getSelectedCategory',
'fetchLinkToPlay',
'getExpireDate',
'addToFavorites',
'removeFromFavorites',
'fetchMovieFileId',
'resolveVodPlayback',
'createLinkToPlayVod',
'addToRecentlyViewed',
'removeFromRecentlyViewed',
'fetchChannelEpg',
'makeStalkerRequest',
];
for (const methodName of expectedMethods) {
expect(typeof store[methodName]).toBe('function');
}
expect(store.getContentResource).toBeDefined();
expect(typeof store.getContentResource.value).toBe('function');
expect(store.serialSeasonsResource).toBeDefined();
expect(typeof store.serialSeasonsResource.value).toBe('function');
expect(store.vodSeriesSeasonsResource).toBeDefined();
expect(typeof store.vodSeriesSeasonsResource.value).toBe('function');
});
});
-28
View File
@@ -1,28 +0,0 @@
import { signalStore } from '@ngrx/signals';
import {
withStalkerContent,
withStalkerEpg,
withStalkerFavorites,
withStalkerPlayer,
withStalkerPortal,
withStalkerRecent,
withStalkerSelection,
withStalkerSeries,
} from './stores/features';
/**
* StalkerStore facade composed from feature slices.
* Public API compatibility is preserved while implementation is split by concern.
*/
export const StalkerStore = signalStore(
{ providedIn: 'root' },
withStalkerPortal(),
withStalkerSelection(),
withStalkerContent(),
withStalkerSeries(),
withStalkerPlayer(),
withStalkerFavorites(),
withStalkerRecent(),
withStalkerEpg()
);
@@ -1,9 +0,0 @@
export * from './with-stalker-content.feature';
export * from './with-stalker-epg.feature';
export * from './with-stalker-favorites.feature';
export * from './with-stalker-player.feature';
export * from './with-stalker-portal.feature';
export * from './with-stalker-recent.feature';
export * from './with-stalker-selection.feature';
export * from './with-stalker-series.feature';
@@ -1,390 +0,0 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { TranslateService } from '@ngx-translate/core';
import { DataService, StalkerSessionService } from 'services';
import {
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
import { createLogger } from '../../../shared/utils/logger';
import {
StalkerCategoryItem,
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import { toStalkerContentItem, toStalkerItvChannel } from '../utils';
/**
* Content/categories/channels feature state.
*/
export interface StalkerContentState {
totalCount: number;
vodCategories: StalkerCategoryItem[];
seriesCategories: StalkerCategoryItem[];
itvCategories: StalkerCategoryItem[];
hasMoreChannels: boolean;
itvChannels: StalkerItvChannel[];
}
const initialContentState: StalkerContentState = {
totalCount: 0,
vodCategories: [],
seriesCategories: [],
itvCategories: [],
hasMoreChannels: false,
itvChannels: [],
};
export function withStalkerContent() {
const logger = createLogger('withStalkerContent');
return signalStoreFeature(
withState<StalkerContentState>(initialContentState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService),
translateService = inject(TranslateService)
) => ({
getCategoryResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
action: StalkerPortalActions.GetCategories,
currentPlaylist: (store as any).currentPlaylist(),
}),
loader: async ({
params,
}): Promise<StalkerCategoryItem[]> => {
if (params.currentPlaylist === undefined) return;
switch (params.contentType) {
case 'itv':
if (store.itvCategories().length > 0) {
return store.itvCategories();
}
break;
case 'vod':
if (store.vodCategories().length > 0) {
return store.vodCategories();
}
break;
case 'series':
if (store.seriesCategories().length > 0) {
return store.seriesCategories();
}
break;
}
const { portalUrl, macAddress } =
params.currentPlaylist;
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = params.currentPlaylist as Playlist;
const queryParams = {
action: StalkerContentTypes[params.contentType]
.getCategoryAction,
type: params.contentType,
};
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js || !Array.isArray(response.js)) {
logger.warn(
'Invalid categories response',
response
);
return [];
}
const categories = response.js
.map(
(item): StalkerCategoryItem => ({
category_name: item.title,
category_id: String(item.id),
})
)
.sort((a, b) =>
a.category_name.localeCompare(b.category_name)
);
// If the portal provides its own "All" category entry,
// promote it to position 0. Otherwise add a synthetic one.
const allIdx = categories.findIndex(
(c) =>
c.category_name.trim().toLowerCase() === 'all'
);
if (allIdx > 0) {
// Move the existing 'All' entry to the front
categories.unshift(categories.splice(allIdx, 1)[0]);
} else if (
allIdx === -1 &&
categories.length > 0 &&
!categories.some(
(category) =>
String(category.category_id) === '*'
)
) {
// No 'All' entry found — prepend synthetic one
categories.unshift({
category_name: translateService.instant(
'PORTALS.ALL_CATEGORIES'
),
category_id: '*',
});
}
patchState(store, {
[`${params.contentType}Categories`]: categories,
});
return categories;
},
}),
getContentResource: resource({
params: () => ({
contentType: (store as any).selectedContentType(),
category: (store as any).selectedCategoryId(),
action: StalkerPortalActions.GetOrderedList,
search: (store as any).searchPhrase(),
pageIndex: (store as any).page() + 1,
availableCategoryCount: (() => {
const contentType = (
store as any
).selectedContentType();
const categories =
contentType === 'vod'
? store.vodCategories()
: contentType === 'series'
? store.seriesCategories()
: store.itvCategories();
return categories.filter(
(category) =>
String(category.category_id) !== '*'
).length;
})(),
}),
loader: async ({
params,
}): Promise<StalkerContentItem[] | undefined> => {
if (
!params.category ||
params.category === null ||
params.category === ''
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
if (
params.category === '*' &&
(params.contentType === 'vod' ||
params.contentType === 'series') &&
params.availableCategoryCount === 0
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
const currentPlaylist = (store as any).currentPlaylist;
// Guard: ensure currentPlaylist is available (may not be during deep link init)
if (
!currentPlaylist() ||
!currentPlaylist().portalUrl
) {
patchState(store, { totalCount: 0 });
return Promise.resolve(undefined);
}
// VOD uses 'genre' param, series uses 'category' param, itv uses both
// Based on stalker-to-m3u implementation
// Use "*" for categories without an ID (e.g. "All") to fetch all items
const categoryParam = params.category || '*';
const queryParams: Record<string, string | number> = {
action: StalkerContentTypes[params.contentType]
.getContentAction,
type: params.contentType,
sortby: 'added',
...(params.search !== ''
? { search: params.search }
: {}),
p: params.pageIndex,
};
// Add the correct category/genre param based on content type
// Based on working app traces: VOD uses genre=0 and category={id}
if (params.contentType === 'vod') {
queryParams['genre'] = '0';
queryParams['category'] = categoryParam;
} else if (params.contentType === 'series') {
queryParams['category'] = categoryParam;
} else {
// itv - use both for compatibility
queryParams['category'] = categoryParam;
queryParams['genre'] = categoryParam;
}
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = currentPlaylist() as Playlist;
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid response structure', response);
return [];
}
patchState(store, {
totalCount: response.js.total_items ?? 0,
});
const portalUrl = currentPlaylist().portalUrl;
const newItems = response.js.data.map(
(item: StalkerVodSource) =>
toStalkerContentItem(item, portalUrl)
);
if ((store as any).selectedContentType() === 'itv') {
const channels = newItems.map(toStalkerItvChannel);
// Check if we're loading the first page or loading more
if (params.pageIndex === 1) {
patchState(store, { itvChannels: channels });
} else {
patchState(store, {
itvChannels: [
...store.itvChannels(),
...channels,
],
});
}
// Update hasMoreItems based on total count and current items
const totalLoaded = store.itvChannels().length;
patchState(store, {
hasMoreChannels:
totalLoaded <
(response.js.total_items ?? 0),
});
}
return newItems;
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
getTotalPages: computed(() => {
return Math.ceil(store.totalCount() / storeAny.limit());
}),
getSelectedCategoryName: computed(() => {
const type = storeAny.selectedContentType();
const selectedCategoryId = storeAny.selectedCategoryId();
if (!selectedCategoryId) return '';
let categories = [];
if (type === 'vod') {
categories = store.vodCategories();
} else if (type === 'series') {
categories = store.seriesCategories();
} else if (type === 'itv') {
categories = store.itvCategories();
}
const category = categories.find(
(cat) =>
String(cat.category_id) ===
String(selectedCategoryId)
);
return category ? category.category_name : '';
}),
/** category content */
getPaginatedContent: computed(() =>
storeAny.getContentResource.value()
),
isPaginatedContentLoading: computed(() =>
storeAny.getContentResource.isLoading()
),
isPaginatedContentFailed: computed(() =>
storeAny.getContentResource.error()
),
/** category resource */
getCategoryResource: computed(() =>
storeAny.getCategoryResource.value()
),
isCategoryResourceLoading: computed(() =>
storeAny.getCategoryResource.isLoading()
),
isCategoryResourceFailed: computed(() =>
storeAny.getCategoryResource.error()
),
};
}),
withMethods((store) => ({
setCategories(
type: 'vod' | 'series' | 'itv',
categories: StalkerCategoryItem[]
) {
if (type === 'vod') {
patchState(store, { vodCategories: categories });
} else if (type === 'series') {
patchState(store, { seriesCategories: categories });
} else if (type === 'itv') {
patchState(store, { itvCategories: categories });
}
},
resetCategories() {
patchState(store, {
vodCategories: [],
seriesCategories: [],
itvCategories: [],
});
},
setItvChannels(channels: StalkerItvChannel[]) {
patchState(store, { itvChannels: channels });
},
}))
);
}
@@ -1,78 +0,0 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import {
EpgItem,
Playlist,
STALKER_REQUEST,
StalkerPortalActions,
} from 'shared-interfaces';
/**
* EPG concern methods.
*/
export function withStalkerEpg() {
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
return {
async fetchChannelEpg(
channelId: number | string,
size = 10
): Promise<EpgItem[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams: Record<string, string> = {
action: StalkerPortalActions.GetShortEpg,
type: 'itv',
ch_id: String(channelId),
size: String(size),
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
const epgData = response?.js?.data ?? response?.js ?? [];
const items = Array.isArray(epgData) ? epgData : [];
return items.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 ?? ''),
}));
},
};
}
)
);
}
@@ -1,89 +0,0 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
/**
* Favorites concern methods.
*/
export function withStalkerFavorites() {
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
return {
addToFavorites(item: any, onDone?: () => void) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.addPortalFavorite(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
id: item.stream_id ?? item.id,
})
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
);
snackBar.open(
translate.instant(
'PORTALS.ADDED_TO_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
removeFromFavorites(
favoriteId: string,
onDone?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.removeFromPortalFavorites(portalId, favoriteId)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
favorites:
updatedPlaylist?.favorites,
} as PlaylistMeta,
})
);
snackBar.open(
translate.instant(
'PORTALS.REMOVED_FROM_FAVORITES'
),
null,
{
duration: 1000,
}
);
onDone?.();
});
},
};
}
)
);
}
@@ -1,583 +0,0 @@
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions } from 'm3u-state';
import { DataService, PlaylistsService, StalkerSessionService } from 'services';
import {
Playlist,
PlaylistMeta,
ResolvedPortalPlayback,
STALKER_REQUEST,
StalkerPortalActions,
StalkerPortalItem,
} from 'shared-interfaces';
import { PlayerService } from '../../../services/player.service';
import { createLogger } from '../../../shared/utils/logger';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
buildStalkerExternalPlaybackHeaders,
getStalkerPortalOrigin,
isCrossOriginStalkerStream,
STALKER_MAG_USER_AGENT,
} from '../../stalker-live-playback.utils';
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '../../stalker-vod.utils';
type StalkerContentType = 'itv' | 'vod' | 'series';
interface StalkerPlayableItem extends StalkerPortalItem {
cmd?: string;
has_files?: unknown;
}
interface StalkerPlayerStoreLike {
selectedContentType: () => StalkerContentType;
currentPlaylist: () => Playlist | null;
selectedItem: () => StalkerPlayableItem | null;
addToRecentlyViewed?: (item: Record<string, unknown>) => void;
}
interface StalkerResponse {
js?: {
error?: string;
cmd?: string;
data?: Array<{ id?: string | number }>;
account_info?: {
expire_date?: string | number;
};
};
}
/**
* Playback/link/player concern methods.
*/
export function withStalkerPlayer() {
const logger = createLogger('withStalkerPlayer');
return signalStoreFeature(
withMethods(
(
store,
dataService = inject(DataService),
playlistService = inject(PlaylistsService),
playerService = inject(PlayerService),
stalkerSession = inject(StalkerSessionService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService),
ngrxStore = inject(Store)
) => {
const storeState = store as unknown as StalkerPlayerStoreLike;
const fetchLinkToPlayInternal = async (
portalUrl: string,
macAddress: string,
cmd: string,
series?: number,
forcedContentType?: StalkerContentType
) => {
const normalizeCmdValue = (value: string): string => {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '';
// Some portals prepend transport wrappers like:
// "ffmpeg http://...", "ffrt http://...", etc.
const splitAt = trimmed.indexOf(' ');
if (splitAt > 0) {
const candidate = trimmed.slice(splitAt + 1).trim();
if (
candidate.startsWith('http://') ||
candidate.startsWith('https://') ||
candidate.startsWith('/') ||
candidate.startsWith('?')
) {
return candidate;
}
}
return trimmed;
};
const selectedContentType =
forcedContentType ?? storeState.selectedContentType();
const type = series ? 'vod' : selectedContentType;
// Always use create_link to get the tokenized streaming URL
// The server adds the required token for playback authorization
// Note: cmd is already transformed during item processing (has_files items)
const params = {
action: StalkerContentTypes[selectedContentType]
.getLink,
cmd: cmd,
type,
disable_ad: '0',
download: '0',
JsHttpRequest: '1-xml',
...(series ? { series: String(series) } : {}),
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: portalUrl,
macAddress,
params,
}
);
}
// Check for server-side errors
if (response.js?.error) {
const errorMsg = response.js.error;
logger.error('Server error', errorMsg);
throw new Error(errorMsg);
}
let url = normalizeCmdValue(response.js.cmd as string);
// If cmd is empty, the content is not available
if (!url) {
throw new Error('nothing_to_play');
}
// Handle incomplete URLs - some portals return just query params or relative paths
if (
url &&
!url.startsWith('http://') &&
!url.startsWith('https://')
) {
// Extract base URL from portal URL
try {
const portalUrlObj = new URL(portalUrl);
// Get the stalker portal base path (e.g., /stalker_portal from /stalker_portal/server/load.php)
const pathParts = portalUrlObj.pathname.split('/');
// Find the stalker_portal or c directory and use that as base
let basePath = '';
for (let i = 0; i < pathParts.length; i++) {
if (
pathParts[i] === 'stalker_portal' ||
pathParts[i] === 'c' ||
pathParts[i] === 'portal'
) {
basePath =
'/' +
pathParts.slice(1, i + 1).join('/');
break;
}
}
// If url starts with ?, it's just query params
// Combine with the original cmd path to form the complete streaming URL
if (url.startsWith('?')) {
const normalizedCmd = normalizeCmdValue(cmd);
// The streaming URL is: portal origin + base path + original cmd path + token query
// e.g., http://portal.com + /stalker_portal + /media/12345.mpg + ?token=xxx
if (
normalizedCmd.startsWith('http://') ||
normalizedCmd.startsWith('https://')
) {
url = `${normalizedCmd}${url}`;
} else {
url = `${portalUrlObj.origin}${basePath}${normalizedCmd}${url}`;
}
} else if (url.startsWith('/')) {
// Relative path - prepend origin and base path
url = `${portalUrlObj.origin}${basePath}${url}`;
}
} catch {
// URL parsing failed, return as-is
}
}
return url;
};
const fetchMovieFileIdInternal = async (
movieId: string
): Promise<string | null> => {
const playlist = storeState.currentPlaylist();
if (!playlist) return null;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: movieId,
p: '1',
};
let response: StalkerResponse;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Extract id from the first data item
if (response?.js?.data?.[0]?.id) {
const fileId = response.js.data[0].id;
return String(fileId);
}
return null;
};
const resolveVodPlaybackInternal = async (
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> => {
const item = storeState.selectedItem();
let cmdToUse = cmd ?? item?.cmd;
if (!cmdToUse) {
throw new Error('nothing_to_play');
}
// For items with has_files and relative path, we need to fetch the file id first
if (
item?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId = await fetchMovieFileIdInternal(
normalizeStalkerEntityId(item.id)
);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const playlist = storeState.currentPlaylist();
if (!playlist) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
cmdToUse,
episodeNum
);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed({
...item,
id: item?.id,
cmd: cmd,
cover: thumbnail,
title,
});
} else {
addToRecentlyViewedInternal(
item,
cmd,
thumbnail,
title
);
}
const isEpisode =
episodeNum !== undefined || episodeId !== undefined;
const selectedItemId =
normalizeStalkerEntityIdAsNumber(item?.id) ?? 0;
return {
streamUrl,
title: title ?? '',
thumbnail,
startTime,
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
contentInfo: {
playlistId: playlist._id,
// For episodes, use episodeId if provided, otherwise fall back to item.id
contentXtreamId:
isEpisode && episodeId
? episodeId
: selectedItemId,
contentType: isEpisode ? 'episode' : 'vod',
seriesXtreamId: isEpisode
? selectedItemId
: undefined,
},
};
};
const resolveItvPlaybackInternal = async (
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> => {
const playlist = storeState.currentPlaylist();
if (!playlist || !item?.cmd) {
throw new Error('nothing_to_play');
}
const streamUrl = await fetchLinkToPlayInternal(
playlist.portalUrl,
playlist.macAddress,
item.cmd,
undefined,
'itv'
);
const token = stalkerSession.getCachedToken(playlist._id);
const headers = buildStalkerExternalPlaybackHeaders(
playlist,
token,
streamUrl
);
const crossOriginStream = isCrossOriginStalkerStream(
playlist,
streamUrl
);
const portalOrigin = getStalkerPortalOrigin(playlist);
if (typeof storeState.addToRecentlyViewed === 'function') {
storeState.addToRecentlyViewed({
...item,
id: item.id,
cover: item.logo ?? item.cover,
title: item.o_name || item.name || item.title,
});
}
return {
streamUrl,
title: item.o_name || item.name || item.title || '',
thumbnail: item.logo ?? item.cover ?? null,
headers,
userAgent:
headers['User-Agent'] ||
playlist.userAgent ||
STALKER_MAG_USER_AGENT,
referer: crossOriginStream
? undefined
: playlist.referrer || portalOrigin,
origin: crossOriginStream
? undefined
: playlist.origin || portalOrigin,
};
};
const addToRecentlyViewedInternal = (
item: StalkerPlayableItem,
cmd?: string,
cover?: string,
title?: string
) => {
const playlistId = storeState.currentPlaylist()?._id;
if (!playlistId) return;
const recentlyViewedItem: {
id: string;
title: string;
} & Record<string, unknown> = {
...item,
id: normalizeStalkerEntityId(item?.id),
cmd,
cover,
title,
category_id: storeState.selectedContentType(),
added_at: Date.now(),
};
playlistService
.addPortalRecentlyViewed(playlistId, recentlyViewedItem)
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: playlistId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
});
};
return {
async fetchLinkToPlay(
portalUrl: string,
macAddress: string,
cmd: string,
series?: number
) {
return fetchLinkToPlayInternal(
portalUrl,
macAddress,
cmd,
series
);
},
async getExpireDate() {
const params = {
type: 'account_info',
action: 'get_main_info',
JsHttpRequest: '1-xml',
};
try {
// Use makeAuthenticatedRequest for automatic retry on auth failure
const playlist = storeState.currentPlaylist();
let response: StalkerResponse;
if (!playlist) {
return 'Unknown';
}
if (playlist?.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
params
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
}
);
}
if (
response &&
response.js &&
response.js.account_info
) {
// Extract the expire date from the response
const expireDate =
response.js.account_info.expire_date;
const numericExpireDate = Number(expireDate);
// Convert timestamp to readable date if it's a unix timestamp
if (
expireDate &&
!Number.isNaN(numericExpireDate)
) {
const date = new Date(
numericExpireDate * 1000
); // Convert seconds to milliseconds
return date.toLocaleDateString();
}
return expireDate || 'Unknown';
}
return 'Unknown';
} catch (error) {
logger.error('Failed to fetch expire date', error);
return 'Error fetching data';
}
},
/**
* Fetch movie files using get_ordered_list with movie_id parameter.
* This is needed for items with has_files property to get the correct video_id
* for the create_link request.
*/
async fetchMovieFileId(
movieId: string
): Promise<string | null> {
return fetchMovieFileIdInternal(movieId);
},
async resolveVodPlayback(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
): Promise<ResolvedPortalPlayback> {
return resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
},
async resolveItvPlayback(
item: StalkerPlayableItem
): Promise<ResolvedPortalPlayback> {
return resolveItvPlaybackInternal(item);
},
/**
* Play VOD or episode content
* @param cmd The media command/path
* @param title Display title
* @param thumbnail Thumbnail URL
* @param episodeNum Episode number (for series param in API)
* @param episodeId Optional episode ID for playback tracking (defaults to item.id)
* @param startTime Optional start time in seconds for resume playback
*/
async createLinkToPlayVod(
cmd?: string,
title?: string,
thumbnail?: string,
episodeNum?: number,
episodeId?: number,
startTime?: number
) {
try {
const playback = await resolveVodPlaybackInternal(
cmd,
title,
thumbnail,
episodeNum,
episodeId,
startTime
);
void playerService.openResolvedPlayback(
playback,
true
);
} catch (error) {
logger.error('Failed to get playback URL', error);
const errorMessage =
error?.message === 'nothing_to_play'
? translate.instant(
'PORTALS.CONTENT_NOT_AVAILABLE'
)
: translate.instant(
'PORTALS.PLAYBACK_ERROR'
);
snackBar.open(errorMessage, null, {
duration: 3000,
});
}
},
};
}
)
);
}
@@ -1,114 +0,0 @@
import { inject } from '@angular/core';
import {
patchState,
signalStoreFeature,
withProps,
withMethods,
withState,
} from '@ngrx/signals';
import { Playlist, PlaylistMeta, STALKER_REQUEST } from 'shared-interfaces';
import { DataService, StalkerSessionService } from 'services';
import { createLogger } from '../../../shared/utils/logger';
/**
* Portal/session state and methods.
*/
export interface StalkerPortalState {
currentPlaylist: PlaylistMeta;
}
const initialPortalState: StalkerPortalState = {
currentPlaylist: undefined,
};
export function withStalkerPortal() {
const logger = createLogger('withStalkerPortal');
return signalStoreFeature(
withState<StalkerPortalState>(initialPortalState),
withProps(
(
_store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
/**
* Helper to make stalker requests with automatic token handling
*/
async makeStalkerRequest(
playlist: PlaylistMeta,
params: Record<string, any>
) {
// Get token if it's a full stalker portal
let token: string | undefined;
let serialNumber: string | undefined;
if ((playlist as Playlist).isFullStalkerPortal) {
try {
const result = await stalkerSession.ensureToken(
playlist as Playlist
);
token = result.token ?? undefined;
serialNumber = (playlist as Playlist)
.stalkerSerialNumber;
} catch (error) {
logger.error('Failed to get stalker token', error);
}
}
return dataService.sendIpcEvent(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params,
token,
serialNumber,
});
},
})
),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
stalkerSession.setActiveWatchdogPlaylist(
(playlist as Playlist) || undefined
);
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
// Only sync if this is actually a Stalker playlist (has macAddress and portalUrl)
if (
playlist &&
dataService.isElectron &&
playlist._id &&
playlist.macAddress &&
playlist.portalUrl
) {
try {
const playlistId = String(playlist._id);
// Check if playlist exists in SQLite
const existing =
await window.electron.dbGetPlaylist(playlistId);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlistId,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
logger.error(
'Error syncing Stalker playlist to SQLite',
error
);
}
}
},
})
)
);
}
@@ -1,75 +0,0 @@
import { inject } from '@angular/core';
import { signalStoreFeature, withMethods } from '@ngrx/signals';
import { Store } from '@ngrx/store';
import { PlaylistActions } from 'm3u-state';
import { PlaylistsService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { createLogger } from '../../../shared/utils/logger';
/**
* Recently-viewed concern methods.
*/
export function withStalkerRecent() {
const logger = createLogger('withStalkerRecent');
return signalStoreFeature(
withMethods(
(
store,
playlistService = inject(PlaylistsService),
ngrxStore = inject(Store)
) => {
const storeAny = store as any;
return {
addToRecentlyViewed(item: any) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.addPortalRecentlyViewed(portalId, {
...item,
category_id: storeAny.selectedContentType(),
added_at: Date.now(),
})
.subscribe((updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
});
},
removeFromRecentlyViewed(
itemId: string | number,
onComplete?: () => void
) {
const portalId = storeAny.currentPlaylist()?._id;
playlistService
.removeFromPortalRecentlyViewed(portalId, itemId)
.subscribe({
next: (updatedPlaylist) => {
ngrxStore.dispatch(
PlaylistActions.updatePlaylistMeta({
playlist: {
_id: portalId,
recentlyViewed:
updatedPlaylist?.recentlyViewed,
} as PlaylistMeta,
})
);
onComplete?.();
},
error: (error) =>
logger.error(
'Failed to remove item from recently viewed',
error
),
});
},
};
}
)
);
}
@@ -1,138 +0,0 @@
import { computed } from '@angular/core';
import {
patchState,
signalStoreFeature,
withMethods,
withState,
} from '@ngrx/signals';
import { StalkerVodSource } from '../../models';
import { normalizeStalkerEntityId } from '../../stalker-vod.utils';
/**
* Selection/pagination/search feature state.
*/
export interface StalkerSelectionState {
selectedContentType: 'vod' | 'itv' | 'series';
selectedCategoryId: string | null | undefined;
selectedVodId: string | undefined;
selectedSerialId: string | undefined;
selectedItvId: string | undefined;
limit: number;
page: number;
searchPhrase: string;
selectedItem: StalkerVodSource | null | undefined;
}
const initialSelectionState: StalkerSelectionState = {
selectedContentType: 'vod',
selectedCategoryId: undefined,
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
limit: 14,
page: 0,
searchPhrase: '',
selectedItem: undefined,
};
export function withStalkerSelection() {
return signalStoreFeature(
withState<StalkerSelectionState>(initialSelectionState),
withMethods((store) => ({
setSelectedContentType(type: 'vod' | 'itv' | 'series') {
patchState(store, { selectedContentType: type });
},
setSelectedCategory(id: string | number | null) {
patchState(store, {
selectedCategoryId:
id !== null && id !== undefined ? String(id) : null,
page: 0,
});
},
setSelectedSerialId(id: string) {
patchState(store, { selectedSerialId: id });
},
setSelectedVodId(id: string) {
patchState(store, { selectedVodId: id });
},
setSelectedItvId(id: string) {
patchState(store, { selectedItvId: id });
},
setLimit(limit: number) {
patchState(store, { limit });
},
setPage(page: number) {
patchState(store, { page });
},
setSearchPhrase(phrase: string) {
patchState(store, { searchPhrase: phrase, page: 0 });
},
setSelectedItem(selectedItem: StalkerVodSource | null | undefined) {
const selectedIdRaw =
selectedItem?.id !== undefined && selectedItem?.id !== null
? selectedItem.id
: undefined;
const selectedId =
selectedIdRaw !== undefined
? normalizeStalkerEntityId(selectedIdRaw)
: undefined;
patchState(store, {
selectedVodId: selectedId,
selectedSerialId: selectedId,
selectedItvId: selectedId,
selectedItem,
});
},
clearSelectedItem() {
patchState(store, {
selectedVodId: undefined,
selectedSerialId: undefined,
selectedItvId: undefined,
selectedItem: undefined,
});
},
/** getters */
getSelectedCategory: computed(() => {
const storeAny = store as any;
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
category_name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
const readCategories = (
getterName:
| 'vodCategories'
| 'seriesCategories'
| 'itvCategories'
) =>
typeof storeAny[getterName] === 'function'
? storeAny[getterName]()
: [];
if (contentType === 'vod') {
categories = readCategories('vodCategories');
} else if (contentType === 'series') {
categories = readCategories('seriesCategories');
} else if (contentType === 'itv') {
categories = readCategories('itvCategories');
}
return (
categories.find(
(c: any) => String(c.category_id) === String(categoryId)
) || {
category_id: categoryId,
category_name: '',
type: contentType,
}
);
}),
}))
);
}
@@ -1,277 +0,0 @@
import { computed, inject, resource } from '@angular/core';
import {
patchState,
signalStoreFeature,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { DataService, StalkerSessionService } from 'services';
import { Playlist, STALKER_REQUEST, StalkerPortalActions } from 'shared-interfaces';
import {
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
import { StalkerContentTypes } from '../../stalker-content-types';
import {
sortByNumericValue,
sortEpisodesByNumber,
sortVodSeriesSeasonsByNumber,
} from '../utils';
import { createLogger } from '../../../shared/utils/logger';
/**
* Regular-series and VOD-series feature state.
*/
export interface StalkerSeriesState {
/** For VOD items that are actually series (Ministra plugin is_series=1) */
vodSeriesSeasons: StalkerVodSeriesSeason[];
vodSeriesEpisodes: StalkerVodSeriesEpisode[];
selectedVodSeriesSeasonId: string | undefined;
}
const initialSeriesState: StalkerSeriesState = {
vodSeriesSeasons: [],
vodSeriesEpisodes: [],
selectedVodSeriesSeasonId: undefined,
};
export function withStalkerSeries() {
const logger = createLogger('withStalkerSeries');
const toMovieId = (value: unknown): string => {
const raw = String(value ?? '').trim();
if (!raw) return '';
return raw.includes(':') ? raw.split(':')[0] : raw;
};
return signalStoreFeature(
withState<StalkerSeriesState>(initialSeriesState),
withProps(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => ({
serialSeasonsResource: resource({
params: () => ({
itemId: (store as any).selectedSerialId(),
}),
loader: async ({ params }) => {
const movieId = toMovieId(params.itemId);
// Guard: ensure currentPlaylist and itemId are available
if (!(store as any).currentPlaylist() || !movieId) {
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerContentTypes.series.getContentAction,
type: 'series',
movie_id: movieId,
};
// Use makeAuthenticatedRequest for automatic retry on auth failure
let response: any;
if (playlist.isFullStalkerPortal) {
// Full stalker portal - use authenticated request with retry
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
// Simple stalker portal - no auth needed
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
// Guard: ensure response has expected structure
if (!response?.js?.data) {
logger.warn('Invalid seasons response', response);
return [];
}
return sortByNumericValue(response.js.data);
},
}),
/**
* Resource to fetch seasons for VOD items that are actually series (is_series=1)
* Used for Ministra plugin where VOD items can contain series/seasons
*/
vodSeriesSeasonsResource: resource({
params: () => ({
selectedItem: (store as any).selectedItem(),
selectedContentType:
(store as any).selectedContentType?.() ?? 'vod',
}),
loader: async ({
params,
}): Promise<StalkerVodSeriesSeason[]> => {
const item = params.selectedItem;
logger.debug('vodSeriesSeasonsResource loader called', {
item,
isSeries: item?.is_series,
currentPlaylist: (store as any).currentPlaylist(),
});
// Only fetch if item is a VOD series (has is_series flag)
if (
!(store as any).currentPlaylist() ||
params.selectedContentType !== 'vod' ||
!item ||
!item.is_series
) {
logger.debug(
'vodSeriesSeasonsResource skipped - conditions not met'
);
return [];
}
const playlist = (store as any).currentPlaylist() as Playlist;
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: item.id,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
logger.debug(
'vodSeriesSeasonsResource - no response data'
);
return [];
}
logger.debug(
'vodSeriesSeasonsResource response data',
response.js.data
);
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: StalkerVodSeriesSeason) =>
item.is_season === true
);
logger.debug(
'vodSeriesSeasonsResource filtered seasons',
seasons
);
return sortVodSeriesSeasonsByNumber(seasons);
},
}),
})
),
withComputed((store) => {
const storeAny = store as any;
return {
/** serials */
getSerialSeasonsResource: computed(() =>
storeAny.serialSeasonsResource.value()
),
isSerialSeasonsLoading: computed(() =>
storeAny.serialSeasonsResource.isLoading()
),
/** VOD series (Ministra plugin is_series=1) */
getVodSeriesSeasonsResource: computed(() =>
storeAny.vodSeriesSeasonsResource.value()
),
isVodSeriesSeasonsLoading: computed(() =>
storeAny.vodSeriesSeasonsResource.isLoading()
),
};
}),
withMethods(
(
store,
dataService = inject(DataService),
stalkerSession = inject(StalkerSessionService)
) => {
const storeAny = store as any;
return {
/**
* Fetch episodes for a VOD series season (Ministra plugin)
* @param videoId The video_id from the season item
* @param seasonId The season id
* @returns Array of episode items
*/
async fetchVodSeriesEpisodes(
videoId: string,
seasonId: string
): Promise<StalkerVodSeriesEpisode[]> {
const playlist = storeAny.currentPlaylist() as Playlist;
if (!playlist) return [];
const queryParams = {
action: StalkerPortalActions.GetOrderedList,
type: 'vod',
movie_id: videoId,
season_id: seasonId,
p: '1',
};
let response: any;
if (playlist.isFullStalkerPortal) {
response =
await stalkerSession.makeAuthenticatedRequest(
playlist,
queryParams
);
} else {
response = await dataService.sendIpcEvent(
STALKER_REQUEST,
{
url: playlist.portalUrl,
macAddress: playlist.macAddress,
params: queryParams,
}
);
}
if (!response?.js?.data) {
return [];
}
// Filter for episode items (is_episode: true) and sort by series_number
const episodes = sortEpisodesByNumber(
response.js.data.filter(
(item: StalkerVodSeriesEpisode) =>
item.is_episode === true
)
);
// Store episodes and selected season
patchState(store, {
vodSeriesEpisodes: episodes,
selectedVodSeriesSeasonId: seasonId,
});
return episodes;
},
};
}
)
);
}
-3
View File
@@ -1,3 +0,0 @@
export * from './features';
export * from './utils';
@@ -1,3 +0,0 @@
export * from './stalker-content-mappers';
export * from './stalker-sorting';
@@ -1,77 +0,0 @@
import {
StalkerContentItem,
StalkerItvChannel,
StalkerVodSource,
} from '../../models';
/**
* Convert relative URLs to absolute URLs using the portal base URL
* Handles screenshot_uri and cmd paths that come as relative from the server
*/
export function makeAbsoluteUrl(baseUrl: string, relativePath: string): string {
if (!relativePath) return '';
// Already absolute URL
if (
relativePath.startsWith('http://') ||
relativePath.startsWith('https://')
) {
return relativePath;
}
// Parse the base URL to get origin
try {
const url = new URL(baseUrl);
// Ensure the relative path starts with /
const path = relativePath.startsWith('/')
? relativePath
: `/${relativePath}`;
return `${url.origin}${path}`;
} catch {
return relativePath;
}
}
/**
* Post-process stalker items to convert relative URLs to absolute
*/
export function processItemUrls<T extends StalkerVodSource>(
item: T,
portalUrl: string
): T {
const processed = { ...item };
// Convert screenshot_uri to absolute URL
if (processed.screenshot_uri) {
processed.screenshot_uri = makeAbsoluteUrl(
portalUrl,
processed.screenshot_uri
);
}
return processed;
}
export function toStalkerContentItem(
item: StalkerVodSource,
portalUrl: string
): StalkerContentItem {
const processed = processItemUrls(item, portalUrl);
return {
...processed,
cover: processed.screenshot_uri,
};
}
export function toStalkerItvChannel(item: StalkerContentItem): StalkerItvChannel {
return {
...item,
id: item.id ?? item.stream_id ?? '',
cmd: String(item.cmd ?? ''),
name:
typeof item.name === 'string' ? item.name : undefined,
o_name:
typeof item.o_name === 'string' ? item.o_name : undefined,
logo:
typeof item.logo === 'string' ? item.logo : undefined,
};
}
@@ -1,48 +0,0 @@
import {
StalkerSeason,
StalkerVodSeriesEpisode,
StalkerVodSeriesSeason,
} from '../../models';
export function extractNumericValue(str: string): number {
const matches = str.match(/\d+/);
if (matches) {
return parseInt(matches[0], 10);
}
return 0;
}
export function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
if (!array) return [];
const key = 'name';
return [...array].sort((a, b) => {
const numericA = extractNumericValue(a[key]);
const numericB = extractNumericValue(b[key]);
return numericA - numericB;
});
}
export function sortVodSeriesSeasonsByNumber(
array: StalkerVodSeriesSeason[]
): StalkerVodSeriesSeason[] {
if (!array) return [];
return [...array].sort((a, b) => {
const numA = Number(a.season_number ?? 0) || 0;
const numB = Number(b.season_number ?? 0) || 0;
return numA - numB;
});
}
/**
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
*/
export function sortEpisodesByNumber(
episodes: StalkerVodSeriesEpisode[]
): StalkerVodSeriesEpisode[] {
if (!episodes) return [];
return [...episodes].sort((a, b) => {
const numA = Number(a.series_number ?? 0) || 0;
const numB = Number(b.series_number ?? 0) || 0;
return numA - numB;
});
}
@@ -1,35 +1 @@
import { Injectable, signal } from '@angular/core';
import { XtreamCategory } from 'shared-interfaces';
/**
* Lightweight service that bridges the favorites/recently-viewed 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 workspace shell reads it to populate the context panel.
*/
@Injectable({ providedIn: 'root' })
export class FavoritesContextService {
/** 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');
}
}
export { FavoritesContextService } from '@iptvnator/portal/shared/util';
@@ -29,8 +29,8 @@ import {
import { EpgViewComponent } from 'shared-portals';
import { PlayerService } from '../../services/player.service';
import { SettingsStore } from '../../services/settings-store.service';
import { XtreamApiService } from '../../xtream-electron/services/xtream-api.service';
import { XtreamUrlService } from '../../xtream-electron/services/xtream-url.service';
import { XtreamApiService } from '@iptvnator/portal/xtream/data-access';
import { XtreamUrlService } from '@iptvnator/portal/xtream/data-access';
import { GlobalFavoritesListComponent } from './global-favorites-list.component';
import { GlobalFavoritesService } from './global-favorites.service';
import { UnifiedFavoriteChannel } from './unified-favorite-channel.interface';
@@ -5,14 +5,14 @@ import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { Router } from '@angular/router';
import { CategoryViewComponent } from '../shared/components/category-view/category-view.component';
import { PlaylistErrorViewComponent } from '../shared/components/playlist-error-view/playlist-error-view.component';
import { StalkerStore } from '../stalker/stalker.store';
import { CategoryViewComponent } from '@iptvnator/portal/shared/ui';
import { PlaylistErrorViewComponent } from '@iptvnator/portal/shared/ui';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
CategoryManagementDialogComponent,
CategoryManagementDialogData,
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more