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:
4gray committed 2026-03-08 15:48:37 +01:00
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();
@@ -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()"
@@ -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
);
}
},
})
)
);
};
+3 -1
View File
@@ -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 [];
}
);
+5 -2
View File
@@ -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();
}