mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
refactor: enhance playlist favorites handling and introduce portal activity items
- Updated the `selectFavorites` selector to filter favorites ensuring they are strings. - Added new interfaces for `PortalActivityItem`, `PortalRecentItem`, and `PortalFavoriteItem` to standardize activity item representation. - Introduced `StalkerPortalItem` interface to represent items in Stalker portals. - Created utility functions for extracting and normalizing data from Stalker portal items. - Refactored `DashboardDataService` to utilize new interfaces and utility functions for managing recent and favorite items. - Removed deprecated mapping functions and replaced them with direct usage of new interfaces. - Added navigation utilities for recent and favorite items to streamline routing logic.
This commit is contained in:
1 parent
39dff635eb
commit
9e1ef0b0bd
21 files changed
+1041
-979
No files matched your search
@@ -234,22 +234,17 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
playlist.favorites &&
|
||||
playlist.favorites.length > 0
|
||||
) {
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: playlist.favorites,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
const stringFavs = (
|
||||
playlist.favorites ?? []
|
||||
).filter(
|
||||
(f): f is string =>
|
||||
typeof f === 'string'
|
||||
);
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: stringFavs,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
} else if (params['id']) {
|
||||
@@ -280,23 +275,17 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
);
|
||||
|
||||
// Load favorites from the playlist
|
||||
if (
|
||||
playlist.favorites &&
|
||||
playlist.favorites.length > 0
|
||||
) {
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: playlist.favorites,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Clear favorites if playlist has none
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
const stringFavs = (
|
||||
playlist.favorites ?? []
|
||||
).filter(
|
||||
(f): f is string =>
|
||||
typeof f === 'string'
|
||||
);
|
||||
this.store.dispatch(
|
||||
FavoritesActions.setFavorites({
|
||||
channelIds: stringFavs,
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
.category-item {
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ResolvedPortalPlayback,
|
||||
STALKER_REQUEST,
|
||||
StalkerPortalActions,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
import { PlayerService } from '../../../services/player.service';
|
||||
import { createLogger } from '../../../shared/utils/logger';
|
||||
@@ -28,8 +29,7 @@ import {
|
||||
|
||||
type StalkerContentType = 'itv' | 'vod' | 'series';
|
||||
|
||||
interface StalkerPlayableItem {
|
||||
id?: string | number;
|
||||
interface StalkerPlayableItem extends StalkerPortalItem {
|
||||
cmd?: string;
|
||||
has_files?: unknown;
|
||||
}
|
||||
@@ -363,27 +363,15 @@ export function withStalkerPlayer() {
|
||||
storeState.addToRecentlyViewed({
|
||||
...item,
|
||||
id: item.id,
|
||||
cover:
|
||||
(item as { logo?: string }).logo ??
|
||||
(item as { cover?: string }).cover,
|
||||
title:
|
||||
(item as { o_name?: string }).o_name ||
|
||||
(item as { name?: string }).name ||
|
||||
(item as { title?: string }).title,
|
||||
cover: item.logo ?? item.cover,
|
||||
title: item.o_name || item.name || item.title,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
streamUrl,
|
||||
title:
|
||||
(item as { o_name?: string }).o_name ||
|
||||
(item as { name?: string }).name ||
|
||||
(item as { title?: string }).title ||
|
||||
'',
|
||||
thumbnail:
|
||||
(item as { logo?: string }).logo ??
|
||||
(item as { cover?: string }).cover ??
|
||||
null,
|
||||
title: item.o_name || item.name || item.title || '',
|
||||
thumbnail: item.logo ?? item.cover ?? null,
|
||||
headers,
|
||||
userAgent:
|
||||
headers['User-Agent'] ||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
<header class="context-header">
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() }}</h2>
|
||||
@if (isXtreamCategories()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
(click)="openManageCategories()"
|
||||
matTooltip="Manage categories"
|
||||
aria-label="Manage categories"
|
||||
>
|
||||
<mat-icon>tune</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<!-- @if (subtitle(); as text) {
|
||||
<span class="context-header__badge">{{ text }}</span>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
.context-header__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
h2 {
|
||||
@@ -35,6 +35,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-header__action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.context-header__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { Component, computed, DestroyRef, inject, input } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
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 { XtreamStore } from '../xtream-electron/stores/xtream.store';
|
||||
import { StalkerStore } from '../stalker/stalker.store';
|
||||
import {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
|
||||
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
|
||||
|
||||
type WorkspaceProvider = 'xtreams' | 'stalker' | 'playlists';
|
||||
|
||||
@@ -19,7 +28,13 @@ interface XtreamCategoryLike {
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-context-panel',
|
||||
imports: [CategoryViewComponent, PlaylistErrorViewComponent],
|
||||
imports: [
|
||||
CategoryViewComponent,
|
||||
PlaylistErrorViewComponent,
|
||||
MatIconButton,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
],
|
||||
templateUrl: './workspace-context-panel.component.html',
|
||||
styleUrl: './workspace-context-panel.component.scss',
|
||||
})
|
||||
@@ -27,6 +42,8 @@ export class WorkspaceContextPanelComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
readonly context = input.required<WorkspaceContextRoute>();
|
||||
readonly section = input.required<string>();
|
||||
@@ -87,6 +104,40 @@ export class WorkspaceContextPanelComponent {
|
||||
return '';
|
||||
});
|
||||
|
||||
openManageCategories(): void {
|
||||
const context = this.context();
|
||||
const section = this.section();
|
||||
const contentType =
|
||||
section === 'series'
|
||||
? 'series'
|
||||
: section === 'live'
|
||||
? 'live'
|
||||
: 'vod';
|
||||
|
||||
const dialogRef = this.dialog.open<
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
boolean
|
||||
>(CategoryManagementDialogComponent, {
|
||||
data: {
|
||||
playlistId: context.playlistId,
|
||||
contentType,
|
||||
itemCounts: this.xtreamStore.getCategoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
|
||||
const context = this.context();
|
||||
const section = this.section();
|
||||
|
||||
@@ -81,17 +81,6 @@
|
||||
</button>
|
||||
</label>
|
||||
|
||||
@if (externalPlaybackSession(); as session) {
|
||||
<div class="header-now-playing">
|
||||
<app-external-playback-dock
|
||||
[session]="session"
|
||||
[compact]="true"
|
||||
(closeClicked)="closeActiveExternalSession()"
|
||||
(dismissClicked)="dismissActiveExternalSession()"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -164,33 +153,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
@if (
|
||||
contextActionGroups().hasPlaylistActions &&
|
||||
(contextActionGroups().hasSectionActions ||
|
||||
contextActionGroups().hasCleanupActions)
|
||||
) {
|
||||
<mat-divider />
|
||||
}
|
||||
|
||||
@if (contextActionGroups().hasSectionActions) {
|
||||
@if (canManageCategories()) {
|
||||
<button mat-menu-item (click)="openManageCategories()">
|
||||
<mat-icon>tune</mat-icon>
|
||||
<span>Manage categories (this section)</span>
|
||||
</button>
|
||||
}
|
||||
@if (canSort()) {
|
||||
<button mat-menu-item [matMenuTriggerFor]="sortMenu">
|
||||
<mat-icon>sort</mat-icon>
|
||||
<span>Sort content (this section)</span>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
@if (
|
||||
contextActionGroups().hasCleanupActions &&
|
||||
(contextActionGroups().hasPlaylistActions ||
|
||||
contextActionGroups().hasSectionActions)
|
||||
contextActionGroups().hasPlaylistActions
|
||||
) {
|
||||
<mat-divider />
|
||||
}
|
||||
@@ -208,33 +173,6 @@
|
||||
}
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
<mat-menu #sortMenu="matMenu">
|
||||
<button mat-menu-item (click)="setContentSortMode('date-desc')">
|
||||
<span>{{ 'WORKSPACE.SORT_DATE_DESC' | translate }}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setContentSortMode('date-asc')">
|
||||
<span>{{ 'WORKSPACE.SORT_DATE_ASC' | translate }}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setContentSortMode('name-asc')">
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_ASC' | translate }}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button mat-menu-item (click)="setContentSortMode('name-desc')">
|
||||
<span>{{ 'WORKSPACE.SORT_NAME_DESC' | translate }}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
</header>
|
||||
|
||||
<div class="workspace-body" [class.single-pane]="!showContextPanel()">
|
||||
|
||||
@@ -162,13 +162,6 @@
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.header-now-playing {
|
||||
display: none;
|
||||
min-width: 0;
|
||||
flex: 0 1 280px;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.playlist-switcher {
|
||||
width: min(260px, 32vw);
|
||||
flex-shrink: 0;
|
||||
@@ -448,26 +441,12 @@
|
||||
|
||||
// ─── Responsive ───────────────────────────────────────────────────────────────
|
||||
@media (max-width: 960px) {
|
||||
.header-now-playing {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
min-width: 120px;
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 961px) {
|
||||
.workspace-now-playing {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 56px 1fr;
|
||||
@@ -491,7 +470,7 @@
|
||||
@media (max-width: 640px) {
|
||||
.workspace-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 52px 56px 1fr;
|
||||
grid-template-rows: 52px 56px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.app-rail {
|
||||
@@ -535,6 +514,7 @@
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
display: none;
|
||||
grid-column: 1;
|
||||
grid-row: 4;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
RouterOutlet,
|
||||
} from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import {
|
||||
AddPlaylistMenuComponent,
|
||||
PlaylistInfoComponent,
|
||||
@@ -47,13 +46,8 @@ import {
|
||||
} 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 {
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
} from '../xtream-electron/category-management-dialog/category-management-dialog.component';
|
||||
import { GlobalRecentlyViewedComponent } from '../xtream-electron/recently-viewed/global-recently-viewed.component';
|
||||
import { GlobalSearchResultsComponent } from '../xtream-electron/search-results/global-search-results.component';
|
||||
import { XtreamCategorySortMode } from '../xtream-electron/stores/features/with-selection.feature';
|
||||
import { XtreamStore } from '../xtream-electron/stores/xtream.store';
|
||||
import { FavoritesContextService } from './favorites-context.service';
|
||||
import { SettingsContextService } from './settings-context.service';
|
||||
@@ -85,7 +79,6 @@ interface WorkspaceContextActionGroup {
|
||||
hasCleanupActions: boolean;
|
||||
}
|
||||
|
||||
const XTREAM_CATEGORY_SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
|
||||
@Component({
|
||||
@@ -99,7 +92,6 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
AddPlaylistMenuComponent,
|
||||
ExternalPlaybackDockComponent,
|
||||
PlaylistSwitcherComponent,
|
||||
TranslatePipe,
|
||||
RouterLink,
|
||||
RouterOutlet,
|
||||
WorkspaceContextPanelComponent,
|
||||
@@ -352,21 +344,6 @@ export class WorkspaceShellComponent {
|
||||
|
||||
return 'Search in this playlist...';
|
||||
});
|
||||
readonly canSort = computed(() => {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
return context?.provider === 'xtreams'
|
||||
? section === 'vod' || section === 'series'
|
||||
: false;
|
||||
});
|
||||
readonly contentSortMode = this.xtreamStore.contentSortMode;
|
||||
readonly contentSortLabel = computed(() => {
|
||||
const mode = this.contentSortMode();
|
||||
if (mode === 'date-asc') return 'Date Added (Oldest First)';
|
||||
if (mode === 'name-asc') return 'Name A-Z';
|
||||
if (mode === 'name-desc') return 'Name Z-A';
|
||||
return 'Date Added (Latest First)';
|
||||
});
|
||||
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
|
||||
const context = this.railContext();
|
||||
if (!context) return [];
|
||||
@@ -398,14 +375,6 @@ export class WorkspaceShellComponent {
|
||||
readonly canOpenAccountInfo = computed(() =>
|
||||
Boolean(this.activePlaylist()?.serverUrl)
|
||||
);
|
||||
readonly canManageCategories = computed(() => {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
return (
|
||||
context?.provider === 'xtreams' &&
|
||||
(section === 'vod' || section === 'series' || section === 'live')
|
||||
);
|
||||
});
|
||||
readonly headerBulkAction = computed<WorkspaceHeaderBulkAction | null>(
|
||||
() => {
|
||||
const context = this.currentContext();
|
||||
@@ -469,7 +438,7 @@ export class WorkspaceShellComponent {
|
||||
readonly contextActionGroups = computed<WorkspaceContextActionGroup>(() => {
|
||||
const hasPlaylistActions =
|
||||
this.canOpenPlaylistInfo() || this.canOpenAccountInfo();
|
||||
const hasSectionActions = this.canManageCategories() || this.canSort();
|
||||
const hasSectionActions = false;
|
||||
const hasCleanupActions = Boolean(this.headerBulkAction());
|
||||
return {
|
||||
hasPlaylistActions,
|
||||
@@ -498,18 +467,6 @@ export class WorkspaceShellComponent {
|
||||
|
||||
document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
|
||||
const savedSortMode = localStorage.getItem(
|
||||
XTREAM_CATEGORY_SORT_STORAGE_KEY
|
||||
);
|
||||
if (
|
||||
savedSortMode === 'date-desc' ||
|
||||
savedSortMode === 'date-asc' ||
|
||||
savedSortMode === 'name-asc' ||
|
||||
savedSortMode === 'name-desc'
|
||||
) {
|
||||
this.xtreamStore.setContentSortMode(savedSortMode);
|
||||
}
|
||||
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter(
|
||||
@@ -700,48 +657,6 @@ export class WorkspaceShellComponent {
|
||||
}
|
||||
}
|
||||
|
||||
openManageCategories(): void {
|
||||
const context = this.currentContext();
|
||||
const section = this.currentSection();
|
||||
if (
|
||||
!context ||
|
||||
context.provider !== 'xtreams' ||
|
||||
(section !== 'vod' && section !== 'series' && section !== 'live')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType =
|
||||
section === 'series'
|
||||
? 'series'
|
||||
: section === 'live'
|
||||
? 'live'
|
||||
: 'vod';
|
||||
|
||||
const dialogRef = this.dialog.open<
|
||||
CategoryManagementDialogComponent,
|
||||
CategoryManagementDialogData,
|
||||
boolean
|
||||
>(CategoryManagementDialogComponent, {
|
||||
data: {
|
||||
playlistId: context.playlistId,
|
||||
contentType,
|
||||
itemCounts: this.xtreamStore.getCategoryItemCounts(),
|
||||
},
|
||||
width: '500px',
|
||||
maxHeight: '80vh',
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((result) => {
|
||||
if (result) {
|
||||
this.xtreamStore.reloadCategories();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openDownloadsShortcut(): void {
|
||||
this.router.navigate(['/workspace/downloads']);
|
||||
}
|
||||
@@ -799,11 +714,6 @@ export class WorkspaceShellComponent {
|
||||
});
|
||||
}
|
||||
|
||||
setContentSortMode(mode: XtreamCategorySortMode): void {
|
||||
this.xtreamStore.setContentSortMode(mode);
|
||||
localStorage.setItem(XTREAM_CATEGORY_SORT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
private getCommandPaletteItems(): WorkspaceCommandItem[] {
|
||||
const context = this.currentContext();
|
||||
const dashboardXtream = this.dashboardXtreamContext();
|
||||
|
||||
+61
-1
@@ -33,11 +33,71 @@
|
||||
<div class="category-content-layout">
|
||||
<div class="category-content-header">
|
||||
<div class="category-meta">
|
||||
<h2 class="category-title">{{ selectedCategoryTitle() }}</h2>
|
||||
<h2 class="category-title">
|
||||
{{ selectedCategoryTitle() }}
|
||||
</h2>
|
||||
<div class="category-subtitle">
|
||||
{{ categoryItemSubtitle() }}
|
||||
</div>
|
||||
</div>
|
||||
@if (!isStalker) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="sort-action"
|
||||
[matMenuTriggerFor]="sortContentMenu"
|
||||
matTooltip="Sort content"
|
||||
aria-label="Sort content"
|
||||
>
|
||||
<mat-icon>sort</mat-icon>
|
||||
</button>
|
||||
<mat-menu #sortContentMenu="matMenu">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('date-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_DATE_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'date-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-asc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_ASC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-asc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="setContentSortMode('name-desc')"
|
||||
>
|
||||
<span>{{
|
||||
'WORKSPACE.SORT_NAME_DESC' | translate
|
||||
}}</span>
|
||||
@if (contentSortMode() === 'name-desc') {
|
||||
<mat-icon>check</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</mat-menu>
|
||||
}
|
||||
@if (categoryItemCount() > 0) {
|
||||
<mat-paginator
|
||||
[pageIndex]="pageIndex()"
|
||||
|
||||
+37
-3
@@ -7,8 +7,12 @@ import {
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatIconButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
@@ -39,9 +43,12 @@ import {
|
||||
} 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';
|
||||
|
||||
const SORT_STORAGE_KEY = 'xtream-category-sort-mode';
|
||||
|
||||
interface CategoryContentItem {
|
||||
id?: number | string;
|
||||
is_series?: number | string | boolean;
|
||||
@@ -79,7 +86,11 @@ interface DownloadVodData {
|
||||
styleUrls: ['./category-content-view.component.scss'],
|
||||
imports: [
|
||||
GridListComponent,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatTooltip,
|
||||
PlaylistErrorViewComponent,
|
||||
StalkerSeriesViewComponent,
|
||||
TranslatePipe,
|
||||
@@ -296,6 +307,10 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
readonly selectedItem = this.store.selectedItem;
|
||||
readonly totalPages = this.store.getTotalPages;
|
||||
readonly bigStore = inject(Store);
|
||||
readonly contentSortMode = !this.isStalker
|
||||
? (this.store as unknown as InstanceType<typeof XtreamStore>)
|
||||
.contentSortMode
|
||||
: signal<XtreamCategorySortMode>('date-desc');
|
||||
|
||||
/** Computed VodDetailsItem for the vod-details component */
|
||||
readonly vodDetailsItem = computed<VodDetailsItem | null>(() => {
|
||||
@@ -345,11 +360,29 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
|
||||
seasons = [];
|
||||
|
||||
setContentSortMode(mode: XtreamCategorySortMode): void {
|
||||
(
|
||||
this.store as unknown as InstanceType<typeof XtreamStore>
|
||||
).setContentSortMode(mode);
|
||||
localStorage.setItem(SORT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
|
||||
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
|
||||
if (!this.isStalker) {
|
||||
const savedSortMode = localStorage.getItem(SORT_STORAGE_KEY);
|
||||
if (
|
||||
savedSortMode === 'date-desc' ||
|
||||
savedSortMode === 'date-asc' ||
|
||||
savedSortMode === 'name-asc' ||
|
||||
savedSortMode === 'name-desc'
|
||||
) {
|
||||
(
|
||||
this.store as unknown as InstanceType<typeof XtreamStore>
|
||||
).setContentSortMode(savedSortMode);
|
||||
}
|
||||
const xtreamStore = this.store as unknown as CategoryStoreLike;
|
||||
const playlistId = xtreamStore.currentPlaylist?.()?.id;
|
||||
if (playlistId) {
|
||||
@@ -677,7 +710,9 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
startTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
|
||||
const stalkerStore = this.store as InstanceType<
|
||||
typeof StalkerStore
|
||||
>;
|
||||
const playback = await stalkerStore.resolveVodPlayback(
|
||||
cmd,
|
||||
title,
|
||||
@@ -698,8 +733,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start inline VOD playback', error);
|
||||
const errorMessage =
|
||||
error instanceof Error &&
|
||||
error.message === 'nothing_to_play'
|
||||
error instanceof Error && error.message === 'nothing_to_play'
|
||||
? this.translateService.instant(
|
||||
'PORTALS.CONTENT_NOT_AVAILABLE'
|
||||
)
|
||||
|
||||
@@ -8,21 +8,44 @@ import {
|
||||
import { rxMethod } from '@ngrx/signals/rxjs-interop';
|
||||
import { firstValueFrom, pipe, switchMap, tap } from 'rxjs';
|
||||
import { DatabaseService, PlaylistsService } from 'services';
|
||||
import {
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
PortalRecentItem,
|
||||
} from 'shared-interfaces';
|
||||
import { createLogger } from '../shared/utils/logger';
|
||||
|
||||
export interface RecentlyViewedItem {
|
||||
id: number | string;
|
||||
title: string;
|
||||
type: 'live' | 'movie' | 'series';
|
||||
poster_url: string;
|
||||
export interface RecentlyViewedItem extends PortalRecentItem {
|
||||
/** @deprecated Redundant — always equals `id`. Retained for compat. */
|
||||
content_id: number | string;
|
||||
playlist_id: string;
|
||||
playlist_name?: string;
|
||||
viewed_at: string;
|
||||
xtream_id: number | string;
|
||||
category_id: number | string;
|
||||
source?: 'xtream' | 'stalker';
|
||||
stalker_item?: any;
|
||||
}
|
||||
|
||||
function mapDbRecentItem(
|
||||
item: {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
poster_url: string;
|
||||
viewed_at?: string;
|
||||
xtream_id: number;
|
||||
category_id: number;
|
||||
},
|
||||
playlistId: string
|
||||
): RecentlyViewedItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as 'live' | 'movie' | 'series',
|
||||
poster_url: item.poster_url,
|
||||
content_id: item.id,
|
||||
playlist_id: playlistId,
|
||||
viewed_at: item.viewed_at || '',
|
||||
xtream_id: item.xtream_id,
|
||||
category_id: item.category_id,
|
||||
};
|
||||
}
|
||||
|
||||
export const withRecentItems = function () {
|
||||
@@ -31,30 +54,16 @@ export const withRecentItems = function () {
|
||||
withState({
|
||||
recentItems: [],
|
||||
}),
|
||||
withMethods(
|
||||
(
|
||||
store,
|
||||
dbService = inject(DatabaseService),
|
||||
playlistsService = inject(PlaylistsService)
|
||||
) => ({
|
||||
withMethods((store, dbService = inject(DatabaseService)) => ({
|
||||
loadRecentItems: rxMethod<{ id: string }>(
|
||||
pipe(
|
||||
switchMap(async (playlist) => {
|
||||
const items = await dbService.getRecentItems(
|
||||
playlist.id
|
||||
);
|
||||
// Map to RecentlyViewedItem format
|
||||
return items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as 'live' | 'movie' | 'series',
|
||||
poster_url: item.poster_url,
|
||||
content_id: item.id,
|
||||
playlist_id: playlist.id,
|
||||
viewed_at: item.viewed_at || '',
|
||||
xtream_id: item.xtream_id,
|
||||
category_id: item.category_id,
|
||||
}));
|
||||
return items.map((item) =>
|
||||
mapDbRecentItem(item, playlist.id)
|
||||
);
|
||||
}),
|
||||
tap((items: RecentlyViewedItem[]) =>
|
||||
patchState(store, { recentItems: items })
|
||||
@@ -68,205 +77,197 @@ export const withRecentItems = function () {
|
||||
dbService = inject(DatabaseService),
|
||||
playlistsService = inject(PlaylistsService)
|
||||
) => ({
|
||||
addRecentItem: rxMethod<{
|
||||
contentId: number;
|
||||
playlist: Signal<{ id: string }>;
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(async ({ contentId, playlist }) => {
|
||||
// contentId is actually xtream_id, need to look up the database content.id
|
||||
const playlistId = playlist().id;
|
||||
const content = await dbService.getContentByXtreamId(
|
||||
contentId,
|
||||
playlistId
|
||||
);
|
||||
if (content) {
|
||||
await dbService.addRecentItem(
|
||||
content.id,
|
||||
addRecentItem: rxMethod<{
|
||||
contentId: number;
|
||||
playlist: Signal<{ id: string }>;
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(async ({ contentId, playlist }) => {
|
||||
// contentId is actually xtream_id, need to look up the database content.id
|
||||
const playlistId = playlist().id;
|
||||
const content =
|
||||
await dbService.getContentByXtreamId(
|
||||
contentId,
|
||||
playlistId
|
||||
);
|
||||
if (content) {
|
||||
await dbService.addRecentItem(
|
||||
content.id,
|
||||
playlistId
|
||||
);
|
||||
|
||||
// Reload after add/update so re-watched items
|
||||
// immediately move to the top in recently-viewed.
|
||||
const items =
|
||||
await dbService.getRecentItems(playlistId);
|
||||
patchState(store, {
|
||||
recentItems: items.map((item) =>
|
||||
mapDbRecentItem(item, playlistId)
|
||||
),
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
clearRecentItems: rxMethod<{ id: string }>(
|
||||
pipe(
|
||||
switchMap(async (playlist) => {
|
||||
await dbService.clearPlaylistRecentItems(
|
||||
playlist.id
|
||||
);
|
||||
patchState(store, { recentItems: [] });
|
||||
})
|
||||
)
|
||||
),
|
||||
removeRecentItem: rxMethod<{
|
||||
itemId: number;
|
||||
playlistId: string;
|
||||
}>(
|
||||
pipe(
|
||||
switchMap(async ({ itemId, playlistId }) => {
|
||||
await dbService.removeRecentItem(
|
||||
itemId,
|
||||
playlistId
|
||||
);
|
||||
|
||||
// Reload after add/update so re-watched items
|
||||
// immediately move to the top in recently-viewed.
|
||||
// Reload recent items to update UI
|
||||
const items =
|
||||
await dbService.getRecentItems(playlistId);
|
||||
patchState(store, {
|
||||
recentItems: items.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as
|
||||
| 'live'
|
||||
| 'movie'
|
||||
| 'series',
|
||||
poster_url: item.poster_url,
|
||||
content_id: item.id,
|
||||
playlist_id: playlistId,
|
||||
viewed_at: item.viewed_at || '',
|
||||
xtream_id: item.xtream_id,
|
||||
category_id: item.category_id,
|
||||
})),
|
||||
recentItems: items.map((item) =>
|
||||
mapDbRecentItem(item, playlistId)
|
||||
),
|
||||
});
|
||||
}
|
||||
})
|
||||
)
|
||||
),
|
||||
clearRecentItems: rxMethod<{ id: string }>(
|
||||
pipe(
|
||||
switchMap(async (playlist) => {
|
||||
await dbService.clearPlaylistRecentItems(playlist.id);
|
||||
patchState(store, { recentItems: [] });
|
||||
})
|
||||
)
|
||||
),
|
||||
removeRecentItem: rxMethod<{ itemId: number; playlistId: string }>(
|
||||
pipe(
|
||||
switchMap(async ({ itemId, playlistId }) => {
|
||||
await dbService.removeRecentItem(itemId, playlistId);
|
||||
// Reload recent items to update UI
|
||||
const items =
|
||||
await dbService.getRecentItems(playlistId);
|
||||
const mappedItems = items.map((item) => ({
|
||||
})
|
||||
)
|
||||
),
|
||||
async loadGlobalRecentItems() {
|
||||
try {
|
||||
const xtreamItems =
|
||||
await dbService.getGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
playlistsService.getAllPlaylists()
|
||||
)) as any[];
|
||||
const stalkerItems: RecentlyViewedItem[] = playlists
|
||||
.filter((playlist: any) =>
|
||||
Boolean(playlist.macAddress)
|
||||
)
|
||||
.reduce(
|
||||
(acc: RecentlyViewedItem[], playlist: any) => {
|
||||
const recent = Array.isArray(
|
||||
playlist.recentlyViewed
|
||||
)
|
||||
? playlist.recentlyViewed
|
||||
: [];
|
||||
const mapped = recent.map(
|
||||
(rawItem: any, index: number) => {
|
||||
const item = (rawItem ??
|
||||
{}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(
|
||||
item,
|
||||
String(playlist._id),
|
||||
index
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(
|
||||
item
|
||||
),
|
||||
type: extractStalkerItemType(
|
||||
item
|
||||
),
|
||||
poster_url:
|
||||
extractStalkerItemPoster(
|
||||
item
|
||||
),
|
||||
content_id: id,
|
||||
playlist_id: String(
|
||||
playlist._id
|
||||
),
|
||||
playlist_name:
|
||||
playlist.title ||
|
||||
'Stalker Portal',
|
||||
viewed_at: normalizeStalkerDate(
|
||||
item['added_at']
|
||||
),
|
||||
xtream_id: id,
|
||||
category_id: String(
|
||||
item['category_id'] ?? ''
|
||||
),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
};
|
||||
}
|
||||
);
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const normalizedXtream: RecentlyViewedItem[] = (
|
||||
xtreamItems || []
|
||||
).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as 'live' | 'movie' | 'series',
|
||||
poster_url: item.poster_url,
|
||||
content_id: item.id,
|
||||
playlist_id: playlistId,
|
||||
viewed_at: item.viewed_at || '',
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
viewed_at: item.viewed_at,
|
||||
xtream_id: item.xtream_id,
|
||||
category_id: item.category_id,
|
||||
source: 'xtream',
|
||||
}));
|
||||
patchState(store, { recentItems: mappedItems });
|
||||
})
|
||||
)
|
||||
),
|
||||
async loadGlobalRecentItems() {
|
||||
try {
|
||||
const xtreamItems = await dbService.getGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
playlistsService.getAllPlaylists()
|
||||
)) as any[];
|
||||
const stalkerItems: RecentlyViewedItem[] = playlists
|
||||
.filter((playlist: any) => Boolean(playlist.macAddress))
|
||||
.reduce(
|
||||
(acc: RecentlyViewedItem[], playlist: any) => {
|
||||
const recent = Array.isArray(
|
||||
playlist.recentlyViewed
|
||||
)
|
||||
? playlist.recentlyViewed
|
||||
: [];
|
||||
const mapped = recent.map((item: any) => {
|
||||
const categoryId = String(
|
||||
item?.category_id ?? ''
|
||||
);
|
||||
const type =
|
||||
categoryId === 'itv'
|
||||
? 'live'
|
||||
: categoryId === 'series'
|
||||
? 'series'
|
||||
: 'movie';
|
||||
const viewedAt = (() => {
|
||||
const raw = item?.added_at;
|
||||
if (
|
||||
typeof raw === 'number' ||
|
||||
/^\d+$/.test(String(raw ?? ''))
|
||||
) {
|
||||
return new Date(
|
||||
Number(raw)
|
||||
).toISOString();
|
||||
}
|
||||
const parsed = new Date(
|
||||
String(raw ?? '')
|
||||
);
|
||||
return Number.isNaN(parsed.getTime())
|
||||
? new Date().toISOString()
|
||||
: parsed.toISOString();
|
||||
})();
|
||||
|
||||
return {
|
||||
id: String(item?.id ?? ''),
|
||||
title:
|
||||
item?.title ??
|
||||
item?.o_name ??
|
||||
item?.name ??
|
||||
'Unknown',
|
||||
type,
|
||||
poster_url:
|
||||
item?.cover ??
|
||||
item?.logo ??
|
||||
item?.poster_url ??
|
||||
'',
|
||||
content_id: String(item?.id ?? ''),
|
||||
playlist_id: String(playlist._id),
|
||||
playlist_name:
|
||||
playlist.title || 'Stalker Portal',
|
||||
viewed_at: viewedAt,
|
||||
xtream_id: String(item?.id ?? ''),
|
||||
category_id: categoryId,
|
||||
source: 'stalker',
|
||||
stalker_item: item,
|
||||
};
|
||||
});
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
},
|
||||
[]
|
||||
const items = [
|
||||
...normalizedXtream,
|
||||
...stalkerItems,
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewed_at).getTime() -
|
||||
new Date(a.viewed_at).getTime()
|
||||
);
|
||||
|
||||
const normalizedXtream: RecentlyViewedItem[] = (
|
||||
xtreamItems || []
|
||||
).map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type as 'live' | 'movie' | 'series',
|
||||
poster_url: item.poster_url,
|
||||
content_id: item.id,
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
viewed_at: item.viewed_at,
|
||||
xtream_id: item.xtream_id,
|
||||
category_id: item.category_id,
|
||||
source: 'xtream',
|
||||
}));
|
||||
|
||||
const items = [...normalizedXtream, ...stalkerItems].sort(
|
||||
(a, b) =>
|
||||
new Date(b.viewed_at).getTime() -
|
||||
new Date(a.viewed_at).getTime()
|
||||
);
|
||||
patchState(store, {
|
||||
recentItems: items,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error loading global recent items', error);
|
||||
patchState(store, { recentItems: [] });
|
||||
}
|
||||
},
|
||||
async clearGlobalRecentlyViewed() {
|
||||
try {
|
||||
await dbService.clearGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
playlistsService.getAllPlaylists()
|
||||
)) as any[];
|
||||
await Promise.all(
|
||||
playlists
|
||||
.filter((playlist: any) =>
|
||||
Boolean(playlist.macAddress)
|
||||
)
|
||||
.map((playlist: any) =>
|
||||
firstValueFrom(
|
||||
playlistsService.clearPortalRecentlyViewed(
|
||||
playlist._id
|
||||
patchState(store, {
|
||||
recentItems: items,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Error loading global recent items',
|
||||
error
|
||||
);
|
||||
patchState(store, { recentItems: [] });
|
||||
}
|
||||
},
|
||||
async clearGlobalRecentlyViewed() {
|
||||
try {
|
||||
await dbService.clearGlobalRecentlyViewed();
|
||||
const playlists = (await firstValueFrom(
|
||||
playlistsService.getAllPlaylists()
|
||||
)) as any[];
|
||||
await Promise.all(
|
||||
playlists
|
||||
.filter((playlist: any) =>
|
||||
Boolean(playlist.macAddress)
|
||||
)
|
||||
.map((playlist: any) =>
|
||||
firstValueFrom(
|
||||
playlistsService.clearPortalRecentlyViewed(
|
||||
playlist._id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
patchState(store, { recentItems: [] });
|
||||
} catch (error) {
|
||||
logger.error('Error clearing global recently viewed', error);
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
patchState(store, { recentItems: [] });
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'Error clearing global recently viewed',
|
||||
error
|
||||
);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -137,7 +137,9 @@ export const selectFavorites = createSelector(
|
||||
data.selectedId &&
|
||||
data.entities[data.selectedId]
|
||||
) {
|
||||
return data.entities[data.selectedId]?.favorites || [];
|
||||
return (data.entities[data.selectedId]?.favorites || []).filter(
|
||||
(f): f is string => typeof f === 'string'
|
||||
);
|
||||
} else return [];
|
||||
}
|
||||
);
|
||||
@@ -11,10 +11,11 @@ export * from './lib/ipc-commands';
|
||||
export * from './lib/language.enum';
|
||||
export * from './lib/parsed-playlist.interface';
|
||||
export * from './lib/playback-position.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
export * from './lib/portal-playback.interface';
|
||||
export * from './lib/playlist-meta.type';
|
||||
export * from './lib/playlist.interface';
|
||||
export * from './lib/portal-activity-item.interface';
|
||||
export * from './lib/portal-debug.interface';
|
||||
export * from './lib/portal-playback.interface';
|
||||
export * from './lib/settings.interface';
|
||||
export * from './lib/stalker-portal-actions.enum';
|
||||
export * from './lib/store-keys.enum';
|
||||
@@ -31,6 +32,8 @@ export * from './lib/xtream-vod-details.interface';
|
||||
export * from './lib/xtream-vod-stream.interface';
|
||||
|
||||
// Stalker interfaces
|
||||
export * from './lib/stalker-item.normalizer';
|
||||
export * from './lib/stalker-portal-item.interface';
|
||||
export * from './lib/stalker-serial-details.interface';
|
||||
export * from './lib/stalker-vod-details.interface';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { StalkerPortalItem } from './stalker-portal-item.interface';
|
||||
|
||||
/**
|
||||
* An interface that describe the possible states of the playlist update/refresh process
|
||||
*/
|
||||
@@ -17,7 +19,11 @@ export interface Playlist {
|
||||
playlist?: any;
|
||||
importDate: string;
|
||||
lastUsage: string;
|
||||
favorites?: string[];
|
||||
/**
|
||||
* M3U playlists store channel URL strings (`string[]`).
|
||||
* Stalker portals store full item objects (`StalkerPortalItem[]`).
|
||||
*/
|
||||
favorites?: (string | StalkerPortalItem)[];
|
||||
items?: unknown[];
|
||||
header?: unknown;
|
||||
count: number;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
PortalActivityType,
|
||||
StalkerPortalItem,
|
||||
} from './stalker-portal-item.interface';
|
||||
|
||||
/**
|
||||
* Base interface for cross-provider activity items (recently viewed,
|
||||
* favorites) displayed on the dashboard and in global lists.
|
||||
*/
|
||||
export interface PortalActivityItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: PortalActivityType;
|
||||
playlist_id: string;
|
||||
playlist_name?: string;
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
source?: 'xtream' | 'stalker';
|
||||
/** Original stalker item for navigation state; undefined for xtream. */
|
||||
stalker_item?: StalkerPortalItem;
|
||||
}
|
||||
|
||||
/** A recently-viewed item with a `viewed_at` timestamp. */
|
||||
export interface PortalRecentItem extends PortalActivityItem {
|
||||
viewed_at: string;
|
||||
}
|
||||
|
||||
/** A favorite item with an `added_at` timestamp. */
|
||||
export interface PortalFavoriteItem extends PortalActivityItem {
|
||||
added_at: string;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
PortalActivityType,
|
||||
StalkerPortalItem,
|
||||
} from './stalker-portal-item.interface';
|
||||
|
||||
/**
|
||||
* Extract a stable ID from an untyped Stalker portal item.
|
||||
*
|
||||
* Stalker portals use different id fields depending on the content type
|
||||
* (`id`, `stream_id`, `series_id`, `movie_id`). This function returns
|
||||
* the first non-empty one, or a generated fallback based on the playlist
|
||||
* id and index.
|
||||
*/
|
||||
export function extractStalkerItemId(
|
||||
item: StalkerPortalItem | Record<string, unknown>,
|
||||
fallbackPrefix = '',
|
||||
fallbackIndex = 0
|
||||
): string {
|
||||
const raw = item as Record<string, unknown>;
|
||||
const id =
|
||||
raw['id'] ?? raw['stream_id'] ?? raw['series_id'] ?? raw['movie_id'];
|
||||
const idStr = String(id ?? '').trim();
|
||||
return idStr || `${fallbackPrefix}-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a display title from a Stalker portal item.
|
||||
*/
|
||||
export function extractStalkerItemTitle(
|
||||
item: StalkerPortalItem | Record<string, unknown>
|
||||
): string {
|
||||
const raw = item as Record<string, unknown>;
|
||||
return String(raw['title'] ?? raw['o_name'] ?? raw['name'] ?? 'Unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a poster/cover URL from a Stalker portal item.
|
||||
*/
|
||||
export function extractStalkerItemPoster(
|
||||
item: StalkerPortalItem | Record<string, unknown>
|
||||
): string {
|
||||
const raw = item as Record<string, unknown>;
|
||||
return String(raw['cover'] ?? raw['logo'] ?? raw['poster_url'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the normalised activity type of a Stalker item.
|
||||
*
|
||||
* - `itv` / `live` → `'live'`
|
||||
* - `series` or `is_series` truthy → `'series'`
|
||||
* - everything else → `'movie'`
|
||||
*/
|
||||
export function extractStalkerItemType(
|
||||
item: StalkerPortalItem | Record<string, unknown>
|
||||
): PortalActivityType {
|
||||
const raw = item as Record<string, unknown>;
|
||||
const categoryId = String(raw['category_id'] ?? '').toLowerCase();
|
||||
const streamType = String(raw['stream_type'] ?? '').toLowerCase();
|
||||
|
||||
if (categoryId === 'itv' || streamType === 'live') {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
if (
|
||||
categoryId === 'series' ||
|
||||
raw['is_series'] === true ||
|
||||
raw['is_series'] === 1 ||
|
||||
raw['is_series'] === '1'
|
||||
) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a raw date value (epoch number, numeric string, or ISO string)
|
||||
* into a consistent ISO 8601 string.
|
||||
*/
|
||||
export function normalizeStalkerDate(value: unknown): string {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return '';
|
||||
const ms = value > 10_000_000_000 ? value : value * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a raw item from `Playlist.favorites` matches the given itemId.
|
||||
*
|
||||
* Used to filter stalker favorites/recently-viewed arrays when removing items.
|
||||
*/
|
||||
export function stalkerItemMatchesId(
|
||||
raw: unknown,
|
||||
targetId: string,
|
||||
fallbackPrefix = '',
|
||||
fallbackIndex = 0
|
||||
): boolean {
|
||||
const item = (raw ?? {}) as Record<string, unknown>;
|
||||
const rawId = extractStalkerItemId(item, fallbackPrefix, fallbackIndex);
|
||||
return rawId === targetId;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Represents a Stalker portal item stored in `Playlist.favorites` or
|
||||
* `Playlist.recentlyViewed` arrays.
|
||||
*
|
||||
* Stalker portals store favourites / recently-viewed as full objects
|
||||
* (unlike M3U playlists, which use a simple `string[]` of channel URLs).
|
||||
*/
|
||||
export interface StalkerPortalItem {
|
||||
id?: string | number;
|
||||
stream_id?: string | number;
|
||||
series_id?: string | number;
|
||||
movie_id?: string | number;
|
||||
title?: string;
|
||||
/** Original name */
|
||||
o_name?: string;
|
||||
name?: string;
|
||||
cover?: string;
|
||||
logo?: string;
|
||||
poster_url?: string;
|
||||
/** 'itv' | 'vod' | 'series' — Stalker content type */
|
||||
category_id?: string | number;
|
||||
stream_type?: string;
|
||||
is_series?: boolean | number | string;
|
||||
added_at?: string | number;
|
||||
cmd?: string;
|
||||
}
|
||||
|
||||
/** Activity type normalised across all providers. */
|
||||
export type PortalActivityType = 'live' | 'movie' | 'series';
|
||||
@@ -5,54 +5,39 @@ import {
|
||||
selectActivePlaylist,
|
||||
selectAllPlaylistsMeta,
|
||||
} from 'm3u-state';
|
||||
import { DatabaseService } from 'services';
|
||||
import {
|
||||
DatabaseService,
|
||||
GlobalFavoriteItem as DbGlobalFavoriteItem,
|
||||
GlobalRecentItem as DbGlobalRecentItem,
|
||||
} from 'services';
|
||||
import { PlaylistMeta } from 'shared-interfaces';
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
stalkerItemMatchesId,
|
||||
} from 'shared-interfaces';
|
||||
import {
|
||||
buildStalkerFavoriteItems,
|
||||
buildStalkerRecentItems,
|
||||
getTypeLabel,
|
||||
mapDbFavoriteToItem,
|
||||
mapDbRecentToItem,
|
||||
toDateTimestamp,
|
||||
toTimestamp,
|
||||
} from './dashboard-mappers';
|
||||
import {
|
||||
getGlobalFavoriteNavigation,
|
||||
getRecentItemNavigation,
|
||||
} from './dashboard-navigation.utils';
|
||||
import {
|
||||
DashboardWidgetProvider,
|
||||
DashboardWidgetScopeSettings,
|
||||
createDefaultWidgetScope,
|
||||
} from './dashboard-widget.model';
|
||||
|
||||
type RecentActivityType = 'live' | 'movie' | 'series';
|
||||
|
||||
export type DashboardContentKind = 'all' | 'channels' | 'vod' | 'series';
|
||||
|
||||
export interface GlobalRecentItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: RecentActivityType;
|
||||
playlist_id: string;
|
||||
playlist_name?: string;
|
||||
viewed_at: string;
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
source?: 'xtream' | 'stalker';
|
||||
stalker_item?: unknown;
|
||||
}
|
||||
|
||||
export interface DashboardFavoriteItem {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: RecentActivityType;
|
||||
playlist_id: string;
|
||||
playlist_name?: string;
|
||||
added_at: string;
|
||||
category_id: string | number;
|
||||
xtream_id: string | number;
|
||||
poster_url?: string;
|
||||
source?: 'xtream' | 'stalker';
|
||||
stalker_item?: unknown;
|
||||
}
|
||||
|
||||
interface DashboardNavigationTarget {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
/** @deprecated Use {@link PortalRecentItem} from `shared-interfaces` instead. */
|
||||
export type GlobalRecentItem = PortalRecentItem;
|
||||
/** @deprecated Use {@link PortalFavoriteItem} from `shared-interfaces` instead. */
|
||||
export type DashboardFavoriteItem = PortalFavoriteItem;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DashboardDataService {
|
||||
@@ -68,29 +53,25 @@ export class DashboardDataService {
|
||||
readonly activePlaylist = this.store.selectSignal(selectActivePlaylist);
|
||||
|
||||
readonly stalkerGlobalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
this.buildStalkerGlobalRecentItems(this.playlists())
|
||||
buildStalkerRecentItems(this.playlists())
|
||||
);
|
||||
|
||||
readonly globalRecentItems = computed<GlobalRecentItem[]>(() =>
|
||||
[...this.xtreamGlobalRecentItems(), ...this.stalkerGlobalRecentItems()]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
this.toDateTimestamp(b.viewed_at) -
|
||||
this.toDateTimestamp(a.viewed_at)
|
||||
toDateTimestamp(b.viewed_at) - toDateTimestamp(a.viewed_at)
|
||||
)
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
readonly stalkerGlobalFavorites = computed<DashboardFavoriteItem[]>(() =>
|
||||
this.buildStalkerGlobalFavorites(this.playlists())
|
||||
buildStalkerFavoriteItems(this.playlists())
|
||||
);
|
||||
|
||||
readonly globalFavoriteItems = computed(() =>
|
||||
[...this.xtreamGlobalFavorites(), ...this.stalkerGlobalFavorites()]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
this.toTimestamp(b.added_at) - this.toTimestamp(a.added_at)
|
||||
)
|
||||
.sort((a, b) => toTimestamp(b.added_at) - toTimestamp(a.added_at))
|
||||
.slice(0, 200)
|
||||
);
|
||||
|
||||
@@ -132,7 +113,7 @@ export class DashboardDataService {
|
||||
try {
|
||||
const recentItems = await this.dbService.getGlobalRecentlyViewed();
|
||||
const normalized = recentItems.map((item) =>
|
||||
this.mapGlobalRecent(item)
|
||||
mapDbRecentToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalRecentItems.set(normalized));
|
||||
} catch (err) {
|
||||
@@ -153,7 +134,7 @@ export class DashboardDataService {
|
||||
try {
|
||||
const favorites = await this.dbService.getGlobalFavorites();
|
||||
const normalized = favorites.map((item) =>
|
||||
this.mapGlobalFavorite(item)
|
||||
mapDbFavoriteToItem(item)
|
||||
);
|
||||
this.ngZone.run(() => this.xtreamGlobalFavorites.set(normalized));
|
||||
} catch (err) {
|
||||
@@ -166,7 +147,7 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
isTypeInKind(
|
||||
type: RecentActivityType,
|
||||
type: PortalActivityType,
|
||||
kind: DashboardContentKind
|
||||
): boolean {
|
||||
if (kind === 'all') {
|
||||
@@ -259,17 +240,17 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
getRecentItemTypeLabel(item: GlobalRecentItem): string {
|
||||
return this.getTypeLabel(item.type);
|
||||
return getTypeLabel(item.type);
|
||||
}
|
||||
|
||||
getRecentItemLink(item: GlobalRecentItem): string[] {
|
||||
return this.getRecentItemNavigation(item).link;
|
||||
return getRecentItemNavigation(item).link;
|
||||
}
|
||||
|
||||
getRecentItemNavigationState(
|
||||
item: GlobalRecentItem
|
||||
): Record<string, unknown> | undefined {
|
||||
return this.getRecentItemNavigation(item).state;
|
||||
return getRecentItemNavigation(item).state;
|
||||
}
|
||||
|
||||
async removeGlobalRecentItem(item: GlobalRecentItem): Promise<void> {
|
||||
@@ -289,22 +270,24 @@ export class DashboardDataService {
|
||||
if (!playlist) return;
|
||||
|
||||
const currentRecent = Array.isArray(
|
||||
(playlist as any).recentlyViewed
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? [...(playlist as any).recentlyViewed]
|
||||
? [
|
||||
...((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? []),
|
||||
]
|
||||
: [];
|
||||
|
||||
const itemMatchStr = String(item.id);
|
||||
const filteredRecent = currentRecent.filter((raw, index) => {
|
||||
const rawId = String(
|
||||
(raw as any)?.id ??
|
||||
(raw as any)?.stream_id ??
|
||||
(raw as any)?.series_id ??
|
||||
(raw as any)?.movie_id ??
|
||||
`${playlist._id}-${index}`
|
||||
);
|
||||
return rawId !== itemMatchStr && rawId !== '';
|
||||
});
|
||||
const filteredRecent = currentRecent.filter(
|
||||
(raw, index) =>
|
||||
!stalkerItemMatchesId(
|
||||
raw,
|
||||
itemMatchStr,
|
||||
playlist._id,
|
||||
index
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
@@ -328,17 +311,17 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
getFavoriteItemTypeLabel(item: DashboardFavoriteItem): string {
|
||||
return this.getTypeLabel(item.type);
|
||||
return getTypeLabel(item.type);
|
||||
}
|
||||
|
||||
getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] {
|
||||
return this.getGlobalFavoriteNavigation(item).link;
|
||||
return getGlobalFavoriteNavigation(item).link;
|
||||
}
|
||||
|
||||
getGlobalFavoriteNavigationState(
|
||||
item: DashboardFavoriteItem
|
||||
): Record<string, unknown> | undefined {
|
||||
return this.getGlobalFavoriteNavigation(item).state;
|
||||
return getGlobalFavoriteNavigation(item).state;
|
||||
}
|
||||
|
||||
async removeGlobalFavorite(item: DashboardFavoriteItem): Promise<void> {
|
||||
@@ -362,17 +345,15 @@ export class DashboardDataService {
|
||||
: [];
|
||||
const itemMatchStr = String(item.id);
|
||||
|
||||
// Stalker favorite objects are usually stored entirely in the favorites array
|
||||
const filteredFavorites = currentFavorites.filter((raw, index) => {
|
||||
const rawId = String(
|
||||
(raw as any)?.id ??
|
||||
(raw as any)?.stream_id ??
|
||||
(raw as any)?.series_id ??
|
||||
(raw as any)?.movie_id ??
|
||||
`${playlist._id}-${index}`
|
||||
);
|
||||
return rawId !== itemMatchStr && rawId !== '';
|
||||
});
|
||||
const filteredFavorites = currentFavorites.filter(
|
||||
(raw, index) =>
|
||||
!stalkerItemMatchesId(
|
||||
raw,
|
||||
itemMatchStr,
|
||||
playlist._id,
|
||||
index
|
||||
)
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
PlaylistActions.updatePlaylistMeta({
|
||||
@@ -386,7 +367,7 @@ export class DashboardDataService {
|
||||
}
|
||||
|
||||
formatTimestamp(value?: string | number): string {
|
||||
const timestamp = this.toTimestamp(value);
|
||||
const timestamp = toTimestamp(value);
|
||||
if (!timestamp) {
|
||||
return 'Not yet synced';
|
||||
}
|
||||
@@ -394,363 +375,6 @@ export class DashboardDataService {
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
private mapGlobalFavorite(
|
||||
item: DbGlobalFavoriteItem
|
||||
): DashboardFavoriteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: this.normalizeType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
added_at: this.normalizeDateString(item.added_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
private mapGlobalRecent(item: DbGlobalRecentItem): GlobalRecentItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: this.normalizeType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
viewed_at: this.normalizeDateString(item.viewed_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
private buildStalkerGlobalRecentItems(
|
||||
playlists: PlaylistMeta[]
|
||||
): GlobalRecentItem[] {
|
||||
const items = playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<GlobalRecentItem[]>((acc, playlist) => {
|
||||
const recentItems = Array.isArray(
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? ((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? [])
|
||||
: [];
|
||||
|
||||
const mapped = recentItems.map((rawItem, index: number) => {
|
||||
const item = (rawItem ?? {}) as Record<string, unknown>;
|
||||
const categoryId = String(item.category_id ?? '');
|
||||
const id = String(
|
||||
item.id ??
|
||||
item.stream_id ??
|
||||
item.series_id ??
|
||||
item.movie_id ??
|
||||
`${playlist._id}-${index}`
|
||||
);
|
||||
const title = String(
|
||||
item.title ?? item.o_name ?? item.name ?? 'Unknown'
|
||||
);
|
||||
const posterUrl = String(
|
||||
item.cover ?? item.logo ?? item.poster_url ?? ''
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type: this.normalizeStalkerType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
viewed_at: this.normalizeDateString(item.added_at),
|
||||
category_id: categoryId,
|
||||
xtream_id: id,
|
||||
poster_url: posterUrl,
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
};
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
this.toDateTimestamp(b.viewed_at) -
|
||||
this.toDateTimestamp(a.viewed_at)
|
||||
);
|
||||
}
|
||||
|
||||
private buildStalkerGlobalFavorites(
|
||||
playlists: PlaylistMeta[]
|
||||
): DashboardFavoriteItem[] {
|
||||
const items = playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<DashboardFavoriteItem[]>((acc, playlist) => {
|
||||
const favorites = Array.isArray(playlist.favorites)
|
||||
? playlist.favorites
|
||||
: [];
|
||||
|
||||
const mapped = favorites.map((item, index) => {
|
||||
const categoryId = String((item as any)?.category_id ?? '');
|
||||
const type = this.normalizeStalkerType(item);
|
||||
const id = String(
|
||||
(item as any)?.id ??
|
||||
(item as any)?.stream_id ??
|
||||
(item as any)?.series_id ??
|
||||
(item as any)?.movie_id ??
|
||||
`${playlist._id}-${index}`
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
title:
|
||||
(item as any)?.title ??
|
||||
(item as any)?.o_name ??
|
||||
(item as any)?.name ??
|
||||
'Unknown',
|
||||
type,
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
added_at: this.normalizeDateString(
|
||||
(item as any)?.added_at
|
||||
),
|
||||
category_id: categoryId,
|
||||
xtream_id: id,
|
||||
poster_url:
|
||||
(item as any)?.cover ??
|
||||
(item as any)?.logo ??
|
||||
(item as any)?.poster_url ??
|
||||
'',
|
||||
source: 'stalker' as const,
|
||||
stalker_item: item,
|
||||
};
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return items.sort(
|
||||
(a, b) =>
|
||||
this.toTimestamp(b.added_at) - this.toTimestamp(a.added_at)
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeStalkerType(item: unknown): RecentActivityType {
|
||||
const categoryId = String(
|
||||
(item as any)?.category_id ?? ''
|
||||
).toLowerCase();
|
||||
const streamType = String(
|
||||
(item as any)?.stream_type ?? ''
|
||||
).toLowerCase();
|
||||
|
||||
if (categoryId === 'itv' || streamType === 'live') {
|
||||
return 'live';
|
||||
}
|
||||
|
||||
if (
|
||||
categoryId === 'series' ||
|
||||
(item as any)?.is_series === true ||
|
||||
(item as any)?.is_series === 1 ||
|
||||
(item as any)?.is_series === '1'
|
||||
) {
|
||||
return 'series';
|
||||
}
|
||||
|
||||
return 'movie';
|
||||
}
|
||||
|
||||
private getRecentItemNavigation(
|
||||
item: GlobalRecentItem
|
||||
): DashboardNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
|
||||
state: {
|
||||
openRecentItem: this.buildStalkerStateItem(
|
||||
item.stalker_item,
|
||||
item
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...this.buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private getGlobalFavoriteNavigation(
|
||||
item: DashboardFavoriteItem
|
||||
): DashboardNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
|
||||
state: {
|
||||
openFavoriteItem: this.buildStalkerStateItem(
|
||||
item.stalker_item,
|
||||
item
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...this.buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private buildXtreamNavigationTarget(params: {
|
||||
playlistId: string;
|
||||
type: RecentActivityType;
|
||||
categoryId: string | number;
|
||||
itemId: string | number;
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
}): DashboardNavigationTarget {
|
||||
const link = this.buildXtreamItemLink(params);
|
||||
const routeType = this.toXtreamRouteType(params.type);
|
||||
if (routeType !== 'live') {
|
||||
return { link };
|
||||
}
|
||||
|
||||
const streamId = Number(this.toPathSegment(params.itemId));
|
||||
if (!Number.isFinite(streamId) || streamId <= 0) {
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
link,
|
||||
state: {
|
||||
openXtreamLiveItemId: streamId,
|
||||
openXtreamLiveTitle: params.title || '',
|
||||
openXtreamLivePoster: params.imageUrl || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildXtreamItemLink(params: {
|
||||
playlistId: string;
|
||||
type: RecentActivityType;
|
||||
categoryId: string | number;
|
||||
itemId: string | number;
|
||||
}): string[] {
|
||||
const routeType = this.toXtreamRouteType(params.type);
|
||||
const categoryId = this.toPathSegment(params.categoryId);
|
||||
const itemId = this.toPathSegment(params.itemId);
|
||||
|
||||
if (routeType === 'live') {
|
||||
return categoryId
|
||||
? [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
'live',
|
||||
categoryId,
|
||||
]
|
||||
: ['/workspace', 'xtreams', params.playlistId, 'live'];
|
||||
}
|
||||
|
||||
if (categoryId && itemId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
itemId,
|
||||
];
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
];
|
||||
}
|
||||
|
||||
return ['/workspace', 'xtreams', params.playlistId, routeType];
|
||||
}
|
||||
|
||||
private buildStalkerStateItem(
|
||||
rawItem: unknown,
|
||||
fallback: {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: RecentActivityType;
|
||||
poster_url?: string;
|
||||
}
|
||||
) {
|
||||
const normalizedCategory = this.toStalkerCategoryId(
|
||||
(rawItem as any)?.category_id ?? fallback.type
|
||||
);
|
||||
const normalizedRaw = rawItem
|
||||
? {
|
||||
...(rawItem as Record<string, unknown>),
|
||||
category_id: normalizedCategory,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (normalizedRaw) {
|
||||
return normalizedRaw;
|
||||
}
|
||||
|
||||
const title = fallback.title || 'Unknown';
|
||||
return {
|
||||
id: String(fallback.id ?? ''),
|
||||
title,
|
||||
name: title,
|
||||
o_name: title,
|
||||
category_id: normalizedCategory,
|
||||
cover: fallback.poster_url || '',
|
||||
logo: fallback.poster_url || '',
|
||||
};
|
||||
}
|
||||
|
||||
private toXtreamRouteType(
|
||||
type: RecentActivityType
|
||||
): 'live' | 'vod' | 'series' {
|
||||
if (type === 'movie') {
|
||||
return 'vod';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
private toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
if (normalized === 'series') {
|
||||
return 'series';
|
||||
}
|
||||
if (normalized === 'itv' || normalized === 'live') {
|
||||
return 'itv';
|
||||
}
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
private toPathSegment(value: string | number): string {
|
||||
const segment = String(value ?? '').trim();
|
||||
return segment;
|
||||
}
|
||||
|
||||
private normalizeScope(
|
||||
scope?: DashboardWidgetScopeSettings
|
||||
): DashboardWidgetScopeSettings {
|
||||
@@ -772,95 +396,7 @@ export class DashboardDataService {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private normalizeType(value: string): RecentActivityType {
|
||||
return value === 'live' || value === 'series' ? value : 'movie';
|
||||
}
|
||||
|
||||
private getTypeLabel(
|
||||
type: RecentActivityType
|
||||
): 'Live' | 'Movie' | 'Series' {
|
||||
if (type === 'live') {
|
||||
return 'Live';
|
||||
}
|
||||
|
||||
if (type === 'series') {
|
||||
return 'Series';
|
||||
}
|
||||
|
||||
return 'Movie';
|
||||
}
|
||||
|
||||
private normalizeDateString(value: unknown): string {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '';
|
||||
}
|
||||
const ms = value > 10_000_000_000 ? value : value * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric)) {
|
||||
const ms =
|
||||
numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private getRecentTimestamp(item: PlaylistMeta): number {
|
||||
return (
|
||||
this.toTimestamp(item.updateDate) ||
|
||||
this.toTimestamp(item.importDate)
|
||||
);
|
||||
}
|
||||
|
||||
private toDateTimestamp(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private toTimestamp(value?: string | number): number {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return toTimestamp(item.updateDate) || toTimestamp(item.importDate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
GlobalFavoriteItem as DbGlobalFavoriteItem,
|
||||
GlobalRecentItem as DbGlobalRecentItem,
|
||||
} from 'services';
|
||||
import {
|
||||
PlaylistMeta,
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
extractStalkerItemId,
|
||||
extractStalkerItemPoster,
|
||||
extractStalkerItemTitle,
|
||||
extractStalkerItemType,
|
||||
normalizeStalkerDate,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
// ────── Type / label helpers ──────
|
||||
|
||||
export function normalizeActivityType(value: string): PortalActivityType {
|
||||
return value === 'live' || value === 'series' ? value : 'movie';
|
||||
}
|
||||
|
||||
export function getTypeLabel(
|
||||
type: PortalActivityType
|
||||
): 'Live' | 'Movie' | 'Series' {
|
||||
if (type === 'live') return 'Live';
|
||||
if (type === 'series') return 'Series';
|
||||
return 'Movie';
|
||||
}
|
||||
|
||||
// ────── Xtream DB → ViewModel ──────
|
||||
|
||||
export function mapDbFavoriteToItem(
|
||||
item: DbGlobalFavoriteItem
|
||||
): PortalFavoriteItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
added_at: normalizeStalkerDate(item.added_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: normalizeActivityType(item.type),
|
||||
playlist_id: item.playlist_id,
|
||||
playlist_name: item.playlist_name,
|
||||
viewed_at: normalizeStalkerDate(item.viewed_at),
|
||||
category_id: item.category_id,
|
||||
xtream_id: item.xtream_id,
|
||||
poster_url: item.poster_url,
|
||||
source: 'xtream',
|
||||
};
|
||||
}
|
||||
|
||||
// ────── Stalker playlist → ViewModel ──────
|
||||
|
||||
export function buildStalkerRecentItems(
|
||||
playlists: PlaylistMeta[]
|
||||
): PortalRecentItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<PortalRecentItem[]>((acc, playlist) => {
|
||||
const recentItems = Array.isArray(
|
||||
(playlist as { recentlyViewed?: unknown[] }).recentlyViewed
|
||||
)
|
||||
? ((playlist as { recentlyViewed?: unknown[] })
|
||||
.recentlyViewed ?? [])
|
||||
: [];
|
||||
|
||||
const mapped = recentItems.map((rawItem, index) => {
|
||||
const item = (rawItem ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(item, playlist._id, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(item),
|
||||
type: extractStalkerItemType(item),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
viewed_at: normalizeStalkerDate(item['added_at']),
|
||||
category_id: String(item['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(item),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: rawItem,
|
||||
} as PortalRecentItem;
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function buildStalkerFavoriteItems(
|
||||
playlists: PlaylistMeta[]
|
||||
): PortalFavoriteItem[] {
|
||||
return playlists
|
||||
.filter((playlist) => Boolean(playlist.macAddress))
|
||||
.reduce<PortalFavoriteItem[]>((acc, playlist) => {
|
||||
const favorites = Array.isArray(playlist.favorites)
|
||||
? playlist.favorites
|
||||
: [];
|
||||
|
||||
const mapped = favorites.map((item, index) => {
|
||||
const raw = (item ?? {}) as Record<string, unknown>;
|
||||
const id = extractStalkerItemId(raw, playlist._id, index);
|
||||
|
||||
return {
|
||||
id,
|
||||
title: extractStalkerItemTitle(raw),
|
||||
type: extractStalkerItemType(raw),
|
||||
playlist_id: playlist._id,
|
||||
playlist_name: playlist.title || 'Stalker Portal',
|
||||
added_at: normalizeStalkerDate(raw['added_at']),
|
||||
category_id: String(raw['category_id'] ?? ''),
|
||||
xtream_id: id,
|
||||
poster_url: extractStalkerItemPoster(raw),
|
||||
source: 'stalker' as const,
|
||||
stalker_item: item,
|
||||
} as PortalFavoriteItem;
|
||||
});
|
||||
|
||||
acc.push(...mapped);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
// ────── Timestamp helpers ──────
|
||||
|
||||
export function toDateTimestamp(value: unknown): number {
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||||
return value > 10_000_000_000 ? value : value * 1000;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
const numeric = Number(trimmed);
|
||||
if (Number.isFinite(numeric) && numeric > 0) {
|
||||
return numeric > 10_000_000_000 ? numeric : numeric * 1000;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function toTimestamp(value?: string | number): number {
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
PortalActivityType,
|
||||
PortalFavoriteItem,
|
||||
PortalRecentItem,
|
||||
StalkerPortalItem,
|
||||
} from 'shared-interfaces';
|
||||
|
||||
/** Internal target produced by navigation helpers. */
|
||||
export interface DashboardNavigationTarget {
|
||||
link: string[];
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ────── Recent-item navigation ──────
|
||||
|
||||
export function getRecentItemNavigation(
|
||||
item: PortalRecentItem
|
||||
): DashboardNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'recent'],
|
||||
state: {
|
||||
openRecentItem: buildStalkerStateItem(item.stalker_item, item),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
});
|
||||
}
|
||||
|
||||
// ────── Favorite-item navigation ──────
|
||||
|
||||
export function getGlobalFavoriteNavigation(
|
||||
item: PortalFavoriteItem
|
||||
): DashboardNavigationTarget {
|
||||
if (item.source === 'stalker') {
|
||||
return {
|
||||
link: ['/workspace', 'stalker', item.playlist_id, 'favorites'],
|
||||
state: {
|
||||
openFavoriteItem: buildStalkerStateItem(
|
||||
item.stalker_item,
|
||||
item
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return buildXtreamNavigationTarget({
|
||||
playlistId: item.playlist_id,
|
||||
type: item.type,
|
||||
categoryId: item.category_id,
|
||||
itemId: item.xtream_id,
|
||||
title: item.title,
|
||||
imageUrl: item.poster_url,
|
||||
});
|
||||
}
|
||||
|
||||
// ────── Xtream link builders ──────
|
||||
|
||||
export function buildXtreamNavigationTarget(params: {
|
||||
playlistId: string;
|
||||
type: PortalActivityType;
|
||||
categoryId: string | number;
|
||||
itemId: string | number;
|
||||
title?: string;
|
||||
imageUrl?: string;
|
||||
}): DashboardNavigationTarget {
|
||||
const link = buildXtreamItemLink(params);
|
||||
const routeType = toXtreamRouteType(params.type);
|
||||
if (routeType !== 'live') {
|
||||
return { link };
|
||||
}
|
||||
|
||||
const streamId = Number(toPathSegment(params.itemId));
|
||||
if (!Number.isFinite(streamId) || streamId <= 0) {
|
||||
return { link };
|
||||
}
|
||||
|
||||
return {
|
||||
link,
|
||||
state: {
|
||||
openXtreamLiveItemId: streamId,
|
||||
openXtreamLiveTitle: params.title || '',
|
||||
openXtreamLivePoster: params.imageUrl || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXtreamItemLink(params: {
|
||||
playlistId: string;
|
||||
type: PortalActivityType;
|
||||
categoryId: string | number;
|
||||
itemId: string | number;
|
||||
}): string[] {
|
||||
const routeType = toXtreamRouteType(params.type);
|
||||
const categoryId = toPathSegment(params.categoryId);
|
||||
const itemId = toPathSegment(params.itemId);
|
||||
|
||||
if (routeType === 'live') {
|
||||
return categoryId
|
||||
? ['/workspace', 'xtreams', params.playlistId, 'live', categoryId]
|
||||
: ['/workspace', 'xtreams', params.playlistId, 'live'];
|
||||
}
|
||||
|
||||
if (categoryId && itemId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
itemId,
|
||||
];
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
return [
|
||||
'/workspace',
|
||||
'xtreams',
|
||||
params.playlistId,
|
||||
routeType,
|
||||
categoryId,
|
||||
];
|
||||
}
|
||||
|
||||
return ['/workspace', 'xtreams', params.playlistId, routeType];
|
||||
}
|
||||
|
||||
// ────── Stalker state helpers ──────
|
||||
|
||||
export function buildStalkerStateItem(
|
||||
rawItem: StalkerPortalItem | undefined,
|
||||
fallback: {
|
||||
id: string | number;
|
||||
title: string;
|
||||
type: PortalActivityType;
|
||||
poster_url?: string;
|
||||
}
|
||||
): Record<string, unknown> {
|
||||
const normalizedCategory = toStalkerCategoryId(
|
||||
rawItem?.category_id ?? fallback.type
|
||||
);
|
||||
if (rawItem) {
|
||||
return {
|
||||
...(rawItem as Record<string, unknown>),
|
||||
category_id: normalizedCategory,
|
||||
};
|
||||
}
|
||||
|
||||
const title = fallback.title || 'Unknown';
|
||||
return {
|
||||
id: String(fallback.id ?? ''),
|
||||
title,
|
||||
name: title,
|
||||
o_name: title,
|
||||
category_id: normalizedCategory,
|
||||
cover: fallback.poster_url || '',
|
||||
logo: fallback.poster_url || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ────── Small helpers ──────
|
||||
|
||||
export function toXtreamRouteType(
|
||||
type: PortalActivityType
|
||||
): 'live' | 'vod' | 'series' {
|
||||
return type === 'movie' ? 'vod' : type;
|
||||
}
|
||||
|
||||
export function toStalkerCategoryId(value: unknown): 'vod' | 'series' | 'itv' {
|
||||
const normalized = String(value ?? '').toLowerCase();
|
||||
if (normalized === 'series') return 'series';
|
||||
if (normalized === 'itv' || normalized === 'live') return 'itv';
|
||||
return 'vod';
|
||||
}
|
||||
|
||||
function toPathSegment(value: string | number): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
Reference in new issue
Block a user