mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
feat(stalker): support ministra shows under VOD content type
Update translations and refactor StalkerPortalImportComponent to use Angular signals and readonly/dependency-injected readonly properties. - Add new i18n keys: LOADING_SEASONS and NO_EPISODES; adjust PLAYBACK_ERROR punctuation. - Replace EventEmitter @Output with a signals-based readonly output and mark form, constants and flags as readonly. - Switch to inject() style for services and mark many injected fields private/readonly to clarify intent. - Replace DataService import with STALKER_SERIAL_NUMBER token import. - Improve logging formatting, whitespace and minor code cleanup. - Minor template/style whitespace fixes. These changes modernize the component for better immutability, improve DX with clearer logging, and add missing user-facing translation strings.
This commit is contained in:
1 parent
61eef5e00d
commit
16baf54b16
9 files changed
+887
-379
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, Output, inject, signal } from '@angular/core';
|
||||
import { Component, inject, output, signal } from '@angular/core';
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -14,7 +14,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { PlaylistActions } from 'm3u-state';
|
||||
import { DataService, StalkerSessionService } from 'services';
|
||||
import { STALKER_SERIAL_NUMBER, StalkerSessionService } from 'services';
|
||||
import { Playlist } from 'shared-interfaces';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -41,7 +41,7 @@ import { v4 as uuid } from 'uuid';
|
||||
form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -51,10 +51,10 @@ import { v4 as uuid } from 'uuid';
|
||||
],
|
||||
})
|
||||
export class StalkerPortalImportComponent {
|
||||
@Output() addClicked = new EventEmitter<void>();
|
||||
URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
readonly addClicked = output<void>();
|
||||
readonly URL_REGEX = /^(http|https|file):\/\/[^ "]+$/;
|
||||
|
||||
form = new FormGroup({
|
||||
readonly form = new FormGroup({
|
||||
_id: new FormControl(uuid()),
|
||||
title: new FormControl('', [Validators.required]),
|
||||
macAddress: new FormControl('', [Validators.required]),
|
||||
@@ -73,32 +73,38 @@ export class StalkerPortalImportComponent {
|
||||
userAgent: new FormControl(''),
|
||||
});
|
||||
|
||||
dataService = inject(DataService);
|
||||
stalkerSessionService = inject(StalkerSessionService);
|
||||
store = inject(Store);
|
||||
snackBar = inject(MatSnackBar);
|
||||
translate = inject(TranslateService);
|
||||
|
||||
isLoading = signal(false);
|
||||
private readonly stalkerSessionService = inject(StalkerSessionService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
readonly translate = inject(TranslateService);
|
||||
|
||||
readonly isLoading = signal(false);
|
||||
|
||||
async addPlaylist() {
|
||||
if (!this.form.valid || this.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
|
||||
try {
|
||||
const originalUrl = this.form.value.portalUrl;
|
||||
const transformedUrl = this.transformPortalUrl(originalUrl);
|
||||
const isFullStalkerPortal = this.isFullStalkerPortalUrl(originalUrl);
|
||||
|
||||
const isFullStalkerPortal =
|
||||
this.isFullStalkerPortalUrl(originalUrl);
|
||||
|
||||
console.log('[StalkerImport] Starting import process');
|
||||
console.log('[StalkerImport] Original URL:', originalUrl);
|
||||
console.log('[StalkerImport] Transformed URL:', transformedUrl);
|
||||
console.log('[StalkerImport] Is Full Stalker Portal:', isFullStalkerPortal);
|
||||
console.log('[StalkerImport] MAC Address:', this.form.value.macAddress);
|
||||
|
||||
console.log(
|
||||
'[StalkerImport] Is Full Stalker Portal:',
|
||||
isFullStalkerPortal
|
||||
);
|
||||
console.log(
|
||||
'[StalkerImport] MAC Address:',
|
||||
this.form.value.macAddress
|
||||
);
|
||||
|
||||
let stalkerToken: string | undefined;
|
||||
let stalkerAccountInfo: Playlist['stalkerAccountInfo'] | undefined;
|
||||
let stalkerSerialNumber: string | undefined;
|
||||
@@ -106,51 +112,98 @@ export class StalkerPortalImportComponent {
|
||||
let stalkerDeviceId2: string | undefined;
|
||||
let stalkerSignature1: string | undefined;
|
||||
let stalkerSignature2: string | undefined;
|
||||
|
||||
|
||||
// For full stalker portal URLs, perform handshake and get profile
|
||||
if (isFullStalkerPortal) {
|
||||
console.log('[StalkerImport] Full stalker portal detected, starting authentication...');
|
||||
|
||||
console.log(
|
||||
'[StalkerImport] Full stalker portal detected, starting authentication...'
|
||||
);
|
||||
|
||||
// Use provided serial number or generate a new one
|
||||
stalkerSerialNumber = this.form.value.serialNumber?.trim() || this.generateSerialNumber();
|
||||
console.log('[StalkerImport] Using serial number:', stalkerSerialNumber,
|
||||
this.form.value.serialNumber?.trim() ? '(user provided)' : '(auto-generated)');
|
||||
|
||||
stalkerSerialNumber =
|
||||
this.form.value.serialNumber?.trim() ||
|
||||
STALKER_SERIAL_NUMBER;
|
||||
console.log(
|
||||
'[StalkerImport] Using serial number:',
|
||||
stalkerSerialNumber,
|
||||
this.form.value.serialNumber?.trim()
|
||||
? '(user provided)'
|
||||
: '(auto-generated)'
|
||||
);
|
||||
|
||||
// Use provided device IDs if available (64 hex chars each)
|
||||
stalkerDeviceId1 = this.form.value.deviceId1?.trim() || undefined;
|
||||
stalkerDeviceId2 = this.form.value.deviceId2?.trim() || undefined;
|
||||
stalkerSignature1 = this.form.value.signature1?.trim() || undefined;
|
||||
stalkerSignature2 = this.form.value.signature2?.trim() || undefined;
|
||||
console.log('[StalkerImport] Device ID 1:', stalkerDeviceId1 ? stalkerDeviceId1.substring(0, 16) + '... (user provided)' : '(auto-generated)');
|
||||
console.log('[StalkerImport] Device ID 2:', stalkerDeviceId2 ? stalkerDeviceId2.substring(0, 16) + '... (user provided)' : '(auto-generated)');
|
||||
console.log('[StalkerImport] Signature 1:', stalkerSignature1 ? stalkerSignature1.substring(0, 16) + '... (user provided)' : '(not provided)');
|
||||
console.log('[StalkerImport] Signature 2:', stalkerSignature2 ? stalkerSignature2.substring(0, 16) + '... (user provided)' : '(not provided)');
|
||||
|
||||
stalkerDeviceId1 =
|
||||
this.form.value.deviceId1?.trim() || undefined;
|
||||
stalkerDeviceId2 =
|
||||
this.form.value.deviceId2?.trim() || undefined;
|
||||
stalkerSignature1 =
|
||||
this.form.value.signature1?.trim() || undefined;
|
||||
stalkerSignature2 =
|
||||
this.form.value.signature2?.trim() || undefined;
|
||||
console.log(
|
||||
'[StalkerImport] Device ID 1:',
|
||||
stalkerDeviceId1
|
||||
? stalkerDeviceId1.substring(0, 16) +
|
||||
'... (user provided)'
|
||||
: '(auto-generated)'
|
||||
);
|
||||
console.log(
|
||||
'[StalkerImport] Device ID 2:',
|
||||
stalkerDeviceId2
|
||||
? stalkerDeviceId2.substring(0, 16) +
|
||||
'... (user provided)'
|
||||
: '(auto-generated)'
|
||||
);
|
||||
console.log(
|
||||
'[StalkerImport] Signature 1:',
|
||||
stalkerSignature1
|
||||
? stalkerSignature1.substring(0, 16) +
|
||||
'... (user provided)'
|
||||
: '(not provided)'
|
||||
);
|
||||
console.log(
|
||||
'[StalkerImport] Signature 2:',
|
||||
stalkerSignature2
|
||||
? stalkerSignature2.substring(0, 16) +
|
||||
'... (user provided)'
|
||||
: '(not provided)'
|
||||
);
|
||||
|
||||
try {
|
||||
const authResult = await this.stalkerSessionService.authenticate(
|
||||
transformedUrl,
|
||||
this.form.value.macAddress,
|
||||
stalkerSerialNumber,
|
||||
stalkerDeviceId1,
|
||||
stalkerDeviceId2
|
||||
);
|
||||
|
||||
const authResult =
|
||||
await this.stalkerSessionService.authenticate(
|
||||
transformedUrl,
|
||||
this.form.value.macAddress,
|
||||
stalkerSerialNumber,
|
||||
stalkerDeviceId1,
|
||||
stalkerDeviceId2
|
||||
);
|
||||
|
||||
stalkerToken = authResult.token;
|
||||
console.log('[StalkerImport] Authentication successful, token received:', stalkerToken?.substring(0, 10) + '...');
|
||||
|
||||
console.log(
|
||||
'[StalkerImport] Authentication successful, token received:',
|
||||
stalkerToken?.substring(0, 10) + '...'
|
||||
);
|
||||
|
||||
if (authResult.accountInfo) {
|
||||
stalkerAccountInfo = {
|
||||
login: authResult.accountInfo.login,
|
||||
expireDate: authResult.accountInfo.expire_date,
|
||||
tariffPlanName: authResult.accountInfo.tariff_plan_name,
|
||||
tariffPlanName:
|
||||
authResult.accountInfo.tariff_plan_name,
|
||||
status: authResult.accountInfo.status,
|
||||
};
|
||||
console.log('[StalkerImport] Account info received:', stalkerAccountInfo);
|
||||
console.log(
|
||||
'[StalkerImport] Account info received:',
|
||||
stalkerAccountInfo
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Show success notification with account info if available
|
||||
if (stalkerAccountInfo?.expireDate) {
|
||||
const expireDate = new Date(stalkerAccountInfo.expireDate * 1000);
|
||||
const expireDate = new Date(
|
||||
stalkerAccountInfo.expireDate * 1000
|
||||
);
|
||||
this.snackBar.open(
|
||||
`Portal validated. Expires: ${expireDate.toLocaleDateString()}`,
|
||||
null,
|
||||
@@ -158,7 +211,10 @@ export class StalkerPortalImportComponent {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[StalkerImport] Authentication failed:', error);
|
||||
console.error(
|
||||
'[StalkerImport] Authentication failed:',
|
||||
error
|
||||
);
|
||||
this.snackBar.open(
|
||||
'Failed to authenticate with portal. Please check URL and MAC address.',
|
||||
null,
|
||||
@@ -168,9 +224,11 @@ export class StalkerPortalImportComponent {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.log('[StalkerImport] Simple portal URL, skipping authentication');
|
||||
console.log(
|
||||
'[StalkerImport] Simple portal URL, skipping authentication'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const playlist: Playlist = {
|
||||
...this.form.value,
|
||||
portalUrl: transformedUrl,
|
||||
@@ -183,13 +241,13 @@ export class StalkerPortalImportComponent {
|
||||
stalkerSignature1,
|
||||
stalkerSignature2,
|
||||
} as Playlist;
|
||||
|
||||
|
||||
console.log('[StalkerImport] Creating playlist with config:', {
|
||||
portalUrl: playlist.portalUrl,
|
||||
isFullStalkerPortal: playlist.isFullStalkerPortal,
|
||||
hasToken: !!playlist.stalkerToken,
|
||||
});
|
||||
|
||||
|
||||
this.store.dispatch(PlaylistActions.addPlaylist({ playlist }));
|
||||
this.addClicked.emit();
|
||||
} finally {
|
||||
@@ -213,43 +271,40 @@ export class StalkerPortalImportComponent {
|
||||
transformPortalUrl(url: string): string {
|
||||
// Remove trailing slashes
|
||||
url = url.replace(/\/+$/, '');
|
||||
|
||||
|
||||
// Case 1: Simple URL ending with /c -> convert to /portal.php
|
||||
if (url.endsWith('/c')) {
|
||||
// Check if it's a full stalker portal URL
|
||||
if (url.includes('/stalker_portal')) {
|
||||
// example.com/stalker_portal/c -> example.com/stalker_portal/server/load.php
|
||||
return url.replace(/\/stalker_portal\/c$/, '/stalker_portal/server/load.php');
|
||||
return url.replace(
|
||||
/\/stalker_portal\/c$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
// Simple URL: example.com/c -> example.com/portal.php
|
||||
return url.replace(/\/c$/, '/portal.php');
|
||||
}
|
||||
|
||||
// Case 2: Full stalker portal URL without /c at the end
|
||||
if (url.includes('/stalker_portal') && !url.includes('/server/load.php')) {
|
||||
if (
|
||||
url.includes('/stalker_portal') &&
|
||||
!url.includes('/server/load.php')
|
||||
) {
|
||||
// example.com/stalker_portal -> example.com/stalker_portal/server/load.php
|
||||
if (url.endsWith('/stalker_portal')) {
|
||||
return url + '/server/load.php';
|
||||
}
|
||||
// If it has other path segments after /stalker_portal, append server/load.php
|
||||
if (!url.endsWith('/load.php')) {
|
||||
return url.replace(/\/stalker_portal(\/.*)?$/, '/stalker_portal/server/load.php');
|
||||
return url.replace(
|
||||
/\/stalker_portal(\/.*)?$/,
|
||||
'/stalker_portal/server/load.php'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise keep the provided url
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random serial number - 13 hex characters
|
||||
*/
|
||||
private generateSerialNumber(): string {
|
||||
const chars = '0123456789ABCDEF';
|
||||
let result = '';
|
||||
for (let i = 0; i < 13; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@let seasons = seasonsData;
|
||||
@let seasons = seasonsData();
|
||||
<div class="container">
|
||||
@if (displayItem(); as serial) {
|
||||
<div class="image">
|
||||
@@ -9,6 +9,7 @@
|
||||
(error)="
|
||||
$event.target.src = './assets/images/default-poster.png'
|
||||
"
|
||||
alt="Series Cover"
|
||||
/>
|
||||
} @else {
|
||||
<div class="placeholder-cover"></div>
|
||||
@@ -22,7 +23,9 @@
|
||||
</div>
|
||||
@if (serial.info.director) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.DIRECTOR' | translate }}:</div>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.DIRECTOR' | translate }}:
|
||||
</div>
|
||||
{{ serial.info.director }}
|
||||
</div>
|
||||
}
|
||||
@@ -46,7 +49,9 @@
|
||||
}
|
||||
@if (serial.info.rating_imdb) {
|
||||
<div>
|
||||
<div class="label">{{ 'XTREAM.IMDB_RATING' | translate }}:</div>
|
||||
<div class="label">
|
||||
{{ 'XTREAM.IMDB_RATING' | translate }}:
|
||||
</div>
|
||||
{{ serial.info.rating_imdb }}
|
||||
</div>
|
||||
}
|
||||
@@ -70,19 +75,85 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Loading state for VOD series seasons -->
|
||||
@if (isVodSeries() && isVodSeriesSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Loading state for regular series seasons -->
|
||||
@if (!isVodSeries() && isSerialSeasonsLoading()) {
|
||||
<div class="seasons-loading">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
<span>{{ 'PORTALS.LOADING_SEASONS' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="seasons">
|
||||
@for (item of seasons(); track $index) {
|
||||
<mat-divider />
|
||||
<h4>{{ item.name }}</h4>
|
||||
<div class="episodes">
|
||||
@for (episode of item.series; track $index) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="playEpisodeClicked(episode, item.cmd)"
|
||||
>
|
||||
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
|
||||
</button>
|
||||
@if (isVodSeries()) {
|
||||
<!-- VOD series mode: expandable seasons with dynamic episode loading -->
|
||||
@for (item of vodSeriesSeasons(); track item.id) {
|
||||
<mat-divider />
|
||||
<div class="season-header" (click)="toggleSeasonExpanded(item)">
|
||||
<h4>{{ item.name }}</h4>
|
||||
<span class="expand-icon">{{
|
||||
item.isExpanded ? '▼' : '►'
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
@if (item.isExpanded) {
|
||||
@if (item.isLoading) {
|
||||
<div class="episodes-loading">
|
||||
<mat-spinner diameter="24"></mat-spinner>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="episodes">
|
||||
@for (
|
||||
episode of item.episodes;
|
||||
track episode.id || $index
|
||||
) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="
|
||||
playVodSeriesEpisode(episode);
|
||||
$event.stopPropagation()
|
||||
"
|
||||
>
|
||||
{{
|
||||
episode.name ||
|
||||
('XTREAM.EPISODE' | translate) +
|
||||
' ' +
|
||||
episode.series_number
|
||||
}}
|
||||
</button>
|
||||
}
|
||||
@if (!item.episodes || item.episodes.length === 0) {
|
||||
<span class="no-episodes">{{
|
||||
'PORTALS.NO_EPISODES' | translate
|
||||
}}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<!-- Regular series mode: pre-loaded episodes -->
|
||||
@for (item of seasons; track item.id || $index) {
|
||||
<mat-divider />
|
||||
<h4>{{ item.name }}</h4>
|
||||
<div class="episodes">
|
||||
@for (episode of $any(item).series; track $index) {
|
||||
<button
|
||||
mat-stroked-button
|
||||
(click)="playEpisodeClicked(episode, $any(item).cmd)"
|
||||
>
|
||||
{{ 'XTREAM.EPISODE' | translate }} {{ episode }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -1,21 +1,49 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatDivider } from '@angular/material/divider';
|
||||
import { MatProgressSpinner } from '@angular/material/progress-spinner';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { FavoritesButtonComponent } from '../favorites-button/favorites-button.component';
|
||||
import { StalkerStore } from '../stalker.store';
|
||||
|
||||
/**
|
||||
* VOD series season with episodes loaded dynamically
|
||||
*/
|
||||
interface VodSeriesSeason {
|
||||
id: string;
|
||||
video_id: string;
|
||||
name: string;
|
||||
season_number: string;
|
||||
episodes?: any[];
|
||||
isLoading?: boolean;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying series/episodes for Stalker portal content.
|
||||
* Supports two modes:
|
||||
* Supports three modes:
|
||||
* 1. Regular series (type=series): Fetches seasons from API via serialSeasonsResource
|
||||
* 2. VOD with embedded series (vclub): Uses the series array from the vodWithSeries input
|
||||
* 3. VOD series (Ministra is_series=1): Fetches seasons/episodes using movie_id and season_id
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-stalker-series-view',
|
||||
templateUrl: './stalker-series-view.component.html',
|
||||
styleUrls: ['../../xtream/detail-view.scss'],
|
||||
imports: [FavoritesButtonComponent, MatButton, MatDivider, TranslatePipe],
|
||||
imports: [
|
||||
FavoritesButtonComponent,
|
||||
MatButton,
|
||||
MatDivider,
|
||||
MatProgressSpinner,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class StalkerSeriesViewComponent {
|
||||
readonly stalkerStore = inject(StalkerStore);
|
||||
@@ -28,21 +56,85 @@ export class StalkerSeriesViewComponent {
|
||||
|
||||
readonly selectedItem = this.stalkerStore.selectedItem;
|
||||
|
||||
/**
|
||||
* Track VOD series seasons with their loaded episodes
|
||||
*/
|
||||
readonly vodSeriesSeasons = signal<VodSeriesSeason[]>([]);
|
||||
|
||||
/**
|
||||
* Indicates if this is a VOD series item (Ministra is_series=1)
|
||||
*/
|
||||
readonly isVodSeries = computed(() => {
|
||||
const item = this.displayItem();
|
||||
return item?.is_series === true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Loading state for VOD series seasons
|
||||
*/
|
||||
readonly isVodSeriesSeasonsLoading =
|
||||
this.stalkerStore.isVodSeriesSeasonsLoading;
|
||||
|
||||
/**
|
||||
* Loading state for regular series seasons
|
||||
*/
|
||||
readonly isSerialSeasonsLoading = this.stalkerStore.isSerialSeasonsLoading;
|
||||
|
||||
constructor() {
|
||||
// Effect to load VOD series seasons when a VOD series item is selected
|
||||
effect(() => {
|
||||
const item = this.displayItem();
|
||||
if (item?.is_series === true) {
|
||||
// Get seasons from the resource
|
||||
const seasons = this.stalkerStore.getVodSeriesSeasonsResource();
|
||||
if (seasons && seasons.length > 0) {
|
||||
// Map seasons to our VodSeriesSeason structure
|
||||
const mappedSeasons: VodSeriesSeason[] = seasons.map(
|
||||
(season: any) => ({
|
||||
id: season.id,
|
||||
video_id: season.video_id,
|
||||
name:
|
||||
season.name || `Season ${season.season_number}`,
|
||||
season_number: season.season_number,
|
||||
episodes: [],
|
||||
isLoading: false,
|
||||
isExpanded: false,
|
||||
})
|
||||
);
|
||||
this.vodSeriesSeasons.set(mappedSeasons);
|
||||
}
|
||||
} else {
|
||||
this.vodSeriesSeasons.set([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* For VOD with embedded series, we create a single "season" with the episodes
|
||||
* For regular series, we use the API-fetched seasons
|
||||
* For VOD series (is_series=1), we use the fetched seasons with dynamic episode loading
|
||||
*/
|
||||
readonly seasonsData = computed(() => {
|
||||
const vodItem = this.vodWithSeries();
|
||||
if (vodItem?.series?.length > 0) {
|
||||
// VOD with embedded series - create a pseudo-season structure
|
||||
return [{
|
||||
id: vodItem.id,
|
||||
name: vodItem.info?.name || 'Episodes',
|
||||
cmd: vodItem.cmd,
|
||||
series: vodItem.series,
|
||||
}];
|
||||
return [
|
||||
{
|
||||
id: vodItem.id,
|
||||
name: vodItem.info?.name || 'Episodes',
|
||||
cmd: vodItem.cmd,
|
||||
series: vodItem.series,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Check if this is a VOD series (is_series=1)
|
||||
const displayItem = this.displayItem();
|
||||
if (displayItem?.is_series === true) {
|
||||
// Return VOD series seasons (episodes loaded dynamically)
|
||||
return this.vodSeriesSeasons();
|
||||
}
|
||||
|
||||
// Regular series - use API-fetched seasons
|
||||
return this.stalkerStore.getSerialSeasonsResource();
|
||||
});
|
||||
@@ -54,6 +146,73 @@ export class StalkerSeriesViewComponent {
|
||||
return this.vodWithSeries() || this.selectedItem();
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle season expansion and load episodes if needed (for VOD series)
|
||||
*/
|
||||
async toggleSeasonExpanded(season: VodSeriesSeason) {
|
||||
const seasons = this.vodSeriesSeasons();
|
||||
const index = seasons.findIndex((s) => s.id === season.id);
|
||||
if (index === -1) return;
|
||||
|
||||
const updatedSeasons = [...seasons];
|
||||
const currentSeason = { ...updatedSeasons[index] };
|
||||
|
||||
// Toggle expansion
|
||||
currentSeason.isExpanded = !currentSeason.isExpanded;
|
||||
|
||||
// Load episodes if expanding and not already loaded
|
||||
if (
|
||||
currentSeason.isExpanded &&
|
||||
(!currentSeason.episodes || currentSeason.episodes.length === 0)
|
||||
) {
|
||||
currentSeason.isLoading = true;
|
||||
updatedSeasons[index] = currentSeason;
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
|
||||
try {
|
||||
const episodes = await this.stalkerStore.fetchVodSeriesEpisodes(
|
||||
currentSeason.video_id,
|
||||
currentSeason.id
|
||||
);
|
||||
// Update season with loaded episodes
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const seasonIndex = newSeasons.findIndex(
|
||||
(s) => s.id === season.id
|
||||
);
|
||||
if (seasonIndex !== -1) {
|
||||
newSeasons[seasonIndex] = {
|
||||
...newSeasons[seasonIndex],
|
||||
episodes: episodes,
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[StalkerSeriesViewComponent] Failed to load episodes:',
|
||||
error
|
||||
);
|
||||
const newSeasons = [...this.vodSeriesSeasons()];
|
||||
const seasonIndex = newSeasons.findIndex(
|
||||
(s) => s.id === season.id
|
||||
);
|
||||
if (seasonIndex !== -1) {
|
||||
newSeasons[seasonIndex] = {
|
||||
...newSeasons[seasonIndex],
|
||||
isLoading: false,
|
||||
};
|
||||
this.vodSeriesSeasons.set(newSeasons);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedSeasons[index] = currentSeason;
|
||||
this.vodSeriesSeasons.set(updatedSeasons);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play episode - handles both regular series and VOD series
|
||||
*/
|
||||
playEpisodeClicked(episode: any, cmd: string) {
|
||||
const item = this.displayItem();
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
@@ -64,6 +223,23 @@ export class StalkerSeriesViewComponent {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play VOD series episode
|
||||
* For VOD series, we need to use a different cmd format
|
||||
*/
|
||||
playVodSeriesEpisode(episode: any) {
|
||||
const item = this.displayItem();
|
||||
// For VOD series episodes, the cmd is: /media/file_{episode.id}.mpg
|
||||
const cmd = `/media/file_${episode.id}.mpg`;
|
||||
const episodeName = episode.name || `Episode ${episode.series_number}`;
|
||||
|
||||
this.stalkerStore.createLinkToPlayVod(
|
||||
cmd,
|
||||
`${item.info.name} - ${episodeName}`,
|
||||
item.info.movie_image
|
||||
);
|
||||
}
|
||||
|
||||
addToFavorites(item: any) {
|
||||
const displayItem = this.displayItem();
|
||||
this.stalkerStore.addToFavorites({
|
||||
|
||||
@@ -43,6 +43,10 @@ interface StalkerState {
|
||||
itvCategories: StalkerCategoryItem[];
|
||||
hasMoreChannels: boolean;
|
||||
itvChannels: any[];
|
||||
/** For VOD items that are actually series (Ministra plugin is_series=1) */
|
||||
vodSeriesSeasons: any[];
|
||||
vodSeriesEpisodes: any[];
|
||||
selectedVodSeriesSeasonId: string;
|
||||
}
|
||||
|
||||
const initialState: StalkerState = {
|
||||
@@ -62,6 +66,9 @@ const initialState: StalkerState = {
|
||||
itvCategories: [],
|
||||
hasMoreChannels: false,
|
||||
itvChannels: [],
|
||||
vodSeriesSeasons: [],
|
||||
vodSeriesEpisodes: [],
|
||||
selectedVodSeriesSeasonId: undefined,
|
||||
};
|
||||
|
||||
function extractNumericValue(str: string) {
|
||||
@@ -82,6 +89,18 @@ function sortByNumericValue(array: StalkerSeason[]): StalkerSeason[] {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort episodes by series_number in ascending numeric order (1, 2, 3... not "1", "10", "2")
|
||||
*/
|
||||
function sortEpisodesByNumber(episodes: any[]): any[] {
|
||||
if (!episodes) return [];
|
||||
return episodes.sort((a, b) => {
|
||||
const numA = parseInt(a.series_number, 10) || 0;
|
||||
const numB = parseInt(b.series_number, 10) || 0;
|
||||
return numA - numB;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert relative URLs to absolute URLs using the portal base URL
|
||||
* Handles screenshot_uri and cmd paths that come as relative from the server
|
||||
@@ -412,6 +431,72 @@ export const StalkerStore = signalStore(
|
||||
return sortByNumericValue(response.js.data);
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* Resource to fetch seasons for VOD items that are actually series (is_series=1)
|
||||
* Used for Ministra plugin where VOD items can contain series/seasons
|
||||
*/
|
||||
vodSeriesSeasonsResource: resource({
|
||||
params: () => ({
|
||||
selectedItem: store.selectedItem(),
|
||||
}),
|
||||
loader: async ({ params }) => {
|
||||
const item = params.selectedItem;
|
||||
// Only fetch if item is a VOD series (has is_series flag)
|
||||
if (!store.currentPlaylist() || !item || !item.is_series) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
const queryParams = {
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: item.id,
|
||||
p: '1',
|
||||
};
|
||||
|
||||
console.log(
|
||||
'[StalkerStore] Fetching VOD series seasons for movie_id:',
|
||||
item.id
|
||||
);
|
||||
|
||||
let response: any;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response =
|
||||
await stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(
|
||||
STALKER_REQUEST,
|
||||
{
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params: queryParams,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[StalkerStore] VOD series seasons response:',
|
||||
response
|
||||
);
|
||||
|
||||
if (!response?.js?.data) {
|
||||
console.warn(
|
||||
'[StalkerStore] Invalid VOD series seasons response:',
|
||||
response
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter for season items (is_season: true)
|
||||
const seasons = response.js.data.filter(
|
||||
(item: any) => item.is_season === true
|
||||
);
|
||||
return sortByNumericValue(seasons);
|
||||
},
|
||||
}),
|
||||
})
|
||||
),
|
||||
withComputed((store) => ({
|
||||
@@ -430,6 +515,16 @@ export const StalkerStore = signalStore(
|
||||
getSerialSeasonsResource: computed(() =>
|
||||
store.serialSeasonsResource.value()
|
||||
),
|
||||
isSerialSeasonsLoading: computed(() =>
|
||||
store.serialSeasonsResource.isLoading()
|
||||
),
|
||||
/** VOD series (Ministra plugin is_series=1) */
|
||||
getVodSeriesSeasonsResource: computed(() =>
|
||||
store.vodSeriesSeasonsResource.value()
|
||||
),
|
||||
isVodSeriesSeasonsLoading: computed(() =>
|
||||
store.vodSeriesSeasonsResource.isLoading()
|
||||
),
|
||||
/** category resource */
|
||||
getCategoryResource: computed(() => store.getCategoryResource.value()),
|
||||
isCategoryResourceLoading: computed(() =>
|
||||
@@ -530,6 +625,76 @@ export const StalkerStore = signalStore(
|
||||
setItvChannels(channels: any[]) {
|
||||
patchState(store, { itvChannels: channels });
|
||||
},
|
||||
/**
|
||||
* Fetch episodes for a VOD series season (Ministra plugin)
|
||||
* @param videoId The video_id from the season item
|
||||
* @param seasonId The season id
|
||||
* @returns Array of episode items
|
||||
*/
|
||||
async fetchVodSeriesEpisodes(
|
||||
videoId: string,
|
||||
seasonId: string
|
||||
): Promise<any[]> {
|
||||
const playlist = store.currentPlaylist() as Playlist;
|
||||
if (!playlist) return [];
|
||||
|
||||
const queryParams = {
|
||||
action: StalkerPortalActions.GetOrderedList,
|
||||
type: 'vod',
|
||||
movie_id: videoId,
|
||||
season_id: seasonId,
|
||||
p: '1',
|
||||
};
|
||||
|
||||
console.log(
|
||||
'[StalkerStore] Fetching VOD series episodes for video_id:',
|
||||
videoId,
|
||||
'season_id:',
|
||||
seasonId
|
||||
);
|
||||
|
||||
let response: any;
|
||||
if (playlist.isFullStalkerPortal) {
|
||||
response = await stalkerSession.makeAuthenticatedRequest(
|
||||
playlist,
|
||||
queryParams
|
||||
);
|
||||
} else {
|
||||
response = await dataService.sendIpcEvent(STALKER_REQUEST, {
|
||||
url: playlist.portalUrl,
|
||||
macAddress: playlist.macAddress,
|
||||
params: queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[StalkerStore] VOD series episodes response:',
|
||||
response
|
||||
);
|
||||
|
||||
if (!response?.js?.data) {
|
||||
console.warn(
|
||||
'[StalkerStore] Invalid VOD series episodes response:',
|
||||
response
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Filter for episode items (is_episode: true) and sort by series_number
|
||||
const episodes = sortEpisodesByNumber(
|
||||
response.js.data.filter(
|
||||
(item: any) => item.is_episode === true
|
||||
)
|
||||
);
|
||||
|
||||
// Store episodes and selected season
|
||||
patchState(store, {
|
||||
vodSeriesEpisodes: episodes,
|
||||
selectedVodSeriesSeasonId: seasonId,
|
||||
});
|
||||
|
||||
return episodes;
|
||||
},
|
||||
/** getters */
|
||||
getSelectedCategory() {
|
||||
return signal<string>(store.selectedCategoryId());
|
||||
|
||||
+6
-1
@@ -4,6 +4,9 @@
|
||||
@if (selectedItem().series?.length > 0) {
|
||||
<!-- VOD item with embedded series (vclub) -->
|
||||
<app-stalker-series-view [vodWithSeries]="selectedItem()" />
|
||||
} @else if (selectedItem().is_series) {
|
||||
<!-- VOD item that is actually a series (Ministra plugin is_series=1) -->
|
||||
<app-stalker-series-view />
|
||||
} @else {
|
||||
<app-vod-details
|
||||
[item]="selectedItem()"
|
||||
@@ -15,7 +18,9 @@
|
||||
)
|
||||
"
|
||||
(addToFavoritesClicked)="addToFavorites($event)"
|
||||
(removeFromFavoritesClicked)="removeFromFavorites($any($event))"
|
||||
(removeFromFavoritesClicked)="
|
||||
removeFromFavorites($any($event))
|
||||
"
|
||||
/>
|
||||
}
|
||||
} @else if (contentType === 'series') {
|
||||
|
||||
+12
-1
@@ -48,7 +48,10 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
const { categoryId } = this.activatedRoute.snapshot.params;
|
||||
// Only set category if it's different from the currently selected one
|
||||
// This preserves the page state when navigating back from detail view
|
||||
if (categoryId && this.store.selectedCategoryId() !== Number(categoryId)) {
|
||||
if (
|
||||
categoryId &&
|
||||
this.store.selectedCategoryId() !== Number(categoryId)
|
||||
) {
|
||||
this.store.setSelectedCategory(categoryId);
|
||||
}
|
||||
}
|
||||
@@ -67,6 +70,14 @@ export class CategoryContentViewComponent implements OnInit {
|
||||
series: item.series,
|
||||
// Preserve has_files for cmd transformation during playback
|
||||
has_files: item.has_files,
|
||||
// Flag for VOD items that are actually series (Ministra plugin)
|
||||
// is_series can be "1" (string) or 1 (number)
|
||||
// ONLY set this for VOD content type - regular series should use the standard series flow
|
||||
is_series:
|
||||
this.contentType === 'vod' &&
|
||||
(item.is_series === '1' || item.is_series === 1),
|
||||
// Store video_id for season fetching if available
|
||||
video_id: item.video_id,
|
||||
info: {
|
||||
movie_image: item.screenshot_uri,
|
||||
description: item.description,
|
||||
|
||||
@@ -42,6 +42,40 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.season-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 10px 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.seasons-loading,
|
||||
.episodes-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.no-episodes {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
|
||||
@@ -413,7 +413,9 @@
|
||||
"SELECT_CATEGORY": "Select a category",
|
||||
"PAGE": "Page",
|
||||
"CONTENT_NOT_AVAILABLE": "This content is not available on this server",
|
||||
"PLAYBACK_ERROR": "Failed to get playback URL"
|
||||
"PLAYBACK_ERROR": "Failed to get playback URL",
|
||||
"LOADING_SEASONS": "Loading seasons...",
|
||||
"NO_EPISODES": "No episodes available"
|
||||
},
|
||||
"INFORMATION": "Information",
|
||||
"REMOTE_CONTROL": {
|
||||
|
||||
Reference in new issue
Block a user