mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
fix(downloads): use canonical Stalker episode ids
This commit is contained in:
1 parent
73ea21b7c7
commit
2500b9015b
5 files changed
+562
-171
No files matched your search
+357
@@ -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();
|
||||
}
|
||||
);
|
||||
});
|
||||
+140
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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)
|
||||
"
|
||||
|
||||
+42
-42
@@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
+22
-128
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user