feat: add o_name support and Stalker playback position tracking

- Add o_name (original title) field to Stalker VOD interfaces
- Prefer o_name over name in adapters and grid list display
- Add playback position tracking for Stalker VOD content
- Support resume playback from last position in vod-details
- Add real-time position updates via IPC listener
- Add progress indicators for Stalker VOD items in grid view
- Add debug logging for VOD series seasons resource

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.5 committed 2026-02-01 10:55:45 +01:00
1 parent 83d031c3b0
commit 0643acac3a
16 files changed
+329 -109

No files matched your search

@@ -8,6 +8,31 @@ import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import * as schema from '../../database/schema';
/**
* Ensure playlist exists in SQLite (auto-create if missing)
* This handles legacy playlists that weren't synced to SQLite
*/
async function ensurePlaylistExists(
db: any,
playlistId: string,
playlistType: 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' = 'stalker'
): Promise<void> {
const existing = await db
.select()
.from(schema.playlists)
.where(eq(schema.playlists.id, playlistId))
.limit(1);
if (existing.length === 0) {
// Create a minimal placeholder entry for the playlist
await db.insert(schema.playlists).values({
id: playlistId,
name: 'Imported Playlist',
type: playlistType,
});
}
}
/**
* Save/update playback position for content
*/
@@ -15,13 +40,11 @@ ipcMain.handle(
'DB_SAVE_PLAYBACK_POSITION',
async (event, playlistId: string, data: any) => {
try {
console.log(
'[DatabaseEvents] Saving playback position:',
playlistId,
data
);
const db = await getDatabase();
// Ensure playlist exists (handles legacy playlists not in SQLite)
await ensurePlaylistExists(db, playlistId, data.playlistType);
// Prepare values
const values = {
playlistId,
@@ -83,9 +106,6 @@ ipcMain.handle(
contentType: 'vod' | 'episode'
) => {
try {
console.log(
`[DatabaseEvents] Getting playback position: playlistId=${playlistId}, id=${contentXtreamId}, type=${contentType}`
);
const db = await getDatabase();
const result = await db
.select()
@@ -117,9 +137,6 @@ ipcMain.handle(
'DB_GET_SERIES_PLAYBACK_POSITIONS',
async (event, playlistId: string, seriesXtreamId: number) => {
try {
console.log(
`[DatabaseEvents] Getting series playback positions: playlistId=${playlistId}, seriesXtreamId=${seriesXtreamId}`
);
const db = await getDatabase();
const result = await db
.select()
@@ -135,15 +152,6 @@ ipcMain.handle(
)
);
console.log(
`[DatabaseEvents] Found ${result.length} positions for series ${seriesXtreamId}:`,
result.map((r) => ({
contentXtreamId: r.contentXtreamId,
seriesXtreamId: r.seriesXtreamId,
position: r.positionSeconds,
}))
);
return result;
} catch (error) {
console.error('Error getting series playback positions:', error);
@@ -21,6 +21,8 @@ ipcMain.handle(
serverUrl?: string;
username?: string;
password?: string;
macAddress?: string;
url?: string;
type: string;
}
) => {
@@ -32,6 +34,8 @@ ipcMain.handle(
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
macAddress: playlist.macAddress,
url: playlist.url,
// enforce supported types
type: playlist.type as
| 'xtream'
@@ -59,7 +59,7 @@ import { WatchedBadgeComponent } from '../../../xtream-tauri/shared/watched-badg
<mat-icon>star</mat-icon>{{ rating }}
</div>
}
@let title = i.title ?? i.name;
@let title = i.title ?? i.o_name ?? i.name;
<mat-card-actions>
<div class="title">
{{ title || 'No name' }}
@@ -131,7 +131,7 @@ export class RecentlyViewedComponent {
this.stalkerStore.setSelectedContentType(item.category_id);
switch (item.category_id) {
case 'itv':
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
break;
case 'vod':
// Normalize the item to ensure is_series flag is properly set
@@ -229,7 +229,8 @@ export class RecentlyViewedComponent {
// Ensure info object exists
const info = item.info || {
name: item.name || item.title || 'Unknown',
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
};
@@ -134,7 +134,7 @@ export class StalkerFavoritesComponent {
switch (item.category_id) {
case 'itv':
this.stalkerStore.setSelectedContentType('itv');
this.createLinkToPlayVodItv(item.cmd, item.name, item.logo);
this.createLinkToPlayVodItv(item.cmd, item.o_name || item.name, item.logo);
break;
case 'vod':
this.itemDetails = normalizedItem;
@@ -202,7 +202,8 @@ export class StalkerFavoritesComponent {
const source = item.details || item;
const info = source.info || {
name: item.name || item.title || 'Unknown',
name: item.o_name || item.name || item.title || 'Unknown',
o_name: item.o_name,
movie_image: item.cover || item.screenshot_uri || item.logo,
description: item.description,
releasedate: item.releasedate || item.year,
@@ -38,7 +38,7 @@
"
alt="logo"
/>
{{ item.name }}
{{ item.o_name || item.name }}
<button
mat-icon-button
matListItemMeta
@@ -97,7 +97,7 @@
}
@if (!isItvCategorySelected) {
{{ selectedCategoryTitle() }}
{{ getContentLabel() }}
}
@if (!contentItems() && !isContentLoading()) {
@@ -15,7 +15,7 @@ import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { PlaylistsService } from 'services';
import { PlaylistSwitcherComponent, ResizableDirective } from 'components';
@@ -48,6 +48,7 @@ import { StalkerStore } from './stalker.store';
})
export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
readonly stalkerStore = inject(StalkerStore);
private readonly translateService = inject(TranslateService);
currentLayout:
| 'category_content'
@@ -282,7 +283,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
createLinkToPlayItv(item: any) {
this.stalkerStore.setSelectedItem(item);
this.stalkerStore.createLinkToPlayVod(item.cmd, item.name, item.logo);
this.stalkerStore.createLinkToPlayVod(item.cmd, item.o_name || item.name, item.logo);
}
toggleFavorite(item: any) {
@@ -296,7 +297,7 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
this.stalkerStore.addToFavorites({
...item,
category_id: 'itv',
title: item.name,
title: item.o_name || item.name,
cover: item.logo,
added_at: new Date().toISOString(),
});
@@ -329,4 +330,21 @@ export class StalkerMainContainerComponent implements AfterViewInit, OnDestroy {
goBackToList() {
this.stalkerStore.clearSelectedItem();
}
getContentLabel(): string {
const categoryName = this.stalkerStore.getSelectedCategoryName();
// Show page number when viewing category content (not detail view)
if (
!this.stalkerStore.selectedItem() &&
this.stalkerStore.getTotalPages() > 1
) {
const currentPage = this.stalkerStore.page() + 1;
const totalPages = this.stalkerStore.getTotalPages();
const pageLabel = this.translateService.instant('PORTALS.PAGE');
return `${categoryName} (${pageLabel} ${currentPage}/${totalPages})`;
}
return categoryName;
}
}
+57 -4
View File
@@ -441,8 +441,15 @@ export const StalkerStore = signalStore(
}),
loader: async ({ params }) => {
const item = params.selectedItem;
// Debug logging
console.log('[StalkerStore] vodSeriesSeasonsResource loader called');
console.log('[StalkerStore] item:', item);
console.log('[StalkerStore] item?.is_series:', item?.is_series);
console.log('[StalkerStore] currentPlaylist:', store.currentPlaylist());
// Only fetch if item is a VOD series (has is_series flag)
if (!store.currentPlaylist() || !item || !item.is_series) {
console.log('[StalkerStore] vodSeriesSeasonsResource - early return, conditions not met');
return [];
}
@@ -473,13 +480,17 @@ export const StalkerStore = signalStore(
}
if (!response?.js?.data) {
console.log('[StalkerStore] vodSeriesSeasonsResource - no response data');
return [];
}
console.log('[StalkerStore] vodSeriesSeasonsResource - response.js.data:', response.js.data);
// Filter for season items (is_season: true)
const seasons = response.js.data.filter(
(item: any) => item.is_season === true
);
console.log('[StalkerStore] vodSeriesSeasonsResource - filtered seasons:', seasons);
return sortByNumericValue(seasons);
},
}),
@@ -572,8 +583,28 @@ export const StalkerStore = signalStore(
setPage(page: number) {
patchState(store, { page });
},
setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
async setCurrentPlaylist(playlist: PlaylistMeta | undefined) {
patchState(store, { currentPlaylist: playlist });
// Ensure Stalker playlist exists in SQLite for playback positions
if (playlist && dataService.isElectron && playlist._id) {
try {
// Check if playlist exists in SQLite
const existing = await window.electron.dbGetPlaylist(playlist._id);
if (!existing) {
// Create playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlist._id,
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
} catch (error) {
console.error('Error syncing Stalker playlist to SQLite:', error);
}
}
},
setSelectedItem(selectedItem: any) {
// TODO: check the item type and proper property either selectedVodId or serialsId etc
@@ -666,9 +697,31 @@ export const StalkerStore = signalStore(
return episodes;
},
/** getters */
getSelectedCategory() {
return signal<string>(store.selectedCategoryId());
},
getSelectedCategory: computed(() => {
const categoryId = store.selectedCategoryId();
if (!categoryId) {
return {
id: 0,
name: 'All Items',
type: store.selectedContentType(),
};
}
// Get categories based on content type
const contentType = store.selectedContentType();
let categories: any[] = [];
if (contentType === 'vod') {
categories = store.vodCategories();
} else if (contentType === 'series') {
categories = store.seriesCategories();
} else if (contentType === 'itv') {
categories = store.itvCategories();
}
return categories.find(
(c: any) => String(c.category_id) === String(categoryId)
) || { id: categoryId, name: '', type: contentType };
}),
/** API */
async fetchLinkToPlay(
portalUrl: string,
@@ -2,7 +2,7 @@
@if (isStalker && selectedItem()) {
@if (contentType() === 'vod') {
@if (selectedItem().series?.length > 0) {
<!-- VOD item with embedded series (vclub) -->
<!-- VOD item with embedded series (vclub or Ministra) -->
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
} @else if (selectedItem().is_series) {
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
@@ -11,7 +11,9 @@
@if (vodDetailsItem()) {
<app-vod-details
[item]="vodDetailsItem()!"
[playbackPosition]="selectedVodPlaybackPosition()"
(playClicked)="onVodPlay($event)"
(resumeClicked)="onVodResume($event)"
(favoriteToggled)="onVodFavoriteToggled($event)"
(backClicked)="onVodBack()"
/>
@@ -1,4 +1,4 @@
import { Component, inject, OnInit, computed } from '@angular/core';
import { Component, inject, OnInit, OnDestroy, computed, signal, effect } from '@angular/core';
import { PageEvent } from '@angular/material/paginator';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -7,11 +7,13 @@ import {
VodDetailsItem,
StalkerVodDetails,
createStalkerVodItem,
PlaybackPositionData,
} from 'shared-interfaces';
import { GridListComponent } from '../../shared/components/grid-list/grid-list.component';
import { StalkerSeriesViewComponent } from '../../stalker/stalker-series-view/stalker-series-view.component';
import { StalkerStore } from '../../stalker/stalker.store';
import { VodDetailsComponent } from '../../xtream/vod-details/vod-details.component';
import { XTREAM_DATA_SOURCE } from '../data-sources';
import { PlaylistErrorViewComponent } from '../playlist-error-view/playlist-error-view.component';
import { XtreamStore } from '../stores/xtream.store';
@@ -27,15 +29,19 @@ import { XtreamStore } from '../stores/xtream.store';
VodDetailsComponent,
],
})
export class CategoryContentViewComponent implements OnInit {
export class CategoryContentViewComponent implements OnInit, OnDestroy {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dataSource = inject(XTREAM_DATA_SOURCE);
readonly isStalker = this.activatedRoute.snapshot.data['api'] === 'stalker';
private readonly store = this.isStalker
? inject(StalkerStore)
: inject(XtreamStore);
/** Unsubscribe function for playback position updates */
private unsubscribePositionUpdates: (() => void) | null = null;
readonly contentType = this.store.selectedContentType;
readonly limit = this.store.limit;
@@ -44,32 +50,103 @@ export class CategoryContentViewComponent implements OnInit {
readonly selectedCategory = this.store.getSelectedCategory;
readonly paginatedContent = this.store.getPaginatedContent;
/** Stalker playback positions (key: `${contentType}_${id}`) */
private readonly stalkerPositions = signal<Map<string, PlaybackPositionData>>(new Map());
/** Stalker series positions (key: seriesId, value: episode positions) */
private readonly stalkerSeriesPositions = signal<Map<number, PlaybackPositionData[]>>(new Map());
/** Track which playlist we've loaded positions for */
private loadedPositionsForPlaylistId: string | null = null;
constructor() {
// Effect to load Stalker positions when playlist becomes available
if (this.isStalker) {
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const playlist = stalkerStore.currentPlaylist();
const playlistId = playlist?._id;
if (playlistId && playlistId !== this.loadedPositionsForPlaylistId) {
this.loadedPositionsForPlaylistId = playlistId;
this.loadStalkerPositions(playlistId);
}
});
// Effect to load position for selected VOD item (ensures fresh data after playback)
effect(() => {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
const item = this.selectedItem();
const playlist = stalkerStore.currentPlaylist();
if (item && playlist?._id && this.contentType() === 'vod') {
this.loadSingleVodPosition(playlist._id, Number(item.id));
}
});
// Listen for real-time playback position updates from video player
if (window.electron?.onPlaybackPositionUpdate) {
this.unsubscribePositionUpdates = window.electron.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
if (data.contentType === 'vod' && data.contentXtreamId) {
const key = `vod_${data.contentXtreamId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, data);
this.stalkerPositions.set(updated);
}
}
);
}
}
}
readonly contentWithProgress = computed(() => {
const items = this.store.getPaginatedContent();
if (!items) return [];
if (this.isStalker) return items;
if (this.isStalker) {
// Enrich Stalker items with progress data
const positions = this.stalkerPositions();
const seriesPositions = this.stalkerSeriesPositions();
const contentType = this.contentType();
return items.map((item: any) => {
const id = Number(item.id);
// Check if this item has series/episode progress (watched episodes)
// This works even when is_series flag isn't set on the item
const hasSeriesProgress = seriesPositions.has(id) && seriesPositions.get(id)!.length > 0;
// For series content type, or items with is_series flag
const isSeries = contentType === 'series' || item.is_series === '1' || item.is_series === 1;
if (hasSeriesProgress) {
return { ...item, hasSeriesProgress: true };
} else if (isSeries) {
return { ...item, hasSeriesProgress: false };
} else {
// Regular VOD item
const vodKey = `vod_${id}`;
const vodPosition = positions.get(vodKey);
const vodProgress = this.calculateProgress(vodPosition);
return {
...item,
progress: vodProgress,
isWatched: vodProgress >= 90,
};
}
});
}
const xtreamStore = this.store as any;
if (!xtreamStore.getProgressPercent) return items;
return items.map((item: any) => {
const isSeries = this.contentType() === 'series';
// Use xtream_id (DB) or series_id/stream_id (API)
const id = Number(
item.xtream_id || item.series_id || item.stream_id
);
// console.log(`[CategoryContent] Processing item ${id}. isSeries=${isSeries}`);
const id = Number(item.xtream_id || item.series_id || item.stream_id);
if (isSeries) {
const hasProgress = xtreamStore.hasSeriesProgress(id);
console.log(
`[CategoryContent] Checking series ${id} (xtream_id=${item.xtream_id}, series_id=${item.series_id}). Has progress: ${hasProgress}`
);
return {
...item,
hasSeriesProgress: hasProgress,
hasSeriesProgress: xtreamStore.hasSeriesProgress(id),
};
} else {
return {
@@ -100,15 +177,25 @@ export class CategoryContentViewComponent implements OnInit {
);
});
/** Playback position for the selected VOD item (Stalker) */
readonly selectedVodPlaybackPosition = computed<number | null>(() => {
if (!this.isStalker) return null;
const item = this.selectedItem();
if (!item) return null;
const id = Number(item.id);
const vodKey = `vod_${id}`;
const position = this.stalkerPositions().get(vodKey);
return position?.positionSeconds ?? null;
});
seasons = [];
ngOnInit() {
const { categoryId } = this.activatedRoute.snapshot.params;
console.log(
`[CategoryContent] ngOnInit. contentType=${this.contentType()}, isStalker=${this.isStalker}, categoryId=${categoryId}`
);
// Ensure playback positions are loaded (Xtream only)
// Ensure playback positions are loaded (Xtream only - Stalker is handled via effect in constructor)
if (!this.isStalker) {
const xtreamStore = this.store as any;
if (xtreamStore.currentPlaylist()?.id) {
@@ -120,20 +207,71 @@ export class CategoryContentViewComponent implements OnInit {
// This ensures the content-header is visible
this.store.setSelectedItem(null);
// Only set category if it's different from the currently selected one
// Set category - setSelectedCategory only resets page if category actually changes
// This preserves the page state when navigating back from detail view
if (categoryId) {
if (this.store.selectedCategoryId() !== Number(categoryId)) {
this.store.setSelectedCategory(categoryId);
}
this.store.setSelectedCategory(categoryId);
} else {
// No categoryId in route means "All Items"
if (this.store.selectedCategoryId() !== null) {
this.store.setSelectedCategory(null);
}
this.store.setSelectedCategory(null);
}
}
/**
* Load all playback positions for a Stalker portal
*/
private async loadStalkerPositions(playlistId: string): Promise<void> {
const positions = await this.dataSource.getAllPlaybackPositions(playlistId);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
const key = `${pos.contentType}_${pos.contentXtreamId}`;
positionsMap.set(key, pos);
// Group episodes by series ID
if (pos.contentType === 'episode' && pos.seriesXtreamId) {
const existing = seriesMap.get(pos.seriesXtreamId) || [];
existing.push(pos);
seriesMap.set(pos.seriesXtreamId, existing);
}
});
this.stalkerPositions.set(positionsMap);
this.stalkerSeriesPositions.set(seriesMap);
}
/**
* Load playback position for a single VOD item
*/
private async loadSingleVodPosition(playlistId: string, vodId: number): Promise<void> {
const position = await this.dataSource.getPlaybackPosition(playlistId, vodId, 'vod');
if (position) {
const key = `vod_${vodId}`;
const updated = new Map(this.stalkerPositions());
updated.set(key, position);
this.stalkerPositions.set(updated);
}
}
/**
* Calculate progress percentage from position data
*/
private calculateProgress(position: PlaybackPositionData | undefined): number {
if (!position || !position.durationSeconds) return 0;
const percent = (position.positionSeconds / position.durationSeconds) * 100;
// If watched > 10s but percent < 1, return 1 to show visual progress
if (position.positionSeconds > 10 && percent < 1) {
return 1;
}
return Math.min(100, Math.round(percent));
}
onPageChange(event: PageEvent) {
this.store.setPage(event.pageIndex);
this.store.setLimit(event.pageSize);
@@ -159,7 +297,8 @@ export class CategoryContentViewComponent implements OnInit {
info: {
movie_image: item.screenshot_uri,
description: item.description,
name: item.name || item.o_name,
name: item.o_name || item.name,
o_name: item.o_name,
director: item.director,
releasedate: item.year,
genre: item.genres_str,
@@ -215,6 +354,21 @@ export class CategoryContentViewComponent implements OnInit {
}
}
/** Handle resume from vod-details component */
onVodResume(event: { item: VodDetailsItem; positionSeconds: number }): void {
if (event.item.type === 'stalker') {
const stalkerStore = this.store as InstanceType<typeof StalkerStore>;
stalkerStore.createLinkToPlayVod(
event.item.cmd,
event.item.data.info?.name,
event.item.data.info?.movie_image,
undefined, // episodeNum
undefined, // episodeId
event.positionSeconds
);
}
}
/** Handle favorite toggle from vod-details component */
onVodFavoriteToggled(event: {
item: VodDetailsItem;
@@ -239,4 +393,8 @@ export class CategoryContentViewComponent implements OnInit {
onVodBack(): void {
this.store.setSelectedItem(null);
}
ngOnDestroy(): void {
this.unsubscribePositionUpdates?.();
}
}
@@ -28,7 +28,8 @@ export class CategoryViewComponent {
const selectedCategory = this.selectedCategoryId();
const itemId = Number((item as any).category_id ?? item.id);
return selectedCategory !== null && selectedCategory === itemId;
// Compare as strings to handle both number and string category IDs
return selectedCategory != null && String(selectedCategory) === String(itemId);
}
getItemCount(item: XtreamCategory): number {
@@ -130,22 +130,16 @@ export function withPlaybackPositions() {
playlistId
);
console.log(
`[withPlaybackPositions] Loaded all positions (${positions.length})`
);
const positionsMap = new Map<string, PlaybackPositionData>();
const seriesMap = new Map<number, PlaybackPositionData[]>();
positions.forEach((pos) => {
// Populate playbackPositions map
const key = getPositionKey(
pos.contentType,
pos.contentXtreamId
);
positionsMap.set(key, pos);
// Populate seriesPositions map
if (
pos.contentType === 'episode' &&
pos.seriesXtreamId
@@ -157,10 +151,6 @@ export function withPlaybackPositions() {
}
});
console.log(
`[withPlaybackPositions] Series map populated: ${seriesMap.size} series`
);
patchState(store, {
playbackPositions: positionsMap,
seriesPositions: seriesMap,
@@ -174,13 +164,7 @@ export function withPlaybackPositions() {
const positions = store
.seriesPositions()
.get(seriesXtreamId);
if (positions && positions.length > 0) {
console.log(`[withPlaybackPositions] Series ${seriesXtreamId} HAS progress`);
return true;
}
// console.log(`[withPlaybackPositions] Series ${seriesXtreamId} NO progress`);
return false;
return positions !== undefined && positions.length > 0;
},
/**
@@ -196,11 +180,6 @@ export function withPlaybackPositions() {
'vod'
);
console.log(
`[withPlaybackPositions] Loaded VOD position for ${vodXtreamId}:`,
position
);
if (position) {
const key = getPositionKey('vod', vodXtreamId);
const updated = new Map(store.playbackPositions());
@@ -221,11 +200,6 @@ export function withPlaybackPositions() {
seriesXtreamId
);
console.log(
`[withPlaybackPositions] Loaded series positions for ${seriesXtreamId}:`,
positions
);
const updated = new Map(store.seriesPositions());
updated.set(seriesXtreamId, positions);
patchState(store, { seriesPositions: updated });
@@ -255,10 +229,6 @@ export function withPlaybackPositions() {
data.contentType,
data.contentXtreamId
);
console.log(
`[withPlaybackPositions] Saving position for ${key}:`,
data
);
const updated = new Map(store.playbackPositions());
updated.set(key, data);
patchState(store, { playbackPositions: updated });
@@ -373,18 +343,8 @@ export function withPlaybackPositions() {
if (window.electron?.onPlaybackPositionUpdate) {
unsubscribe = window.electron.onPlaybackPositionUpdate(
(data: any) => {
console.log(
'[withPlaybackPositions] Received update:',
data
);
// Ensure playlistId is present
if (data.playlistId) {
store.savePosition(data.playlistId, data);
} else {
console.warn(
'[withPlaybackPositions] Missing playlistId in update',
data
);
}
}
);
@@ -176,8 +176,11 @@ export class VodDetailsComponent implements OnDestroy {
/** Handle back navigation */
goBack(): void {
this.backClicked.emit();
// Also handle navigation internally for convenience
this.location.back();
// For Stalker items, parent handles navigation by clearing selectedItem
// For Xtream items, use browser history navigation
if (!this.isStalkerItem()) {
this.location.back();
}
}
/** Handle download request */
+10 -2
View File
@@ -300,7 +300,7 @@ export class PlaylistEffects {
}),
map(async (playlist) => {
if (playlist.serverUrl && this.dataService.isElectron) {
// Use Electron database API
// Create Xtream playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlist._id.toString(),
name: playlist.title || '',
@@ -309,8 +309,16 @@ export class PlaylistEffects {
password: playlist.password || '',
type: 'xtream',
});
console.log('Playlist created in database');
this.router.navigate(['/xtreams/', playlist._id]);
} else if (playlist.macAddress && this.dataService.isElectron) {
// Create Stalker playlist in SQLite
await window.electron.dbCreatePlaylist({
id: playlist._id.toString(),
name: playlist.title || '',
macAddress: playlist.macAddress || '',
url: playlist.portalUrl || '',
type: 'stalker',
});
}
})
);
@@ -16,8 +16,10 @@ export interface StalkerVodInfo {
movie_image: string;
/** Plot description */
description: string;
/** Movie title */
/** Formatted/system title (often generic like "video_name_format") */
name: string;
/** Original title (preferred, more descriptive) */
o_name?: string;
/** Comma-separated list of actors */
actors: string;
/** Director name */
@@ -35,12 +35,13 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
/**
* Normalizes Stalker VOD details into a display-friendly format.
* Stalker has fewer metadata fields than Xtream.
* Prefers o_name (original title) over name (often formatted/generic).
*/
export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta {
const info = item?.info;
return {
title: info?.name || 'Unknown',
title: info?.o_name || info?.name || 'Unknown',
description: info?.description,
posterUrl: info?.movie_image,
backdropUrl: undefined, // Stalker doesn't provide backdrop