fix(downloads): use canonical Stalker episode ids

This commit is contained in:
4gray committed 2026-08-02 12:02:44 +02:00
1 parent 73ea21b7c7
commit 2500b9015b
5 files changed
+562 -171

No files matched your search

@@ -0,0 +1,357 @@
import type { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { createStalkerSeriesDownloadAdapter } from './stalker-series-download.adapter';
const EPISODE_POSTER_URL = 'https://images.example.test/stills/the-call.jpg';
const SERIES_POSTER_URL =
'https://images.example.test/posters/signal-house.jpg';
const PLAYLIST = {
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
userAgent: 'Stalker Client/1.0',
referrer: 'https://stalker.example.test/c/',
origin: 'https://stalker.example.test',
} as const;
const ITEM = {
id: '50001',
cmd: '/media/file_50001.mpg',
category_id: '18',
info: {
name: 'Signal House',
o_name: 'La casa de la señal',
description: 'Parent series plot',
movie_image: SERIES_POSTER_URL,
actors: 'Sienna Wave',
director: 'Cora Bell',
releasedate: '2025-03-01',
genre: 'Drama, Mystery',
rating_imdb: '8.4',
rating_kinopoisk: '8.0',
tmdb_id: 88001,
tmdb_status: 'returning-series',
},
} as const;
function episode(
overrides: Partial<XtreamSerieEpisode> & {
originalCmd?: string;
originalId?: string;
} = {}
): XtreamSerieEpisode {
return {
id: '61001',
episode_num: 3,
title: 'The Call',
container_extension: 'mpg',
info: {
plot: 'Episode-specific plot',
movie_image: EPISODE_POSTER_URL,
},
custom_sid: 'regular-series',
added: '',
season: 2,
direct_source: '',
originalCmd: '/media/file_777.mpg',
...overrides,
} as XtreamSerieEpisode;
}
function options(
resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg')
) {
return {
playlist: PLAYLIST,
item: ITEM,
language: 'en',
seriesId: 50001,
resolveUrl,
};
}
describe('createStalkerSeriesDownloadAdapter', () => {
it('keeps regular-series candidate identity distinct when playback ownership collides', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const adapter = createStalkerSeriesDownloadAdapter(options(resolveUrl));
const first = adapter.createCandidate(episode({ id: '61001' }), '2');
const second = adapter.createCandidate(episode({ id: '61002' }), '2');
expect(first?.identity.xtreamId).toBe(61001);
expect(second?.identity.xtreamId).toBe(61002);
expect(first?.identity).not.toEqual(second?.identity);
await expect(first?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 61001 })
);
await expect(second?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 61002 })
);
expect(resolveUrl).toHaveBeenNthCalledWith(1, '/media/file_777.mpg', 3);
expect(resolveUrl).toHaveBeenNthCalledWith(2, '/media/file_777.mpg', 3);
});
it('keeps VOD-series candidate identity distinct when originalId ownership collides', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const adapter = createStalkerSeriesDownloadAdapter(options(resolveUrl));
const first = adapter.createCandidate(
episode({
id: '62001',
custom_sid: 'vod-series',
originalCmd: undefined,
originalId: '777',
}),
'2'
);
const second = adapter.createCandidate(
episode({
id: '62002',
custom_sid: 'vod-series',
originalCmd: undefined,
originalId: '777',
}),
'2'
);
expect(first?.identity.xtreamId).toBe(62001);
expect(second?.identity.xtreamId).toBe(62002);
expect(first?.identity).not.toEqual(second?.identity);
await expect(first?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 62001 })
);
await expect(second?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 62002 })
);
expect(resolveUrl).toHaveBeenNthCalledWith(1, '/media/file_777.mpg', 3);
expect(resolveUrl).toHaveBeenNthCalledWith(2, '/media/file_777.mpg', 3);
});
it('prepares the complete Stalker episode request and versioned snapshot', async () => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const candidate = createStalkerSeriesDownloadAdapter(
options(resolveUrl)
).createCandidate(episode(), '1');
expect(candidate?.identity).toEqual({
playlistId: 'stalker-1',
contentType: 'episode',
xtreamId: 61001,
seriesXtreamId: 50001,
seasonNumber: 2,
episodeNumber: 3,
});
if (!candidate) {
throw new Error('expected a valid Stalker episode candidate');
}
const request = await candidate.prepare();
const { metadataSnapshot, ...requestWithoutSnapshot } = request;
expect(requestWithoutSnapshot).toEqual({
playlistId: 'stalker-1',
contentType: 'episode',
xtreamId: 61001,
seriesXtreamId: 50001,
seasonNumber: 2,
episodeNumber: 3,
title: 'Signal House - S02E03 - The Call',
url: 'https://cdn.example.test/episode.mpg',
posterUrl: EPISODE_POSTER_URL,
headers: {
userAgent: 'Stalker Client/1.0',
referer: 'https://stalker.example.test/c/',
origin: 'https://stalker.example.test',
},
playlistName: 'Living Room Portal',
playlistType: 'stalker',
portalUrl: 'https://stalker.example.test/stalker_portal/c/',
macAddress: '00:1A:79:12:34:56',
});
expect(metadataSnapshot).toEqual({
version: 1,
language: 'en',
mediaKind: 'series',
title: 'Signal House',
originalTitle: 'La casa de la señal',
plot: 'Parent series plot',
releaseDate: '2025-03-01',
year: 2025,
genres: ['Drama', 'Mystery'],
rating: 8.4,
status: 'returning-series',
posterUrl: SERIES_POSTER_URL,
tmdbId: 88001,
providerCategoryId: '18',
cast: [{ name: 'Sienna Wave' }],
creators: [{ name: 'Cora Bell' }],
episode: {
seasonNumber: 2,
episodeNumber: 3,
title: 'The Call',
plot: 'Episode-specific plot',
stillUrl: EPISODE_POSTER_URL,
},
enrichedAt: expect.any(String),
});
expect(resolveUrl).toHaveBeenCalledWith('/media/file_777.mpg', 3);
});
it.each([
['regular series', 'regular-series', '/media/file_301.mpg', undefined],
[
'embedded VOD series',
'regular-series',
'/media/file_401.mpg',
undefined,
],
['lazy VOD series', 'vod-series', undefined, '501'],
] as const)(
'preserves %s playback ownership solely for URL resolution',
async (_label, customSid, originalCmd, originalId) => {
const resolveUrl = jest
.fn()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
const candidate = createStalkerSeriesDownloadAdapter(
options(resolveUrl)
).createCandidate(
episode({
id: '63001',
custom_sid: customSid,
originalCmd,
originalId,
episode_num: 7,
}),
'4'
);
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({ xtreamId: 63001 })
);
expect(resolveUrl).toHaveBeenCalledWith(
customSid === 'vod-series'
? '/media/file_501.mpg'
: originalCmd,
7
);
}
);
it('preserves the season-key and episode-number fallback rules', () => {
const adapter = createStalkerSeriesDownloadAdapter(options());
expect(
adapter.createCandidate(episode({ season: 0, episode_num: 0 }), '4')
?.identity
).toEqual(
expect.objectContaining({ seasonNumber: 4, episodeNumber: 1 })
);
expect(
adapter.createCandidate(
episode({ season: 0, episode_num: Number.NaN }),
undefined
)?.identity
).toEqual(
expect.objectContaining({ seasonNumber: 1, episodeNumber: 1 })
);
});
it('uses the Stalker Portal title fallback', async () => {
const candidate = createStalkerSeriesDownloadAdapter({
...options(),
playlist: { ...PLAYLIST, title: '' },
}).createCandidate(episode(), '2');
await expect(candidate?.prepare()).resolves.toEqual(
expect.objectContaining({ playlistName: 'Stalker Portal' })
);
});
it('rejects preparation when URL resolution returns an empty URL', async () => {
const candidate = createStalkerSeriesDownloadAdapter(
options(jest.fn().mockResolvedValue(' '))
).createCandidate(episode(), '2');
await expect(candidate?.prepare()).rejects.toThrow(Error);
});
it('propagates a rejected URL resolver promise', async () => {
const providerFailure = new Error('provider failure');
const candidate = createStalkerSeriesDownloadAdapter(
options(jest.fn().mockRejectedValue(providerFailure))
).createCandidate(episode(), '2');
await expect(candidate?.prepare()).rejects.toBe(providerFailure);
});
it.each([
['missing playlist', { playlist: null }, {}, '2'],
[
'missing playlist id',
{ playlist: { ...PLAYLIST, _id: '' } },
{},
'2',
],
[
'missing portal URL',
{ playlist: { ...PLAYLIST, portalUrl: undefined } },
{},
'2',
],
[
'missing MAC address',
{ playlist: { ...PLAYLIST, macAddress: '' } },
{},
'2',
],
['missing item', { item: null }, {}, '2'],
['missing language', { language: ' ' }, {}, '2'],
[
'unsafe episode id',
{},
{ id: String(Number.MAX_SAFE_INTEGER + 1) },
'2',
],
['non-numeric episode id', {}, { id: 'episode-1' }, '2'],
['non-positive episode id', {}, { id: '0' }, '2'],
[
'unsafe series id',
{ seriesId: Number.MAX_SAFE_INTEGER + 1 },
{},
'2',
],
['non-positive series id', { seriesId: 0 }, {}, '2'],
['unsafe season', {}, { season: 1.5 }, '2'],
['non-positive season', {}, { season: -1 }, '2'],
['unsafe episode number', {}, { episode_num: 1.5 }, '2'],
['non-positive episode number', {}, { episode_num: -1 }, '2'],
['non-positive fallback season', {}, { season: 0 }, '-2'],
[
'unsafe fallback season',
{},
{ season: 0 },
String(Number.MAX_SAFE_INTEGER + 1),
],
] as const)(
'returns null for %s',
(_label, optionOverrides, episodeOverrides, fallbackSeasonKey) => {
const adapter = createStalkerSeriesDownloadAdapter({
...options(),
...optionOverrides,
} as never);
expect(
adapter.createCandidate(
episode(episodeOverrides),
fallbackSeasonKey
)
).toBeNull();
}
);
});
@@ -0,0 +1,140 @@
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import type {
StalkerMappedEpisode,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
import type {
Playlist,
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata';
type StalkerDownloadPlaylist = Pick<
Playlist,
| '_id'
| 'title'
| 'portalUrl'
| 'macAddress'
| 'userAgent'
| 'referrer'
| 'origin'
>;
export interface StalkerSeriesDownloadAdapterOptions {
readonly playlist: StalkerDownloadPlaylist | null | undefined;
readonly item: StalkerSelectedVodItem | null | undefined;
readonly language: string;
readonly seriesId: number;
readonly resolveUrl: (
command: string,
episodeNumber: number
) => Promise<string>;
}
function isPresent(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function positiveSafeInteger(value: unknown): number | null {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function episodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
return Array.isArray(episode.info) ? undefined : episode.info;
}
function playbackCommand(episode: XtreamSerieEpisode): string {
const mappedEpisode = episode as StalkerMappedEpisode;
return mappedEpisode.custom_sid === 'vod-series'
? `/media/file_${mappedEpisode.originalId}.mpg`
: (mappedEpisode.originalCmd ?? '');
}
export function createStalkerSeriesDownloadAdapter(
options: StalkerSeriesDownloadAdapterOptions
): SeasonEpisodeDownloadAdapter {
return {
createCandidate(episode, fallbackSeasonKey) {
const playlist = options.playlist;
const item = options.item;
const xtreamId = positiveSafeInteger(episode.id);
const seriesXtreamId = positiveSafeInteger(options.seriesId);
const seasonNumber = positiveSafeInteger(
episode.season || Number(fallbackSeasonKey) || 1
);
const episodeNumber = positiveSafeInteger(episode.episode_num || 1);
if (
!playlist ||
!item ||
!isPresent(playlist._id) ||
!isPresent(playlist.portalUrl) ||
!isPresent(playlist.macAddress) ||
!isPresent(options.language) ||
xtreamId === null ||
seriesXtreamId === null ||
seasonNumber === null ||
episodeNumber === null
) {
return null;
}
const identity = {
playlistId: playlist._id,
contentType: 'episode' as const,
xtreamId,
seriesXtreamId,
seasonNumber,
episodeNumber,
};
return {
identity,
prepare: async () => {
const url = await options.resolveUrl(
playbackCommand(episode),
episodeNumber
);
if (!isPresent(url)) {
throw new Error('Unable to prepare episode download');
}
const info = episodeInfo(episode);
const seriesTitle = item.info?.name || 'Series';
const episodeCode = `S${String(seasonNumber).padStart(
2,
'0'
)}E${String(episodeNumber).padStart(2, '0')}`;
return {
...identity,
title: `${seriesTitle} - ${episodeCode} - ${episode.title}`,
url,
posterUrl: info?.movie_image,
metadataSnapshot: createStalkerSeriesDownloadSnapshot({
item,
episode,
language: options.language,
seriesTitle,
seasonNumber,
episodeNumber,
}),
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
};
},
};
},
};
}
@@ -202,13 +202,13 @@
"
[seasonDescriptions]="seasonDescriptions()"
[downloadsEnabled]="!providerOnly()"
[downloadAdapter]="episodeDownloadAdapter()"
[isLoading]="
isVodSeriesSeasonsLoading() ||
isCurrentSeasonLoading(seasonContainer?.selectedSeason())
"
(seasonSelected)="onSeasonSelected($event)"
(episodeClicked)="onEpisodeClicked($event)"
(episodeDownloadRequested)="downloadEpisode($event)"
(playbackToggleRequested)="
handlePlaybackToggleRequestedFromUi($event)
"
@@ -6,6 +6,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { SeasonContainerComponent } from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
@@ -17,7 +18,7 @@ import {
} from '@iptvnator/portal/stalker/data-access';
import { PlaybackPositionData } from '@iptvnator/shared/interfaces';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { DownloadsService, TmdbEnrichmentService } from '@iptvnator/services';
import { TmdbEnrichmentService } from '@iptvnator/services';
import { EMPTY, of } from 'rxjs';
import { FavoritesButtonComponent } from '../stalker-favorites-button/stalker-favorites-button.component';
import { StalkerSeriesViewComponent } from './stalker-series-view.component';
@@ -39,9 +40,9 @@ class StubSeasonContainerComponent {
readonly seasonDescriptions = input<unknown>(null);
readonly isLoading = input(false);
readonly downloadsEnabled = input(true);
readonly downloadAdapter = input<SeasonEpisodeDownloadAdapter | null>(null);
readonly seasonSelected = output<string>();
readonly episodeClicked = output<unknown>();
readonly episodeDownloadRequested = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
readonly selectedSeason = signal<string | undefined>(undefined);
}
@@ -90,10 +91,10 @@ describe('StalkerSeriesViewComponent', () => {
const openResolvedPlayback = jest.fn();
const isEmbeddedPlayer = jest.fn();
const tmdbGetSeason = jest.fn();
const startDownload = jest.fn();
const fetchLinkToPlay = jest.fn();
const currentPlaylist = signal({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
@@ -155,12 +156,12 @@ describe('StalkerSeriesViewComponent', () => {
overview: 'Season overview from TMDB',
episodes: [],
});
startDownload.mockReset().mockResolvedValue({ success: true });
fetchLinkToPlay
.mockReset()
.mockResolvedValue('https://cdn.example.test/episode.mpg');
currentPlaylist.set({
_id: 'stalker-1',
title: 'Living Room Portal',
portalUrl: 'https://stalker.example.test',
macAddress: '00:1A:79:12:34:56',
});
@@ -212,12 +213,6 @@ describe('StalkerSeriesViewComponent', () => {
navigateByUrl: jest.fn(),
},
},
{
provide: DownloadsService,
useValue: {
startDownload,
},
},
{
provide: TmdbEnrichmentService,
useValue: {
@@ -346,7 +341,7 @@ describe('StalkerSeriesViewComponent', () => {
).not.toBeNull();
});
it('captures enriched parent and episode metadata when an episode download starts', async () => {
it('binds a Stalker adapter that prepares canonical episode metadata', async () => {
selectedContentType.set('vod');
selectedItem.set({
id: '50001',
@@ -357,54 +352,59 @@ describe('StalkerSeriesViewComponent', () => {
description: 'Parent series plot',
movie_image:
'https://images.example.test/posters/signal-house.jpg',
actors: 'Sienna Wave',
director: 'Cora Bell',
tmdb_cast: [],
tmdb_directors: [],
},
});
fixture.detectChanges();
await fixture.whenStable();
await fixture.componentInstance.downloadEpisode({
episode_num: '0',
title: 'The Call',
info: {
plot: 'Episode-specific plot',
movie_image: 'https://images.example.test/stills/the-call.jpg',
},
custom_sid: 'vod-series',
season: '0',
originalId: '502',
} as never);
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
).componentInstance as StubSeasonContainerComponent;
const candidate = seasonContainer.downloadAdapter()?.createCandidate(
{
id: '61001',
episode_num: 3,
title: 'The Call',
custom_sid: 'vod-series',
season: 2,
originalId: '502',
} as never,
'2'
);
expect(startDownload).toHaveBeenCalledWith(
if (!candidate) {
throw new Error('expected a bound Stalker download adapter');
}
const request = await candidate.prepare();
expect(fetchLinkToPlay).toHaveBeenCalledWith(
'https://stalker.example.test',
'00:1A:79:12:34:56',
'/media/file_502.mpg',
3
);
expect(request).toEqual(
expect.objectContaining({
playlistId: 'stalker-1',
playlistType: 'stalker',
seriesXtreamId: 50001,
seasonNumber: 0,
episodeNumber: '0',
title: 'Signal House - S00E00 - The Call',
xtreamId: 61001,
seasonNumber: 2,
episodeNumber: 3,
title: 'Signal House - S02E03 - The Call',
url: 'https://cdn.example.test/episode.mpg',
playlistName: 'Living Room Portal',
metadataSnapshot: expect.objectContaining({
language: 'en',
mediaKind: 'series',
title: 'Signal House',
plot: 'Parent series plot',
posterUrl:
'https://images.example.test/posters/signal-house.jpg',
providerCategoryId: '18',
cast: [{ name: 'Sienna Wave' }],
creators: [{ name: 'Cora Bell' }],
episode: {
seasonNumber: 0,
episodeNumber: 0,
title: 'The Call',
plot: 'Episode-specific plot',
stillUrl:
'https://images.example.test/stills/the-call.jpg',
},
episode: expect.objectContaining({
seasonNumber: 2,
episodeNumber: 3,
}),
}),
})
);
@@ -29,7 +29,6 @@ import {
seriesStatusLabelKey,
TmdbEnrichedCastMember,
XtreamSerieEpisode,
type XtreamSerieEpisodeInfo,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import { SafePipe } from '@iptvnator/pipes';
@@ -69,7 +68,6 @@ import {
import {
CrossPortalSimilarItem,
CrossPortalSimilarService,
DownloadsService,
PlaybackPositionRuntimeBridgeService,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
@@ -83,7 +81,7 @@ import {
saveStalkerSeriesPosition,
StalkerSeriesPositionPartialSaveError,
} from './stalker-series-position-compatibility';
import { createStalkerSeriesDownloadSnapshot } from './stalker-series-download-metadata';
import { createStalkerSeriesDownloadAdapter } from './stalker-series-download.adapter';
interface SeriesPositionContext {
readonly generation: number;
@@ -188,7 +186,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
private readonly playbackPositionBridge = inject(
PlaybackPositionRuntimeBridgeService
);
private readonly downloadsService = inject(DownloadsService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
readonly backClicked = output<void>();
@@ -534,6 +531,27 @@ export class StalkerSeriesViewComponent implements OnDestroy {
youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer)
);
readonly episodeDownloadAdapter = computed(() => {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
return createStalkerSeriesDownloadAdapter({
playlist,
item,
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
seriesId: this.toSeriesId(item?.id ?? 0),
resolveUrl: (command, episodeNumber) =>
this.stalkerStore.fetchLinkToPlay(
playlist?.portalUrl ?? '',
playlist?.macAddress ?? '',
command,
episodeNumber
),
});
});
/**
* Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent.
* Record<string, XtreamSerieEpisode[]> where string is season number/name.
@@ -1072,81 +1090,6 @@ export class StalkerSeriesViewComponent implements OnDestroy {
);
}
async downloadEpisode(episode: XtreamSerieEpisode): Promise<void> {
const playlist = this.stalkerStore.currentPlaylist();
const item = this.displayItem();
if (!playlist || !playlist.portalUrl || !playlist.macAddress || !item) {
return;
}
const customSid = (episode as { custom_sid?: string }).custom_sid;
const cmd =
customSid === 'vod-series'
? `/media/file_${(episode as { originalId?: string }).originalId}.mpg`
: ((episode as { originalCmd?: string }).originalCmd ?? '');
let url: string;
try {
url = await this.stalkerStore.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmd,
episode.episode_num
);
if (!url) {
this.logger.error('Failed to resolve Stalker stream URL');
return;
}
} catch (error) {
this.logger.error('Error resolving Stalker stream URL', error);
return;
}
const episodeInfo = this.getEpisodeInfo(episode);
const posterUrl = episodeInfo?.movie_image;
const seasonNum = Number(episode.season || 1);
const episodeNum = episode.episode_num || 1;
const seriesTitle =
item.info?.name || this.displayItem()?.info?.name || 'Series';
const episodeTitle = `${seriesTitle} - S${String(seasonNum).padStart(
2,
'0'
)}E${String(episodeNum).padStart(2, '0')} - ${episode.title}`;
await this.downloadsService.startDownload({
playlistId: playlist._id,
xtreamId: this.getEpisodeDownloadId(episode),
contentType: 'episode',
title: episodeTitle,
url,
posterUrl,
metadataSnapshot: createStalkerSeriesDownloadSnapshot({
item,
episode,
language:
this.translateService.currentLang ||
this.translateService.defaultLang ||
'en',
seriesTitle,
seasonNumber: seasonNum,
episodeNumber: episodeNum,
}),
seriesXtreamId: this.toSeriesId(item.id),
seasonNumber: seasonNum,
episodeNumber: episodeNum,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referrer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
private async loadSeriesPositions(
context: SeriesPositionContext
): Promise<void> {
@@ -1508,53 +1451,4 @@ export class StalkerSeriesViewComponent implements OnDestroy {
updated.delete(contentXtreamId);
this.episodePlaybackPositions.set(updated);
}
private getEpisodeInfo(
episode: XtreamSerieEpisode
): XtreamSerieEpisodeInfo | undefined {
if (!episode.info || Array.isArray(episode.info)) {
return undefined;
}
return episode.info;
}
private getEpisodeDownloadId(episode: XtreamSerieEpisode): number {
const customSid = (episode as { custom_sid?: string }).custom_sid;
if (customSid === 'regular-series') {
const cmd = (episode as { originalCmd?: string }).originalCmd;
if (cmd) {
const match = cmd.match(/file_(\d+)/);
if (match) {
return Number(match[1]);
}
return this.hashString(cmd);
}
return Number(episode.id);
}
if (customSid === 'vod-series') {
const originalId = (episode as { originalId?: string | number })
.originalId;
const numericId = Number(originalId);
return Number.isNaN(numericId)
? this.hashString(String(originalId))
: numericId;
}
const numericId = Number(episode.id);
return Number.isNaN(numericId)
? this.hashString(String(episode.id))
: numericId;
}
private hashString(str: string): number {
let hash = 0;
for (let index = 0; index < str.length; index++) {
const char = str.charCodeAt(index);
hash = (hash << 5) - hash + char;
hash &= hash;
}
return Math.abs(hash);
}
}