diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
index ed152714e..96501d4da 100644
--- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
+++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts
@@ -758,6 +758,13 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
icon: 'view_list',
tooltipKey: 'TOP_MENU.OPEN_MULTI_EPG',
ariaLabelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ palette: {
+ labelKey: 'TOP_MENU.OPEN_MULTI_EPG',
+ descriptionKey:
+ 'WORKSPACE.SHELL.COMMANDS.OPEN_MULTI_EPG_DESCRIPTION',
+ keywords: ['epg', 'guide', 'schedule'],
+ priority: 10,
+ },
run: () => this.openMultiEpgView(),
});
}
diff --git a/libs/playlist/shared/ui/src/index.ts b/libs/playlist/shared/ui/src/index.ts
index 0e0c7cc73..e550324e3 100644
--- a/libs/playlist/shared/ui/src/index.ts
+++ b/libs/playlist/shared/ui/src/index.ts
@@ -3,3 +3,4 @@ export * from './lib/filter-sort-menu/filter-sort-menu.component';
export * from './lib/playlist-switcher/playlist-switcher.component';
export * from './lib/recent-playlists/playlist-info/playlist-info.component';
export * from './lib/recent-playlists/recent-playlists.component';
+export * from './lib/recent-playlists/empty-state/empty-state.component';
diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html
new file mode 100644
index 000000000..3e353d250
--- /dev/null
+++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss
new file mode 100644
index 000000000..76123f01a
--- /dev/null
+++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.scss
@@ -0,0 +1,267 @@
+:host {
+ display: block;
+ --rail-gap: 16px;
+}
+
+.rail {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 12px 0 0;
+ border: 1px solid transparent;
+ border-radius: 16px;
+ transition:
+ background 0.2s ease,
+ border-color 0.2s ease,
+ box-shadow 0.2s ease;
+}
+
+// Optional active accent — used by recently-added when a section matches the
+// currently-scoped content type. Mirrors the prior `.section--active` look.
+.rail--active {
+ padding: 12px 14px 0;
+ border-color: var(--app-selection-border);
+ background: linear-gradient(
+ 145deg,
+ var(--app-selection-surface),
+ color-mix(in srgb, var(--app-selection-color) 6%, transparent)
+ );
+ box-shadow:
+ inset 0 0 0 1px var(--app-selection-border),
+ 0 14px 24px -22px var(--app-selection-glow);
+
+ .rail__label {
+ color: var(--app-selection-color);
+ }
+}
+
+// ── Header ─────────────────────────────────────────────────────────────────
+.rail__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 0 2px;
+ min-height: 24px;
+}
+
+.rail__title {
+ margin: 0;
+ display: inline-flex;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 1.125rem;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.012em;
+ color: var(--app-heading-color, #1a1d23);
+}
+
+.rail__count {
+ font-size: 0.78rem;
+ font-weight: 500;
+ line-height: 1;
+ color: var(--app-muted-color, #8891a4);
+ font-variant-numeric: tabular-nums;
+ padding: 2px 8px;
+ border-radius: 999px;
+ background: color-mix(
+ in srgb,
+ var(--app-muted-color, #8891a4) 18%,
+ transparent
+ );
+ letter-spacing: 0;
+}
+
+.rail__see-all {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding: 4px 8px 4px 10px;
+ border-radius: 999px;
+ font-size: 0.78rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: 0.005em;
+ color: var(--app-muted-color, #8891a4);
+ text-decoration: none;
+ transition:
+ color 0.15s ease,
+ background-color 0.15s ease;
+
+ mat-icon {
+ font-size: 16px;
+ width: 16px;
+ height: 16px;
+ transition: transform 0.15s ease;
+ }
+
+ &:hover {
+ color: var(--app-selection-color, #2f7bff);
+ background: color-mix(
+ in srgb,
+ var(--app-selection-color, #2f7bff) 12%,
+ transparent
+ );
+
+ mat-icon {
+ transform: translateX(2px);
+ }
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--app-selection-color, #2f7bff);
+ outline-offset: 2px;
+ }
+}
+
+// ── Viewport ───────────────────────────────────────────────────────────────
+.rail__viewport {
+ position: relative;
+ min-width: 0;
+
+ // Edge gradients hint at horizontal scroll without interaction.
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ width: 32px;
+ pointer-events: none;
+ z-index: 1;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+ }
+
+ &::before {
+ left: 0;
+ background: linear-gradient(
+ to right,
+ var(--app-content-bg, #161a22) 0%,
+ transparent 100%
+ );
+ }
+
+ &::after {
+ right: 0;
+ background: linear-gradient(
+ to left,
+ var(--app-content-bg, #161a22) 0%,
+ transparent 100%
+ );
+ }
+
+ &:has(.rail__chev--left.rail__chev--visible)::before {
+ opacity: 0.9;
+ }
+
+ &:has(.rail__chev--right.rail__chev--visible)::after {
+ opacity: 0.9;
+ }
+
+ &:hover .rail__chev--visible {
+ opacity: 1;
+ transform: translateY(-50%) scale(1);
+ }
+}
+
+.rail__track {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--rail-gap);
+ overflow-x: auto;
+ overflow-y: hidden;
+ scroll-snap-type: x mandatory;
+ scroll-padding-inline-start: 4px;
+ scrollbar-width: none;
+ padding: 4px 0 16px;
+ margin: -4px 0 0;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+ // Callers style their own projected cards (width, scroll-snap-align,
+ // flex: 0 0 auto) — projected content stays in the parent's view
+ // encapsulation and can't be reached from here without `::ng-deep`.
+}
+
+// ── Chevron — streaming-app circle (matches dashboard rail) ────────────────
+.rail__chev {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%) scale(0.96);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 44px;
+ height: 44px;
+ padding: 0;
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0.72);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ color: #fff;
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+ transition:
+ opacity 0.18s ease,
+ background-color 0.18s ease,
+ transform 0.18s ease,
+ box-shadow 0.18s ease;
+ z-index: 2;
+ box-shadow:
+ 0 4px 14px -4px rgba(0, 0, 0, 0.55),
+ 0 0 0 0 rgba(255, 255, 255, 0);
+
+ mat-icon {
+ font-size: 24px;
+ width: 24px;
+ height: 24px;
+ line-height: 24px;
+ filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
+ }
+
+ &--visible {
+ opacity: 0.5;
+ pointer-events: auto;
+ }
+
+ &:hover {
+ background: rgba(0, 0, 0, 0.88);
+ transform: translateY(-50%) scale(1.08);
+ box-shadow:
+ 0 8px 22px -6px rgba(0, 0, 0, 0.65),
+ 0 0 0 4px rgba(255, 255, 255, 0.06);
+ }
+
+ &:active {
+ transform: translateY(-50%) scale(0.96);
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--app-selection-color, #78adff);
+ outline-offset: 3px;
+ }
+
+ &--left {
+ left: 6px;
+ }
+
+ &--right {
+ right: 6px;
+ }
+}
+
+@media (hover: none) {
+ .rail__chev {
+ display: none;
+ }
+
+ .rail__viewport::before,
+ .rail__viewport::after {
+ display: none;
+ }
+}
diff --git a/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts
new file mode 100644
index 000000000..1f7593edc
--- /dev/null
+++ b/libs/portal/shared/ui/src/lib/components/content-rail-shell/content-rail-shell.component.ts
@@ -0,0 +1,95 @@
+import {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ OnDestroy,
+ effect,
+ input,
+ signal,
+ viewChild,
+} from '@angular/core';
+import { MatIcon } from '@angular/material/icon';
+import { RouterLink } from '@angular/router';
+import { TranslatePipe } from '@ngx-translate/core';
+
+/**
+ * Reusable horizontal-rail chrome: label + count badge + "see all" link,
+ * scroll-snap track with projected cards, fade-in chevrons and edge gradients
+ * that appear when scroll is possible. Matches the dashboard rail pattern so
+ * any rail-style surface in the app reads as part of the same design system.
+ *
+ * Card markup is projected — callers supply whatever card component fits the
+ * surface (simple poster tile, `app-content-card`, etc.). The shell only
+ * owns the container, header, and navigation affordances.
+ */
+@Component({
+ selector: 'app-content-rail-shell',
+ standalone: true,
+ imports: [MatIcon, RouterLink, TranslatePipe],
+ templateUrl: './content-rail-shell.component.html',
+ styleUrl: './content-rail-shell.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ContentRailShellComponent implements AfterViewInit, OnDestroy {
+ readonly label = input.required();
+ /** Subtle pill next to the label. Hidden when null or <= 0. */
+ readonly totalCount = input(null);
+ readonly seeAllLink = input(null);
+ /**
+ * Already-translated label for the see-all link. Falls back to the
+ * dashboard's "Manage all" translation when null, so dashboard callers
+ * don't need to pass anything.
+ */
+ readonly seeAllLabel = input(null);
+ /** Optional active-accent decoration (e.g. the currently-scoped section). */
+ readonly active = input(false);
+
+ private readonly track = viewChild.required>('track');
+
+ readonly canScrollLeft = signal(false);
+ readonly canScrollRight = signal(false);
+ private readonly viewReady = signal(false);
+
+ private resizeObserver?: ResizeObserver;
+
+ constructor() {
+ // Re-measure whenever the track contents change (projected cards).
+ effect(() => {
+ if (!this.viewReady()) return;
+ // Wait one frame so projected content lays out first.
+ requestAnimationFrame(() => this.updateScrollState());
+ });
+ }
+
+ ngAfterViewInit(): void {
+ this.viewReady.set(true);
+ this.updateScrollState();
+ this.resizeObserver = new ResizeObserver(() => this.updateScrollState());
+ this.resizeObserver.observe(this.track().nativeElement);
+ }
+
+ ngOnDestroy(): void {
+ this.resizeObserver?.disconnect();
+ }
+
+ onScroll(): void {
+ this.updateScrollState();
+ }
+
+ scrollBy(direction: 1 | -1): void {
+ const el = this.track().nativeElement;
+ el.scrollBy({
+ left: direction * el.clientWidth * 0.85,
+ behavior: 'smooth',
+ });
+ }
+
+ private updateScrollState(): void {
+ const el = this.track().nativeElement;
+ this.canScrollLeft.set(el.scrollLeft > 4);
+ this.canScrollRight.set(
+ el.scrollLeft + el.clientWidth < el.scrollWidth - 4
+ );
+ }
+}
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
index e2fd39d16..3b6fe8028 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts
@@ -14,11 +14,13 @@ import { DialogService } from 'components';
import {
COLLECTION_VIEW_STATE_KEY,
CollectionScope,
+ FavoritesChannelSortMode,
OPEN_COLLECTION_DETAIL_STATE_KEY,
ScopeToggleService,
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
+ WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
import { BehaviorSubject } from 'rxjs';
@@ -38,6 +40,7 @@ class StubUnifiedLiveTabComponent {
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly searchTerm = input('');
readonly autoOpenItem = input(null);
+ readonly sortMode = input('custom');
readonly removeItem = output();
readonly reorderItems = output();
@@ -130,6 +133,9 @@ describe('UnifiedCollectionPageComponent', () => {
const dialogService = {
openConfirmDialog: jest.fn(),
};
+ const workspaceViewCommands = {
+ registerCommand: jest.fn(),
+ };
const router = {
navigate: jest.fn().mockResolvedValue(true),
url: '/workspace/global-favorites',
@@ -165,6 +171,7 @@ describe('UnifiedCollectionPageComponent', () => {
playlistsLoaded.set(false);
playlists.set([]);
jest.clearAllMocks();
+ workspaceViewCommands.registerCommand.mockReturnValue(jest.fn());
routeParamMap$ = new BehaviorSubject(convertToParamMap({}));
routeQueryParamMap$ = new BehaviorSubject(convertToParamMap({}));
workspaceParamMap$ = new BehaviorSubject(convertToParamMap({}));
@@ -261,6 +268,10 @@ describe('UnifiedCollectionPageComponent', () => {
provide: DialogService,
useValue: dialogService,
},
+ {
+ provide: WorkspaceViewCommandService,
+ useValue: workspaceViewCommands,
+ },
],
})
.overrideComponent(UnifiedCollectionPageComponent, {
@@ -463,6 +474,63 @@ describe('UnifiedCollectionPageComponent', () => {
expect(favoritesData.removeFavorite).not.toHaveBeenCalled();
});
+ it('registers a workspace command when the current view has actionable items', async () => {
+ fixture.detectChanges();
+ await fixture.whenStable();
+ expect(fixture.componentInstance.isWorkspaceLayout).toBe(true);
+
+ fixture.componentInstance.allItems.set([
+ {
+ uid: 'm3u::playlist-1::one',
+ name: 'Favorite One',
+ contentType: 'live',
+ sourceType: 'm3u',
+ playlistId: 'playlist-1',
+ playlistName: 'Playlist One',
+ streamUrl: 'https://example.com/one.m3u8',
+ },
+ ]);
+ TestBed.flushEffects();
+
+ expect(workspaceViewCommands.registerCommand).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 'unified-collection-clear-current-favorites',
+ group: 'view',
+ icon: 'delete_sweep',
+ labelKey: 'WORKSPACE.SHELL.CLEAR_FAVORITES_TYPE',
+ descriptionKey:
+ 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
+ })
+ );
+ });
+
+ it('unregisters the workspace command when the current view becomes empty', async () => {
+ const unregister = jest.fn();
+ workspaceViewCommands.registerCommand.mockReturnValue(unregister);
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ fixture.componentInstance.allItems.set([
+ {
+ uid: 'm3u::playlist-1::one',
+ name: 'Favorite One',
+ contentType: 'live',
+ sourceType: 'm3u',
+ playlistId: 'playlist-1',
+ playlistName: 'Playlist One',
+ streamUrl: 'https://example.com/one.m3u8',
+ },
+ ]);
+ TestBed.flushEffects();
+
+ fixture.componentInstance.allItems.set([]);
+ TestBed.flushEffects();
+ fixture.detectChanges();
+
+ expect(unregister).toHaveBeenCalled();
+ });
+
it('reloads favorites when bulk clear persistence fails', async () => {
const liveItems = [
{
diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts
index 23d9b658e..0a38fe431 100644
--- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts
+++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts
@@ -49,6 +49,7 @@ import {
UnifiedCollectionItem,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
+ WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from 'm3u-state';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
@@ -92,6 +93,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly recentData = inject(UnifiedRecentDataService);
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
+ private readonly workspaceViewCommands = inject(WorkspaceViewCommandService);
readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
@@ -356,6 +358,39 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
});
});
});
+ private readonly workspaceCommandEffect = effect((onCleanup) => {
+ if (!this.isWorkspaceLayout) {
+ return;
+ }
+
+ const items = this.currentTypeItems();
+ if (items.length === 0) {
+ return;
+ }
+
+ const unregister = this.workspaceViewCommands.registerCommand({
+ id: `unified-collection-clear-current-${this.mode()}`,
+ group: 'view',
+ icon: 'delete_sweep',
+ labelKey: this.clearButtonTooltipKey(),
+ labelParams: () => ({
+ type: this.translate.instant(this.currentTypeLabelKey()),
+ }),
+ descriptionKey:
+ 'WORKSPACE.SHELL.COMMANDS.CLEAR_CURRENT_VIEW_DESCRIPTION',
+ descriptionParams: () => ({
+ type: this.translate.instant(this.currentTypeLabelKey()),
+ }),
+ keywords: () =>
+ this.mode() === 'favorites'
+ ? ['clear', 'favorites', 'remove', this.selectedContentType()]
+ : ['clear', 'recent', 'history', this.selectedContentType()],
+ priority: 10,
+ run: () => this.clearAllCurrent(),
+ });
+
+ onCleanup(unregister);
+ });
constructor() {
if (typeof window !== 'undefined') {
diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts
index 2f17b0643..00a61df5a 100644
--- a/libs/portal/shared/util/src/index.ts
+++ b/libs/portal/shared/util/src/index.ts
@@ -12,6 +12,8 @@ export * from './lib/portal-collection-context.service';
export * from './lib/portal-collection-items';
export * from './lib/remote-channel-navigation';
export * from './lib/workspace-header-context.service';
+export * from './lib/workspace-view-command.types';
+export * from './lib/workspace-view-command.service';
export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
index 74e439dba..b720efc5e 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts
@@ -17,6 +17,7 @@ import {
} from 'shared-interfaces';
import {
buildCollectionUid,
+ buildXtreamCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
@@ -384,7 +385,7 @@ export class UnifiedFavoritesDataService {
private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem {
const ct = xtreamContentType(row.type);
return {
- uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
+ uid: buildXtreamCollectionUid(row.playlist_id, ct, row.xtream_id),
name: row.title,
contentType: ct,
sourceType: 'xtream',
@@ -396,7 +397,9 @@ export class UnifiedFavoritesDataService {
categoryId: row.category_id,
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
rating: row.rating ?? undefined,
- addedAt: row.added_at ?? new Date(0).toISOString(),
+ addedAt:
+ normalizeStalkerDate(row.added_at) ||
+ new Date(0).toISOString(),
position: row.position ?? 0,
contentId: row.id,
};
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
index 52e6319b5..a0160b878 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts
@@ -184,4 +184,126 @@ describe('UnifiedRecentDataService', () => {
);
expect(recorded.viewedAt).toBeTruthy();
});
+
+ it('records Xtream playback with a type-aware fallback lookup', async () => {
+ dbService.getContentByXtreamId.mockResolvedValue({
+ id: 3867578,
+ xtream_id: 290,
+ type: 'live',
+ title: 'SE: V Film Premiere FHD',
+ });
+
+ const item = {
+ uid: 'xtream::xtream-1::live:290',
+ name: 'SE: V Film Premiere FHD',
+ contentType: 'live',
+ sourceType: 'xtream',
+ playlistId: 'xtream-1',
+ playlistName: 'Xtream One',
+ xtreamId: 290,
+ } satisfies UnifiedCollectionItem;
+
+ const recorded = await service.recordLivePlayback(item);
+
+ expect(dbService.getContentByXtreamId).toHaveBeenCalledWith(
+ 290,
+ 'xtream-1',
+ 'live'
+ );
+ expect(dbService.addRecentItem).toHaveBeenCalledWith(
+ 3867578,
+ 'xtream-1'
+ );
+ expect(recorded).toEqual(
+ expect.objectContaining({
+ contentId: 3867578,
+ viewedAt: expect.any(String),
+ })
+ );
+ });
+
+ it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
+ store.select.mockReturnValue(
+ of([
+ {
+ _id: 'xtream-1',
+ title: 'Xtream One',
+ serverUrl: 'https://example.com',
+ } satisfies Partial,
+ ])
+ );
+ dbService.getRecentItems.mockResolvedValue([
+ {
+ id: 3867578,
+ category_id: 11,
+ title: 'SE: V Film Premiere FHD',
+ poster_url: 'https://example.com/live.png',
+ xtream_id: 290,
+ type: 'live',
+ viewed_at: '2026-04-21T20:42:27.000Z',
+ },
+ {
+ id: 3941697,
+ category_id: 17,
+ title: 'Krypton',
+ poster_url: 'https://example.com/krypton.png',
+ xtream_id: 290,
+ type: 'series',
+ viewed_at: '2026-04-21T20:43:27.000Z',
+ },
+ ]);
+
+ const items = await service.getRecentItems('playlist', 'xtream-1', 'xtream');
+
+ expect(items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ uid: 'xtream::xtream-1::live:290',
+ contentType: 'live',
+ contentId: 3867578,
+ }),
+ expect.objectContaining({
+ uid: 'xtream::xtream-1::series:290',
+ contentType: 'series',
+ contentId: 3941697,
+ }),
+ ])
+ );
+ });
+
+ it('normalizes SQLite-style Xtream recent timestamps to ISO before exposing them', async () => {
+ store.select.mockReturnValue(
+ of([
+ playlistMeta,
+ {
+ _id: 'xtream-1',
+ title: 'Xtream One',
+ serverUrl: 'https://example.com',
+ } satisfies Partial,
+ ])
+ );
+ dbService.getGlobalRecentlyViewed.mockResolvedValue([
+ {
+ id: 3940227,
+ category_id: 18,
+ title: 'Unter Nachbarn - 2011',
+ poster_url: 'https://example.com/unter.png',
+ xtream_id: 815302,
+ type: 'movie',
+ playlist_id: 'xtream-1',
+ playlist_name: 'Xtream One',
+ viewed_at: '2026-04-21 22:49:02',
+ },
+ ]);
+
+ const items = await service.getRecentItems('all');
+ const xtreamItem = items.find((item) => item.sourceType === 'xtream');
+
+ expect(xtreamItem).toEqual(
+ expect.objectContaining({
+ name: 'Unter Nachbarn - 2011',
+ viewedAt: '2026-04-21T22:49:02.000Z',
+ })
+ );
+ });
});
diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
index 47d190158..b8bac3b17 100644
--- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
+++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts
@@ -14,15 +14,16 @@ import {
extractStalkerItemType,
isM3uRecentlyViewedItem,
M3uRecentlyViewedItem,
+ normalizeStalkerDate,
Playlist,
PlaylistMeta,
PlaylistRecentlyViewedItem,
PlaylistUpdateState,
StalkerPortalItem,
- normalizeStalkerDate,
} from 'shared-interfaces';
import {
buildCollectionUid,
+ buildXtreamCollectionUid,
UnifiedCollectionItem,
} from './unified-collection-item.interface';
import { CollectionScope } from './scope-toggle.service';
@@ -155,7 +156,8 @@ export class UnifiedRecentDataService {
? (
await this.dbService.getContentByXtreamId(
item.xtreamId,
- item.playlistId
+ item.playlistId,
+ item.contentType
)
)?.id
: null);
@@ -240,7 +242,11 @@ export class UnifiedRecentDataService {
try {
const rows = await this.dbService.getGlobalRecentlyViewed();
return (rows || []).map((row) => ({
- uid: buildCollectionUid('xtream', row.playlist_id, row.xtream_id),
+ uid: buildXtreamCollectionUid(
+ row.playlist_id,
+ xtreamContentType(row.type),
+ row.xtream_id
+ ),
name: row.title,
contentType: xtreamContentType(row.type),
sourceType: 'xtream' as const,
@@ -252,7 +258,7 @@ export class UnifiedRecentDataService {
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
contentId: row.id,
- viewedAt: row.viewed_at,
+ viewedAt: normalizeStalkerDate(row.viewed_at),
}));
} catch {
return [];
@@ -267,7 +273,11 @@ export class UnifiedRecentDataService {
const meta = await this.getPlaylistMeta(playlistId);
return (rows || []).map((row) => ({
- uid: buildCollectionUid('xtream', playlistId, row.xtream_id),
+ uid: buildXtreamCollectionUid(
+ playlistId,
+ xtreamContentType(row.type),
+ row.xtream_id
+ ),
name: row.title,
contentType: xtreamContentType(row.type),
sourceType: 'xtream' as const,
@@ -279,7 +289,7 @@ export class UnifiedRecentDataService {
categoryId: row.category_id,
tvgId: row.type === 'live' ? String(row.xtream_id) : undefined,
contentId: row.id,
- viewedAt: row.viewed_at ?? '',
+ viewedAt: normalizeStalkerDate(row.viewed_at),
}));
} catch {
return [];
diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts
index 041363e91..87e1d1f4d 100644
--- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts
+++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts
@@ -17,14 +17,11 @@ describe('buildPortalRailLinks', () => {
expect(links.secondary.map((link) => link.section)).toEqual([
'recently-added',
'search',
- 'recent',
- 'favorites',
'downloads',
]);
expect(links.primary[0]?.tooltip).toBe('Movies (this playlist)');
- expect(links.secondary[3]?.tooltip).toBe('Favorites (this playlist)');
- expect(links.secondary[4]?.tooltip).toBe('Downloads (this playlist)');
+ expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)');
});
it('builds workspace Xtream links without downloads on web', () => {
@@ -60,15 +57,10 @@ describe('buildPortalRailLinks', () => {
'itv',
'series',
]);
- expect(links.secondary.map((link) => link.section)).toEqual([
- 'search',
- 'recent',
- 'favorites',
- ]);
+ expect(links.secondary.map((link) => link.section)).toEqual(['search']);
expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)');
expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)');
- expect(links.secondary[2]?.tooltip).toBe('Favorites (this playlist)');
});
it('builds M3U playlist links with scoped tooltip labels', () => {
@@ -94,20 +86,6 @@ describe('buildPortalRailLinks', () => {
exact: true,
section: 'groups',
},
- {
- icon: 'history',
- tooltip: 'Recently viewed (this playlist)',
- path: ['/workspace', 'playlists', 'm3u-1', 'recent'],
- exact: true,
- section: 'recent',
- },
- {
- icon: 'favorite',
- tooltip: 'Favorites (this playlist)',
- path: ['/workspace', 'playlists', 'm3u-1', 'favorites'],
- exact: true,
- section: 'favorites',
- },
]);
expect(links.secondary).toEqual([]);
});
diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts
index 038cc79bc..16085a48e 100644
--- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts
+++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts
@@ -89,18 +89,6 @@ export function buildPortalRailLinks(
tooltip: 'Search (this playlist)',
path: [...root, 'search'],
section: 'search',
- },
- {
- icon: 'history',
- tooltip: 'Recently viewed (this playlist)',
- path: [...root, 'recent'],
- section: 'recent',
- },
- {
- icon: 'favorite',
- tooltip: 'Favorites (this playlist)',
- path: [...root, 'favorites'],
- section: 'favorites',
}
);
@@ -145,18 +133,6 @@ export function buildPortalRailLinks(
path: [...root, 'search'],
section: 'search',
},
- {
- icon: 'history',
- tooltip: 'Recently viewed (this playlist)',
- path: [...root, 'recent'],
- section: 'recent',
- },
- {
- icon: 'favorite',
- tooltip: 'Favorites (this playlist)',
- path: [...root, 'favorites'],
- section: 'favorites',
- },
];
if (isElectron) {
@@ -187,20 +163,6 @@ export function buildPortalRailLinks(
exact: true,
section: 'groups',
},
- {
- icon: 'history',
- tooltip: 'Recently viewed (this playlist)',
- path: [...root, 'recent'],
- exact: true,
- section: 'recent',
- },
- {
- icon: 'favorite',
- tooltip: 'Favorites (this playlist)',
- path: [...root, 'favorites'],
- exact: true,
- section: 'favorites',
- },
];
return {
diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts
index 5b7174a91..5d11e0c2e 100644
--- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts
+++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts
@@ -7,6 +7,7 @@ import {
} from 'shared-interfaces';
import {
buildCollectionUid,
+ buildXtreamCollectionUid,
CollectionContentType,
CollectionSourceType,
UnifiedCollectionItem,
@@ -550,7 +551,11 @@ function buildDashboardCollectionDetailItem(
if (item.source === 'xtream') {
return normalizeCollectionDetailItem({
- uid: buildCollectionUid('xtream', item.playlist_id, item.xtream_id),
+ uid: buildXtreamCollectionUid(
+ item.playlist_id,
+ item.type,
+ item.xtream_id
+ ),
name: item.title,
contentType: item.type,
sourceType: 'xtream',
diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts
index 83aae53f4..a236c5e28 100644
--- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts
+++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts
@@ -376,8 +376,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getFavorites(playlistId);
}
- async addFavorite(contentId: number, playlistId: string): Promise {
- await this.dbService.addToFavorites(contentId, playlistId);
+ async addFavorite(
+ contentId: number,
+ playlistId: string,
+ backdropUrl?: string
+ ): Promise {
+ await this.dbService.addToFavorites(contentId, playlistId, backdropUrl);
}
async removeFavorite(contentId: number, playlistId: string): Promise {
@@ -396,8 +400,12 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
return this.dbService.getRecentItems(playlistId);
}
- async addRecentItem(contentId: number, playlistId: string): Promise {
- await this.dbService.addRecentItem(contentId, playlistId);
+ async addRecentItem(
+ contentId: number,
+ playlistId: string,
+ backdropUrl?: string
+ ): Promise {
+ await this.dbService.addRecentItem(contentId, playlistId, backdropUrl);
}
async removeRecentItem(
diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts
index 619e90f28..e1628cab5 100644
--- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts
+++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts
@@ -318,7 +318,13 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return results as XtreamContentItem[];
}
- async addFavorite(contentId: number, playlistId: string): Promise {
+ async addFavorite(
+ contentId: number,
+ playlistId: string,
+ // PWA uses localStorage with no content table, so backdrop persistence
+ // is electron-only. Accept the param for interface parity.
+ _backdropUrl?: string
+ ): Promise {
const allFavorites = this.getFavoritesFromStorage();
if (!allFavorites[playlistId]) {
allFavorites[playlistId] = [];
@@ -530,7 +536,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return results as XtreamContentItem[];
}
- async addRecentItem(contentId: number, playlistId: string): Promise {
+ async addRecentItem(
+ contentId: number,
+ playlistId: string,
+ _backdropUrl?: string
+ ): Promise {
const allRecent = this.getRecentItemsFromStorage();
if (!allRecent[playlistId]) {
allRecent[playlistId] = [];
diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts
index 276d1efc2..de9b5e181 100644
--- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts
+++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts
@@ -277,9 +277,16 @@ export interface IXtreamDataSource {
getFavorites(playlistId: string): Promise;
/**
- * Add content to favorites
+ * Add content to favorites.
+ * @param backdropUrl optionally persisted to `content.backdrop_url` when
+ * the row doesn't already have one. Enables the dashboard hero to surface
+ * a cinematic backdrop without a separate round-trip.
*/
- addFavorite(contentId: number, playlistId: string): Promise;
+ addFavorite(
+ contentId: number,
+ playlistId: string,
+ backdropUrl?: string
+ ): Promise;
/**
* Remove content from favorites
@@ -301,9 +308,13 @@ export interface IXtreamDataSource {
getRecentItems(playlistId: string): Promise;
/**
- * Add item to recently viewed
+ * Add item to recently viewed. See `addFavorite` for `backdropUrl`.
*/
- addRecentItem(contentId: number, playlistId: string): Promise;
+ addRecentItem(
+ contentId: number,
+ playlistId: string,
+ backdropUrl?: string
+ ): Promise;
/**
* Remove item from recently viewed
diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts
index f4ec45697..b3ad6b290 100644
--- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts
+++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts
@@ -12,8 +12,13 @@ export class FavoritesService {
async addToFavorites(item: {
content_id: number;
playlist_id: string;
+ backdrop_url?: string;
}): Promise {
- await this.dbService.addToFavorites(item.content_id, item.playlist_id);
+ await this.dbService.addToFavorites(
+ item.content_id,
+ item.playlist_id,
+ item.backdrop_url
+ );
}
async removeFromFavorites(
diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts
index 17240c5ec..67a395879 100644
--- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts
+++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.spec.ts
@@ -228,6 +228,9 @@ describe('withContent import state', () => {
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
expect(store.isImporting()).toBe(true);
+ expect(store.activeImportContentType()).toBe('live');
+ expect(store.activeImportCurrentCount()).toBe(1);
+ expect(store.activeImportTotalCount()).toBe(2);
expect(store.importCount()).toBe(1);
expect(store.itemsToImport()).toBe(2);
expect(store.activeImportOperationIds()).toEqual([liveOperationId]);
@@ -246,6 +249,9 @@ describe('withContent import state', () => {
await waitForCondition(() => store.importCount() === 2);
const movieOperationId =
optionsByType.get('movie')?.operationId ?? 'movie-op';
+ expect(store.activeImportContentType()).toBe('vod');
+ expect(store.activeImportCurrentCount()).toBe(1);
+ expect(store.activeImportTotalCount()).toBe(3);
expect(store.itemsToImport()).toBe(5);
expect(store.activeImportOperationIds()).toEqual([movieOperationId]);
@@ -262,6 +268,9 @@ describe('withContent import state', () => {
await waitForCondition(() => store.importCount() === 3);
const seriesOperationId =
optionsByType.get('series')?.operationId ?? 'series-op';
+ expect(store.activeImportContentType()).toBe('series');
+ expect(store.activeImportCurrentCount()).toBe(1);
+ expect(store.activeImportTotalCount()).toBe(4);
expect(store.itemsToImport()).toBe(9);
expect(store.activeImportOperationIds()).toEqual([seriesOperationId]);
@@ -287,6 +296,9 @@ describe('withContent import state', () => {
});
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
+ expect(store.activeImportContentType()).toBeNull();
+ expect(store.activeImportCurrentCount()).toBe(0);
+ expect(store.activeImportTotalCount()).toBe(0);
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
});
@@ -715,6 +727,9 @@ describe('withContent import state', () => {
);
const liveOperationId =
optionsByType.get('live')?.operationId ?? 'live-op';
+ expect(store.activeImportContentType()).toBe('live');
+ expect(store.activeImportCurrentCount()).toBe(2);
+ expect(store.activeImportTotalCount()).toBe(5);
await store.cancelImport();
@@ -748,6 +763,9 @@ describe('withContent import state', () => {
});
expect(store.activeImportOperationIds()).toEqual([]);
expect(store.importPhase()).toBeNull();
+ expect(store.activeImportContentType()).toBeNull();
+ expect(store.activeImportCurrentCount()).toBe(0);
+ expect(store.activeImportTotalCount()).toBe(0);
expect(store.importCount()).toBe(0);
expect(store.itemsToImport()).toBe(0);
});
diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts
index 087c90da4..dceaca409 100644
--- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts
+++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts
@@ -93,6 +93,9 @@ export interface ContentState {
importCount: number;
importPhase: string | null;
itemsToImport: number;
+ activeImportContentType: ContentType | null;
+ activeImportCurrentCount: number;
+ activeImportTotalCount: number;
activeImportSessionId: string | null;
activeImportOperationIds: string[];
isContentInitialized: boolean;
@@ -123,6 +126,9 @@ const initialContentState: ContentState = {
importCount: 0,
importPhase: null,
itemsToImport: 0,
+ activeImportContentType: null,
+ activeImportCurrentCount: 0,
+ activeImportTotalCount: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
isContentInitialized: false,
@@ -221,6 +227,18 @@ export function withContent() {
}));
};
+ const setActiveImportProgress = (
+ type: ContentType | null,
+ current = 0,
+ total = 0
+ ): void => {
+ patchState(store, {
+ activeImportContentType: type,
+ activeImportCurrentCount: current,
+ activeImportTotalCount: total,
+ });
+ };
+
const resolveInitBlockReason = (
portalStatus: PortalStatusType | null | undefined
): XtreamContentInitBlockReason | null => {
@@ -354,13 +372,25 @@ export function withContent() {
: state.activeImportOperationIds.includes(
operationId
)
- ? state.activeImportOperationIds
+ ? state.activeImportOperationIds
: [
...state.activeImportOperationIds,
operationId,
],
isCancellingImport: state.isCancellingImport,
}));
+
+ if (
+ event.operation === 'save-content' &&
+ store.activeImportContentType()
+ ) {
+ patchState(store, (state) => ({
+ activeImportCurrentCount:
+ event.current ?? state.activeImportCurrentCount,
+ activeImportTotalCount:
+ event.total ?? state.activeImportTotalCount,
+ }));
+ }
};
const registerImportOperation = (operationId: string): void => {
@@ -473,6 +503,9 @@ export function withContent() {
importCount: 0,
importPhase: null,
itemsToImport: 0,
+ activeImportContentType: null,
+ activeImportCurrentCount: 0,
+ activeImportTotalCount: 0,
activeImportSessionId: importSessionId,
activeImportOperationIds: [],
contentLoadStateByType: {
@@ -574,6 +607,9 @@ export function withContent() {
importCount: 0,
importPhase: null,
itemsToImport: 0,
+ activeImportContentType: null,
+ activeImportCurrentCount: 0,
+ activeImportTotalCount: 0,
activeImportSessionId: null,
activeImportOperationIds: [],
});
@@ -700,16 +736,24 @@ export function withContent() {
const onTotal = (count: number) => {
totalItems += count;
- patchState(store, { itemsToImport: totalItems });
+ patchState(store, {
+ itemsToImport: totalItems,
+ activeImportTotalCount: count,
+ });
};
const onProgress = (count: number) => {
importedItems += count;
- patchState(store, { importCount: importedItems });
+ patchState(store, (state) => ({
+ importCount: importedItems,
+ activeImportCurrentCount:
+ state.activeImportCurrentCount + count,
+ }));
};
try {
throwIfImportCancelled(options?.importSessionId);
+ setActiveImportProgress('live');
const liveOperationId =
databaseService.createOperationId(
'db-save-content'
@@ -746,6 +790,7 @@ export function withContent() {
updateContentTypeLoadState('live', 'ready');
throwIfImportCancelled(options?.importSessionId);
+ setActiveImportProgress('vod');
const vodOperationId =
databaseService.createOperationId(
'db-save-content'
@@ -781,6 +826,7 @@ export function withContent() {
updateContentTypeLoadState('vod', 'ready');
throwIfImportCancelled(options?.importSessionId);
+ setActiveImportProgress('series');
const seriesOperationId =
databaseService.createOperationId(
'db-save-content'
@@ -880,6 +926,9 @@ export function withContent() {
patchState(store, {
isCancellingImport: true,
contentInitBlockReason: 'cancelled',
+ activeImportContentType: null,
+ activeImportCurrentCount: 0,
+ activeImportTotalCount: 0,
activeImportSessionId: null,
});
const ctx = getCredentialsFromStore();
diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts
index 6c24e0bb0..324126864 100644
--- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts
+++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts
@@ -24,7 +24,8 @@ export const withFavorites = function () {
async toggleFavorite(
xtreamId: number,
playlistId: string,
- contentType: 'live' | 'movie' | 'series'
+ contentType: 'live' | 'movie' | 'series',
+ backdropUrl?: string
) {
if (!xtreamId || !playlistId) {
return false;
@@ -58,6 +59,7 @@ export const withFavorites = function () {
await favoritesService.addToFavorites({
content_id: content.id,
playlist_id: playlistId,
+ backdrop_url: backdropUrl,
});
patchState(store, { isFavorite: true });
return true;
diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts
new file mode 100644
index 000000000..950f41e2a
--- /dev/null
+++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts
@@ -0,0 +1,161 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { signalStore } from '@ngrx/signals';
+import { of } from 'rxjs';
+import { DatabaseService, PlaylistsService } from 'services';
+import { withRecentItems } from './with-recent-items';
+
+jest.mock('@iptvnator/portal/shared/util', () => ({
+ createLogger: () => ({
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ }),
+}));
+
+const TestRecentItemsStore = signalStore(withRecentItems());
+
+describe('withRecentItems', () => {
+ let store: InstanceType;
+ let databaseService: {
+ addRecentItem: jest.Mock;
+ getContentByXtreamId: jest.Mock;
+ getRecentItems: jest.Mock;
+ setContentBackdropIfMissing: jest.Mock;
+ };
+
+ beforeEach(() => {
+ Object.defineProperty(window, 'electron', {
+ value: {} as Window['electron'],
+ configurable: true,
+ });
+
+ databaseService = {
+ addRecentItem: jest.fn().mockResolvedValue(undefined),
+ getContentByXtreamId: jest.fn(),
+ getRecentItems: jest.fn().mockResolvedValue([
+ {
+ id: 3941697,
+ title: 'Krypton',
+ type: 'series',
+ poster_url: 'https://example.com/krypton.png',
+ backdrop_url: 'https://example.com/krypton-backdrop.png',
+ viewed_at: '2026-04-21T20:42:27.000Z',
+ xtream_id: 290,
+ category_id: 17,
+ },
+ ]),
+ setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
+ };
+
+ TestBed.configureTestingModule({
+ providers: [
+ TestRecentItemsStore,
+ {
+ provide: DatabaseService,
+ useValue: databaseService,
+ },
+ {
+ provide: PlaylistsService,
+ useValue: {
+ clearPlaylistRecentlyViewed: jest.fn().mockReturnValue(
+ of(undefined)
+ ),
+ getAllPlaylists: jest.fn().mockReturnValue(of([])),
+ },
+ },
+ ],
+ });
+
+ store = TestBed.inject(TestRecentItemsStore);
+ });
+
+ it('looks recent items up with the requested content type before saving one', async () => {
+ databaseService.getContentByXtreamId.mockResolvedValue({
+ id: 3941697,
+ title: 'Krypton',
+ type: 'series',
+ xtream_id: 290,
+ });
+
+ store.addRecentItem({
+ xtreamId: 290,
+ contentType: 'series',
+ playlist: signal({ id: 'playlist-1' }),
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
+ 290,
+ 'playlist-1',
+ 'series'
+ );
+ expect(databaseService.addRecentItem).toHaveBeenCalledWith(
+ 3941697,
+ 'playlist-1',
+ undefined
+ );
+ expect(store.recentItems()).toEqual([
+ expect.objectContaining({
+ id: 3941697,
+ title: 'Krypton',
+ type: 'series',
+ xtream_id: 290,
+ backdrop_url: 'https://example.com/krypton-backdrop.png',
+ }),
+ ]);
+ });
+
+ it('forwards backdrop urls on recent-item saves', async () => {
+ databaseService.getContentByXtreamId.mockResolvedValue({
+ id: 3941697,
+ title: 'Krypton',
+ type: 'series',
+ xtream_id: 290,
+ });
+
+ store.addRecentItem({
+ xtreamId: 290,
+ contentType: 'series',
+ playlist: signal({ id: 'playlist-1' }),
+ backdropUrl: 'https://example.com/krypton-backdrop.png',
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(databaseService.addRecentItem).toHaveBeenCalledWith(
+ 3941697,
+ 'playlist-1',
+ 'https://example.com/krypton-backdrop.png'
+ );
+ });
+
+ it('backfills a backdrop without rewriting recent ordering', async () => {
+ databaseService.getContentByXtreamId.mockResolvedValue({
+ id: 3941697,
+ title: 'Krypton',
+ type: 'series',
+ xtream_id: 290,
+ });
+
+ await store.backfillContentBackdrop({
+ xtreamId: 290,
+ contentType: 'series',
+ playlist: signal({ id: 'playlist-1' }),
+ backdropUrl: ' https://example.com/krypton-backdrop.png ',
+ });
+
+ expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith(
+ 290,
+ 'playlist-1',
+ 'series'
+ );
+ expect(
+ databaseService.setContentBackdropIfMissing
+ ).toHaveBeenCalledWith(
+ 3941697,
+ 'https://example.com/krypton-backdrop.png'
+ );
+ expect(databaseService.addRecentItem).not.toHaveBeenCalled();
+ });
+});
diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts
index 1065afc69..32e1f6046 100644
--- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts
+++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts
@@ -26,6 +26,7 @@ function mapDbRecentItem(
title: string;
type: string;
poster_url: string;
+ backdrop_url?: string | null;
viewed_at?: string;
xtream_id: number;
category_id: number;
@@ -37,6 +38,7 @@ function mapDbRecentItem(
title: item.title,
type: item.type as 'live' | 'movie' | 'series',
poster_url: item.poster_url,
+ backdrop_url: item.backdrop_url ?? undefined,
content_id: item.id,
playlist_id: playlistId,
viewed_at: item.viewed_at || '',
@@ -75,22 +77,30 @@ export const withRecentItems = function () {
playlistsService = inject(PlaylistsService)
) => ({
addRecentItem: rxMethod<{
- contentId: number;
+ xtreamId: number;
+ contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string }>;
+ backdropUrl?: string;
}>(
pipe(
- switchMap(async ({ contentId, playlist }) => {
- // contentId is actually xtream_id, need to look up the database content.id
+ switchMap(
+ async ({
+ xtreamId,
+ contentType,
+ playlist,
+ backdropUrl,
+ }) => {
const playlistId = playlist().id;
- const content =
- await dbService.getContentByXtreamId(
- contentId,
- playlistId
- );
+ const content = await dbService.getContentByXtreamId(
+ xtreamId,
+ playlistId,
+ contentType
+ );
if (content) {
await dbService.addRecentItem(
content.id,
- playlistId
+ playlistId,
+ backdropUrl
);
// Reload after add/update so re-watched items
@@ -106,6 +116,41 @@ export const withRecentItems = function () {
})
)
),
+ async backfillContentBackdrop({
+ xtreamId,
+ contentType,
+ playlist,
+ backdropUrl,
+ }: {
+ xtreamId: number;
+ contentType: 'live' | 'movie' | 'series';
+ playlist: Signal<{ id: string }>;
+ backdropUrl?: string;
+ }): Promise {
+ if (!window.electron) {
+ return;
+ }
+
+ const playlistId = playlist().id;
+ const normalizedBackdropUrl = backdropUrl?.trim();
+ if (!playlistId || !normalizedBackdropUrl) {
+ return;
+ }
+
+ const content = await dbService.getContentByXtreamId(
+ xtreamId,
+ playlistId,
+ contentType
+ );
+ if (!content) {
+ return;
+ }
+
+ await dbService.setContentBackdropIfMissing(
+ content.id,
+ normalizedBackdropUrl
+ );
+ },
clearRecentItems: rxMethod<{ id: string }>(
pipe(
switchMap(async (playlist) => {
@@ -160,6 +205,7 @@ export const withRecentItems = function () {
title: item.title,
type: item.type as 'live' | 'movie' | 'series',
poster_url: item.poster_url,
+ backdrop_url: item.backdrop_url ?? undefined,
content_id: item.id,
playlist_id: item.playlist_id,
playlist_name: item.playlist_name,
diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
index 8622cf107..89ca17a05 100644
--- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
+++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts
@@ -84,6 +84,7 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
readonly xtreamDownloadContext =
signal(null);
private readonly detailsInitDone = signal(false);
+ private readonly backdropBackfillKey = signal(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal(null);
@@ -131,6 +132,30 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.detailsInitDone.set(true);
});
+ effect(() => {
+ const playlistId = this.currentPlaylistId();
+ const selectedItem = this.selectedItem();
+ const xtreamId = Number(selectedItem?.series_id ?? 0);
+ const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim();
+
+ if (!playlistId || !Number.isFinite(xtreamId) || xtreamId <= 0 || !backdropUrl) {
+ return;
+ }
+
+ const backfillKey = `${playlistId}:${xtreamId}:${backdropUrl}`;
+ if (this.backdropBackfillKey() === backfillKey) {
+ return;
+ }
+
+ this.backdropBackfillKey.set(backfillKey);
+ void this.xtreamStore.backfillContentBackdrop({
+ xtreamId,
+ contentType: 'series',
+ playlist: this.xtreamStore.currentPlaylist,
+ backdropUrl,
+ });
+ });
+
effect(() => {
const session = this.externalPlayback.activeSession();
const selectedItem = this.selectedItem();
@@ -236,7 +261,8 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.serialId,
this.xtreamStore.currentPlaylist().id,
- 'series'
+ 'series',
+ this.selectedItem()?.info?.backdrop_path?.[0]
);
}
@@ -285,8 +311,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
private addToRecentlyViewed(xtreamId: number): void {
this.xtreamStore.addRecentItem({
- contentId: xtreamId,
+ xtreamId,
+ contentType: 'series',
playlist: this.xtreamStore.currentPlaylist,
+ backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0],
});
}
diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
index 1bac200fd..3b2e97f16 100644
--- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
+++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts
@@ -66,6 +66,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly translateService = inject(TranslateService);
private readonly logger = createLogger('VodDetailsRoute');
private readonly detailsInitDone = signal(false);
+ private readonly backdropBackfillKey = signal(null);
readonly inlinePlayback = signal(null);
readonly vodPlaybackPosition = signal(null);
@@ -269,6 +270,29 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.detailsInitDone.set(true);
});
+ effect(() => {
+ const playlistId = this.xtreamStore.currentPlaylist()?.id;
+ const vodId = Number(this.route.snapshot.params.vodId);
+ const backdropUrl = this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
+
+ if (!playlistId || !Number.isFinite(vodId) || vodId <= 0 || !backdropUrl) {
+ return;
+ }
+
+ const backfillKey = `${playlistId}:${vodId}:${backdropUrl}`;
+ if (this.backdropBackfillKey() === backfillKey) {
+ return;
+ }
+
+ this.backdropBackfillKey.set(backfillKey);
+ void this.xtreamStore.backfillContentBackdrop({
+ xtreamId: vodId,
+ contentType: 'movie',
+ playlist: this.xtreamStore.currentPlaylist,
+ backdropUrl,
+ });
+ });
+
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates =
window.electron.onPlaybackPositionUpdate(
@@ -392,7 +416,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.xtreamStore.toggleFavorite(
this.route.snapshot.params.vodId,
this.xtreamStore.currentPlaylist().id,
- 'movie'
+ 'movie',
+ this.selectedVodInfo()?.backdrop_path?.[0]
);
}
@@ -485,8 +510,10 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private addToRecentlyViewed(): void {
this.xtreamStore.addRecentItem({
- contentId: this.route.snapshot.params.vodId,
+ xtreamId: Number(this.route.snapshot.params.vodId),
+ contentType: 'movie',
playlist: this.xtreamStore.currentPlaylist,
+ backdropUrl: this.selectedVodInfo()?.backdrop_path?.[0],
});
}
diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts
index a0f27b67a..548fdad18 100644
--- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts
+++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts
@@ -16,6 +16,13 @@ export interface PortalActivityItem {
category_id: string | number;
xtream_id: string | number;
poster_url?: string;
+ /**
+ * Wide backdrop URL (typically 16:9), persisted when the user first views
+ * the detail page. Absent for stalker and for items that have never had
+ * their detail page opened. Dashboards should fall back to a blurred
+ * `poster_url` when undefined.
+ */
+ backdrop_url?: string;
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;