mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
refactor(nx): update repository structure
Introduce new libraries to improve the boundaries and dependencies in mono repo
This commit is contained in:
1 parent
1780bb0e53
commit
256f0e0136
198 files changed
+1384
-815
No files matched your search
@@ -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(),
|
||||
],
|
||||
};
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+9
-9
@@ -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';
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+4
-4
@@ -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',
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
-45
@@ -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>
|
||||
-110
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -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(['/']);
|
||||
}
|
||||
}
|
||||
-55
@@ -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>
|
||||
-81
@@ -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;
|
||||
}
|
||||
-46
@@ -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();
|
||||
}
|
||||
}
|
||||
-29
@@ -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)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
-52
@@ -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);
|
||||
}
|
||||
}
|
||||
-4
@@ -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>
|
||||
-55
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-3
@@ -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 = {};
|
||||
|
||||
|
||||
File renamed without changes.
+4
-4
@@ -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;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
+8
-8
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user