From 2500b9015bfb0a6e73aa3841c945548271ce404f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 12:02:44 +0200 Subject: [PATCH] fix(downloads): use canonical Stalker episode ids --- .../stalker-series-download.adapter.spec.ts | 357 ++++++++++++++++++ .../stalker-series-download.adapter.ts | 140 +++++++ .../stalker-series-view.component.html | 2 +- .../stalker-series-view.component.spec.ts | 84 ++--- .../stalker-series-view.component.ts | 150 ++------ 5 files changed, 562 insertions(+), 171 deletions(-) create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts new file mode 100644 index 000000000..438f6143c --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.spec.ts @@ -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 & { + 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(); + } + ); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts new file mode 100644 index 000000000..2a816b3cd --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-download.adapter.ts @@ -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; +} + +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, + }; + }, + }; + }, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 0256b9952..1542d6c88 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -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) " diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index f256c1bd3..be763f4b2 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -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(null); readonly isLoading = input(false); readonly downloadsEnabled = input(true); + readonly downloadAdapter = input(null); readonly seasonSelected = output(); readonly episodeClicked = output(); - readonly episodeDownloadRequested = output(); readonly playbackToggleRequested = output(); readonly selectedSeason = signal(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, + }), }), }) ); diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 319968b54..46f23a4df 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -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(); @@ -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 where string is season number/name. @@ -1072,81 +1090,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } - async downloadEpisode(episode: XtreamSerieEpisode): Promise { - 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 { @@ -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); - } }