mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+3
-1
@@ -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()"
|
||||
/>
|
||||
|
||||
+184
-26
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user