diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html
index bd1762abe..2a5e384bb 100644
--- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html
+++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.html
@@ -33,6 +33,16 @@
')'
}}
+ @if (showHeaderAction()) {
+
+ }
@if (favorites().length) {
@@ -44,7 +54,10 @@
[type]="item.type"
[date]="item.added_at"
[showRemoveButton]="true"
- [removeTooltip]="'CHANNELS.REMOVE_FAVORITE' | translate"
+ [removeTooltip]="
+ removeTooltip() ||
+ ('CHANNELS.REMOVE_FAVORITE' | translate)
+ "
(cardClick)="openFavorite(item)"
(remove)="removeFromFavorites(item)"
/>
@@ -52,7 +65,7 @@
} @else {
-
favorite_border
+
{{ emptyIcon() }}
No items in the selected category
}
diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss
index c9fc7c64a..6fccda148 100644
--- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss
+++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.scss
@@ -20,6 +20,10 @@
text-transform: capitalize;
padding: 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
h2 {
margin: 0;
diff --git a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts
index 7e164f09f..c3446f34b 100644
--- a/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts
+++ b/apps/web/src/app/shared/components/favorites-layout/favorites-layout.component.ts
@@ -1,6 +1,7 @@
import { Component, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
+import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
import { CategoryViewComponent } from '../../../xtream-tauri/category-view/category-view.component';
@@ -21,6 +22,7 @@ import { ContentCardComponent } from '../content-card/content-card.component';
PlaylistSwitcherComponent,
ResizableDirective,
TranslatePipe,
+ MatTooltip,
],
})
export class FavoritesLayoutComponent {
@@ -30,10 +32,16 @@ export class FavoritesLayoutComponent {
readonly playlistTitle = input('Playlist');
readonly selectedCategoryId = input('movie');
readonly titleTranslationString = input('CHANNELS.FAVORITES');
+ readonly removeTooltip = input('');
+ readonly emptyIcon = input('favorite_border');
+ readonly showHeaderAction = input(false);
+ readonly headerActionIcon = input('delete_sweep');
+ readonly headerActionTooltip = input('');
readonly categoryClicked = output();
readonly removeFavorite = output();
readonly openItem = output();
+ readonly headerActionClicked = output();
setCategoryId(categoryId: any) {
this.categoryClicked.emit({ category_id: categoryId });
@@ -46,4 +54,8 @@ export class FavoritesLayoutComponent {
openFavorite(item: any) {
this.openItem.emit(item);
}
+
+ onHeaderActionClick() {
+ this.headerActionClicked.emit();
+ }
}
diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html
index 59f146302..8e7012bfd 100644
--- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html
+++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.html
@@ -16,6 +16,7 @@
}
diff --git a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts
index 51965e356..1221a1a6e 100644
--- a/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts
+++ b/apps/web/src/app/stalker/recently-viewed/recently-viewed.component.ts
@@ -1,4 +1,4 @@
-import { Component, computed, inject, signal } from '@angular/core';
+import { Component, computed, effect, inject, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistsService } from 'services';
import {
@@ -15,12 +15,13 @@ import {
} from '../models';
import {
createPortalCollectionResource,
- normalizeStalkerEntityIdAsNumber,
+ createPortalFavoritesResource,
toggleStalkerVodFavorite,
clearStalkerDetailViewState,
createStalkerDetailViewState,
createRefreshTrigger,
normalizeStalkerVodDetailsItem,
+ isSelectedStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
@@ -54,12 +55,14 @@ export class RecentlyViewedComponent {
}
private readonly playlistService = inject(PlaylistsService);
private readonly collectionRefresh = createRefreshTrigger();
+ private readonly favoritesRefresh = createRefreshTrigger();
private readonly stalkerStore = inject(StalkerStore);
private readonly translate = inject(TranslateService);
private readonly logger = createLogger('StalkerRecentlyViewed');
itemDetails: (StalkerVodSource & { category_id?: string }) | null = null;
vodDetailsItem: VodDetailsItem | null = null;
+ readonly isSelectedVodFavorite = signal(false);
readonly currentPlaylist = this.stalkerStore.currentPlaylist;
@@ -70,6 +73,11 @@ export class RecentlyViewedComponent {
(playlistService, portalId) =>
playlistService.getPortalRecentlyViewed(portalId)
);
+ readonly portalFavorites = createPortalFavoritesResource(
+ this.playlistService,
+ () => this.stalkerStore.currentPlaylist()?._id,
+ () => this.favoritesRefresh.refreshVersion()
+ );
readonly categories = computed(() => [
{
@@ -131,14 +139,34 @@ export class RecentlyViewedComponent {
this.allFavorites.value()?.filter((item) => item.category_id === 'itv')
);
+ constructor() {
+ effect(() => {
+ this.portalFavorites.value();
+ this.syncSelectedVodFavorite();
+ });
+ }
+
removeFromRecentlyViewed(item: Pick) {
- const id = normalizeStalkerEntityIdAsNumber(item.id);
- if (id === null) return;
+ const id = String(item.id ?? '').trim();
+ if (!id) return;
this.stalkerStore.removeFromRecentlyViewed(id, () => {
this.collectionRefresh.refresh();
});
}
+ clearAllRecentlyViewed() {
+ const playlistId = this.stalkerStore.currentPlaylist()?._id;
+ if (!playlistId) return;
+ this.playlistService.clearPortalRecentlyViewed(playlistId).subscribe({
+ next: () => this.collectionRefresh.refresh(),
+ error: (error) =>
+ this.logger.error(
+ 'Failed to clear recently viewed items',
+ error
+ ),
+ });
+ }
+
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
@@ -166,6 +194,7 @@ export class RecentlyViewedComponent {
};
this.vodDetailsItem = detailViewState.vodDetailsItem;
this.stalkerStore.setSelectedItem(detailViewState.itemDetails);
+ this.syncSelectedVodFavorite();
break;
case 'series':
this.itemDetails = item;
@@ -193,6 +222,10 @@ export class RecentlyViewedComponent {
addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
removeFromFavorites: (favoriteId, onDone) =>
this.removeFromFavorites(favoriteId, onDone),
+ onComplete: () => {
+ this.favoritesRefresh.refresh();
+ this.syncSelectedVodFavorite();
+ },
});
}
@@ -201,6 +234,7 @@ export class RecentlyViewedComponent {
const cleared = clearStalkerDetailViewState();
this.itemDetails = cleared.itemDetails;
this.vodDetailsItem = cleared.vodDetailsItem;
+ this.isSelectedVodFavorite.set(false);
}
async createLinkToPlayVodItv(
@@ -231,4 +265,13 @@ export class RecentlyViewedComponent {
private normalizeRecentItem(item: StalkerVodSource): StalkerSelectedVodItem {
return normalizeStalkerVodDetailsItem(item);
}
+
+ private syncSelectedVodFavorite(): void {
+ this.isSelectedVodFavorite.set(
+ isSelectedStalkerVodFavorite(
+ this.vodDetailsItem,
+ this.portalFavorites.value() ?? []
+ )
+ );
+ }
}
diff --git a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts
index 03f283742..75012a701 100644
--- a/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts
+++ b/apps/web/src/app/stalker/stalker-favorites/stalker-favorites.component.ts
@@ -21,6 +21,7 @@ import {
NormalizedStalkerFavoriteItem,
normalizeStalkerFavoriteItem,
isSelectedStalkerVodFavorite,
+ toggleStalkerVodFavorite,
} from '../stalker-vod.utils';
@Component({
@@ -143,6 +144,14 @@ export class StalkerFavoritesComponent {
);
}
+ addToFavorites(item: Record, onDone?: () => void) {
+ this.stalkerStore.addToFavorites(item, () => {
+ this.favoritesRefresh.refresh();
+ this.syncSelectedVodFavorite();
+ onDone?.();
+ });
+ }
+
setCategoryId(categoryId: string) {
this.selectedCategoryId.set(categoryId);
}
@@ -195,10 +204,15 @@ export class StalkerFavoritesComponent {
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: { item: VodDetailsItem; isFavorite: boolean }): void {
- if (!event.isFavorite && event.item.type === 'stalker') {
- this.removeFromFavorites({ id: event.item.data.id });
- this.isSelectedVodFavorite.set(false);
- }
+ toggleStalkerVodFavorite(event, {
+ addToFavorites: (item, onDone) => this.addToFavorites(item, onDone),
+ removeFromFavorites: (favoriteId, onDone) =>
+ this.removeFromFavorites({ id: favoriteId }, onDone),
+ onComplete: () => {
+ this.favoritesRefresh.refresh();
+ this.syncSelectedVodFavorite();
+ },
+ });
}
/** Handle back from vod-details component */
diff --git a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts
index faf23478a..dda9fccff 100644
--- a/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts
+++ b/apps/web/src/app/stalker/stalker-series-view/stalker-series-view.component.ts
@@ -71,7 +71,10 @@ export class StalkerSeriesViewComponent {
* Note: is_series can be true, 1, or "1" depending on the source
*/
readonly isVodSeries = computed(() => {
- return isVodSeriesItem(this.displayItem());
+ return (
+ this.stalkerStore.selectedContentType() === 'vod' &&
+ isVodSeriesItem(this.displayItem())
+ );
});
/**
@@ -103,17 +106,21 @@ export class StalkerSeriesViewComponent {
const playlist = this.stalkerStore.currentPlaylist();
if (item && playlist?._id) {
const seriesId = Number(item.id);
+ const normalizedSeriesId = this.toSeriesId(item.id);
this.logger.debug(
'Loading positions for series',
{
id: item.id,
- seriesId,
+ seriesId: normalizedSeriesId,
isSeries: item.is_series,
}
);
- if (!isNaN(seriesId)) {
+ if (!isNaN(normalizedSeriesId)) {
// Load playback positions from the XtreamStore (works for any playlist type)
- this.xtreamStore.loadSeriesPositions(playlist._id, seriesId);
+ this.xtreamStore.loadSeriesPositions(
+ playlist._id,
+ normalizedSeriesId
+ );
}
}
});
@@ -331,8 +338,11 @@ export class StalkerSeriesViewComponent {
this.stalkerStore.clearSelectedItem();
}
- toSeriesId(id: string): number {
- const parsed = Number(id);
+ toSeriesId(id: string | number): number {
+ const raw = String(id ?? '').trim();
+ if (!raw) return 0;
+ const primary = raw.includes(':') ? raw.split(':')[0] : raw;
+ const parsed = Number(primary);
return Number.isFinite(parsed) ? parsed : 0;
}
}
diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html
index 0aa387daa..0661f10f9 100644
--- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html
+++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.html
@@ -16,6 +16,7 @@
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
+ (downloadRequested)="onVodDownload($event)"
(backClicked)="onVodBack()"
/>
}
diff --git a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
index 1545ac21f..e6a5c5ef1 100644
--- a/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
+++ b/apps/web/src/app/xtream-tauri/category-content-view/category-content-view.component.ts
@@ -25,6 +25,11 @@ import {
toggleStalkerVodFavorite,
} from '../../stalker/stalker-vod.utils';
import { createLogger } from '../../shared/utils/logger';
+import { DownloadsService } from '../../services/downloads.service';
+import {
+ normalizeStalkerEntityId,
+ normalizeStalkerEntityIdAsNumber,
+} from '../../stalker/stalker-vod.utils';
@Component({
selector: 'app-category-content-view',
@@ -54,6 +59,7 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
readonly contentType = this.store.selectedContentType;
private readonly playlistService = inject(PlaylistsService);
+ private readonly downloadsService = inject(DownloadsService);
private readonly favoritesRefresh = createRefreshTrigger();
readonly limit = this.store.limit;
@@ -411,6 +417,62 @@ export class CategoryContentViewComponent implements OnInit, OnDestroy {
this.store.setSelectedItem(null);
}
+ /** Handle download from vod-details component */
+ async onVodDownload(item: VodDetailsItem): Promise {
+ if (item.type !== 'stalker') return;
+
+ const stalkerStore = this.store as InstanceType;
+ const playlist = stalkerStore.currentPlaylist();
+ if (!playlist || !playlist.portalUrl || !playlist.macAddress) return;
+
+ let cmdToUse = item.cmd;
+ const itemData = item.data as any;
+ const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
+
+ // Align with playback path: resolve has_files references to /media/file_{id}.mpg
+ if (
+ itemData?.has_files !== undefined &&
+ cmdToUse &&
+ !cmdToUse.includes('://') &&
+ cmdToUse.includes('/media/') &&
+ !cmdToUse.includes('/media/file_')
+ ) {
+ const fileId = await stalkerStore.fetchMovieFileId(normalizedItemId);
+ if (fileId) {
+ cmdToUse = `/media/file_${fileId}.mpg`;
+ }
+ }
+
+ const url = await stalkerStore.fetchLinkToPlay(
+ playlist.portalUrl,
+ playlist.macAddress,
+ cmdToUse
+ );
+ if (!url) return;
+
+ const numericId =
+ normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
+
+ await this.downloadsService.startDownload({
+ playlistId: playlist._id,
+ xtreamId: numericId,
+ contentType: 'vod',
+ title: itemData?.info?.name || itemData?.title || 'Unknown',
+ url,
+ posterUrl: itemData?.info?.movie_image,
+ headers: {
+ userAgent: playlist.userAgent,
+ referer: playlist.referrer,
+ origin: playlist.origin,
+ },
+ // Playlist info for auto-creation (Stalker playlists)
+ playlistName: playlist.title || 'Stalker Portal',
+ playlistType: 'stalker',
+ portalUrl: playlist.portalUrl,
+ macAddress: playlist.macAddress,
+ });
+ }
+
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts
index 4ed395832..6343f4579 100644
--- a/libs/services/src/lib/playlists.service.ts
+++ b/libs/services/src/lib/playlists.service.ts
@@ -411,13 +411,41 @@ export class PlaylistsService {
}
removeFromPortalRecentlyViewed(portalId: string, id: string | number) {
+ const normalizePortalItemId = (value: unknown): string => {
+ const raw = String(value ?? '').trim();
+ if (!raw) return '';
+ return raw.includes(':') ? raw.split(':')[0] : raw;
+ };
+
+ return this.getPlaylistById(portalId).pipe(
+ switchMap((portal) => {
+ const expectedId = String(id);
+ const expectedNormalized = normalizePortalItemId(id);
+
+ return this.dbService.update(DbStores.Playlists, {
+ ...portal,
+ recentlyViewed: portal.recentlyViewed?.filter((i: any) => {
+ const itemId = String(i?.id ?? '');
+ const itemNormalized = normalizePortalItemId(itemId);
+ return (
+ itemId !== expectedId &&
+ itemNormalized !== expectedNormalized
+ );
+ }),
+ });
+ })
+ );
+ }
+
+ clearPortalRecentlyViewed(portalId: string) {
+ if (!portalId) {
+ throw new Error('Portal ID is required');
+ }
return this.getPlaylistById(portalId).pipe(
switchMap((portal) =>
this.dbService.update(DbStores.Playlists, {
...portal,
- recentlyViewed: portal.recentlyViewed?.filter(
- (i) => i.id !== id
- ),
+ recentlyViewed: [],
})
)
);