From a25412ea610d8992f997fa9e5d66ecbc3aca2d0b Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 20:38:09 +0200 Subject: [PATCH] refactor(portals): lift the VOD route's orchestration out of the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The details route had grown to 864 lines — the repository's hard maximum is 400, and while the file predates the rule, a baselined exemption is not a budget to spend. Three component-provided services now hold what the component was accumulating: `VodDetailsMultiSourceUiService` (the playback-evidence latch, the caption, the primary button's position, source actions and the failover toast), `VodDetailsSimilarService` (the rail and its cross-portal lookup), and `VodDetailsDownloadsService`. The component keeps its public API, so the template and the existing specs are untouched. 864 -> 566 lines. The downloads move also fixes a latent bug: `downloadVod` and `playFromLocal` read `route.snapshot.params`, which is stale once the router reuses this component for detail-to-detail navigation (the Similar rail) — so a download started from a film reached that way fetched the previous one. They now read the same route-params signal everything else uses, with a regression test. Also from review: pins are applied on the FRESH-import path too. A new playlist has no content when the archive is read, so its user state is parked and replayed after the import — the merge path I wired first never ran there, and every pin was dropped. A failed pin write now propagates instead of being ignored: the backup entry is reported failed, and the parked state is kept so a transient failure can be retried rather than silently losing the preference. Co-Authored-By: Claude Opus 5 --- docs/architecture/vod-multi-source.md | 8 + ...lectron-xtream-data-source.test-helpers.ts | 15 + .../electron-xtream-data-source.ts | 32 +- ...ctron-xtream-data-source.user-data.spec.ts | 48 ++ .../vod-details-downloads.service.ts | 126 ++++++ .../vod-details-multi-source-ui.service.ts | 314 ++++++++++++++ .../vod-details-route.component.spec.ts | 17 + .../vod-details-route.component.ts | 410 +++--------------- .../vod-details/vod-details-route.harness.ts | 3 +- .../vod-details-similar.service.ts | 108 +++++ .../src/lib/playlist-backup.service.ts | 11 +- ...list-backup.service.xtream-restore.spec.ts | 25 ++ 12 files changed, 761 insertions(+), 356 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 1ffe897df..f68b845aa 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -478,6 +478,14 @@ Two things read it, and they must agree: the "Playing from" caption, and the source row's badge — which reads `Current` when a source is merely selected and `Playing` once one really is. +## Where the route's code lives + +`VodDetailsRouteComponent` is a thin host. The multi-source concerns it grew +live in `VodDetailsMultiSourceUiService` (component-provided): the +`playbackLive` evidence, the caption, the primary button's position, the +source actions, and the failover toast. The "Similar" rail and offline +downloads sit in their own component-provided services beside it. + ## Backup Pins ride along with playlist backup, under the playlist they point at, as the diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 48adfd92d..3c33d89ff 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -2,6 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { DatabaseService, PlaybackPositionService, + VodSourcePinService, XtreamPendingRestoreService, } from '@iptvnator/services'; import { @@ -56,6 +57,13 @@ export function createDbServiceMock() { }; } +export function createVodSourcePinServiceMock() { + return { + listForPlaylist: jest.fn().mockResolvedValue([]), + set: jest.fn().mockResolvedValue(true), + }; +} + export function createPlaybackServiceMock() { return { savePlaybackPosition: jest.fn().mockResolvedValue(undefined), @@ -85,6 +93,7 @@ export interface ElectronXtreamDataSourceHarness { dataSource: ElectronXtreamDataSource; dbService: ReturnType; playbackService: ReturnType; + vodSourcePinService: ReturnType; pendingRestoreService: ReturnType; apiService: ReturnType; } @@ -92,6 +101,7 @@ export interface ElectronXtreamDataSourceHarness { export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness { const dbService = createDbServiceMock(); const playbackService = createPlaybackServiceMock(); + const vodSourcePinService = createVodSourcePinServiceMock(); const pendingRestoreService = createPendingRestoreServiceMock(); const apiService = createApiServiceMock(); @@ -100,6 +110,10 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness ElectronXtreamDataSource, { provide: DatabaseService, useValue: dbService }, { provide: PlaybackPositionService, useValue: playbackService }, + { + provide: VodSourcePinService, + useValue: vodSourcePinService, + }, { provide: XtreamPendingRestoreService, useValue: pendingRestoreService, @@ -112,6 +126,7 @@ export function setupElectronXtreamDataSource(): ElectronXtreamDataSourceHarness dataSource: TestBed.inject(ElectronXtreamDataSource), dbService, playbackService, + vodSourcePinService, pendingRestoreService, apiService, }; diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index a2365a76b..d417cc333 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -4,6 +4,7 @@ import { PlaybackPositionService, XtreamPendingRestoreService, XtreamImportStatus, + VodSourcePinService, } from '@iptvnator/services'; import { PlaybackPositionData, @@ -39,6 +40,7 @@ import { export class ElectronXtreamDataSource implements IXtreamDataSource { private readonly dbService = inject(DatabaseService); private readonly playbackService = inject(PlaybackPositionService); + private readonly vodSourcePinService = inject(VodSourcePinService); private readonly pendingRestoreService = inject( XtreamPendingRestoreService ); @@ -540,9 +542,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { async clearPlaylistContent( playlistId: string ): Promise { - const [result, playbackPositions] = await Promise.all([ + const [result, playbackPositions, sourcePins] = await Promise.all([ this.dbService.deleteXtreamPlaylistContent(playlistId), this.playbackService.getAllPlaybackPositions(playlistId), + this.vodSourcePinService.listForPlaylist(playlistId), ]); return { @@ -550,6 +553,11 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { favorites: result.favorites, recentlyViewed: result.recentlyViewed, playbackPositions, + sourcePins: sourcePins.map((pin) => ({ + matchKey: pin.matchKey, + contentId: pin.contentId, + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + })), }; } @@ -573,5 +581,27 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playbackPosition ); } + + // The fresh-import path lands here rather than in the backup service: + // a new playlist has no content yet when the archive is read, so its + // user state is parked and applied once the import finishes. + for (const pin of restoreState.sourcePins ?? []) { + const written = await this.vodSourcePinService.set({ + matchKey: pin.matchKey, + playlistId, + contentId: pin.contentId, + portalType: 'xtream', + ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), + }); + + // Throwing keeps the pending state for a later retry — the caller + // only clears it when this resolves. Dropping it here would lose + // the preference with the import still reporting success. + if (!written) { + throw new Error( + `Restoring the pinned source for "${pin.matchKey}" failed.` + ); + } + } } } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 1b887d1b4..af578ea28 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -194,9 +194,57 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { favorites, recentlyViewed, playbackPositions: [position], + sourcePins: [], }); }); + it('applies parked source pins against the imported playlist', async () => { + // The fresh-import path: a new playlist has no content when the + // archive is read, so its user state is parked and replayed here. + // Without this the backup's pins are dropped for every new import. + const restoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + sourcePins: [ + { + matchKey: 'tmdb:603', + contentId: 501, + updatedAt: '2026-07-06T09:00:00.000Z', + }, + ], + } as never; + + await harness.dataSource.restoreUserData(playlistId, restoreState); + + expect(harness.vodSourcePinService.set).toHaveBeenCalledWith({ + matchKey: 'tmdb:603', + playlistId, + contentId: 501, + portalType: 'xtream', + updatedAt: '2026-07-06T09:00:00.000Z', + }); + }); + + it('keeps the pending state when a pin cannot be written', async () => { + harness.vodSourcePinService.set.mockResolvedValue(false); + const restoreState = { + hiddenCategories: [], + favorites: [], + recentlyViewed: [], + playbackPositions: [], + sourcePins: [{ matchKey: 'tmdb:603', contentId: 501 }], + } as never; + + // The caller clears the parked state only when this resolves, so + // resolving here would drop the preference on a transient DB + // failure while the import still reported success. + await expect( + harness.dataSource.restoreUserData(playlistId, restoreState) + ).rejects.toThrow('tmdb:603'); + }); + it('restores user data, then resets and replays playback positions', async () => { const positionA = { contentXtreamId: 1 } as never; const positionB = { contentXtreamId: 2 } as never; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts new file mode 100644 index 000000000..fd0eb00a3 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-downloads.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { DownloadsService } from '@iptvnator/services'; +import { + XtreamVodDetails, + getXtreamVodInfo, +} from '@iptvnator/shared/interfaces'; + +/** + * Offline downloads for the movie on screen. + * + * Every read re-touches `downloads()` so the computed tracks the signal the + * service updates — the per-item helpers are plain lookups and would not + * otherwise re-run when a download's state changes. + */ +@Injectable() +export class VodDetailsDownloadsService { + private readonly downloadsService = inject(DownloadsService); + private readonly xtreamStore = inject(XtreamStore); + + private routeContentId: Signal = signal(NaN); + + bind(bindings: { routeContentId: Signal }): void { + this.routeContentId = bindings.routeContentId; + } + + private readonly context = computed(() => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + // Read so the state below re-derives as downloads progress. + this.downloadsService.downloads(); + return playlistId ? { playlistId, vodId: this.routeContentId() } : null; + }); + + readonly isDownloaded = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isDownloaded( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + readonly isDownloading = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isDownloading( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + readonly isPausedDownload = computed(() => { + const context = this.context(); + return context + ? this.downloadsService.isPaused( + context.vodId, + context.playlistId, + 'vod' + ) + : false; + }); + + async resumePaused(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + + await this.downloadsService.resumeDownloadByContent( + this.routeContentId(), + playlistId, + 'vod' + ); + } + + async start(vodItem: XtreamVodDetails | null): Promise { + const playlist = this.xtreamStore.currentPlaylist(); + if (!vodItem || !playlist) { + return; + } + + const info = getXtreamVodInfo(vodItem); + const routeVodId = this.routeContentId(); + const id = Number.isFinite(routeVodId) + ? routeVodId + : Number( + vodItem.movie_data?.stream_id || + (vodItem as { stream_id?: number }).stream_id + ); + + await this.downloadsService.startDownload({ + playlistId: playlist.id, + xtreamId: id, + contentType: 'vod', + title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', + url: this.xtreamStore.constructVodStreamUrl(vodItem), + posterUrl: info?.movie_image, + headers: { + userAgent: playlist.userAgent, + referer: playlist.referrer, + origin: playlist.origin, + }, + }); + } + + async playLocal(): Promise { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + + const filePath = this.downloadsService.getDownloadedFilePath( + this.routeContentId(), + playlistId, + 'vod' + ); + + if (filePath) { + await this.downloadsService.playDownload(filePath); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts new file mode 100644 index 000000000..6c56cc98e --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-multi-source-ui.service.ts @@ -0,0 +1,314 @@ +import { Injectable, Signal, computed, inject, signal } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { PORTAL_PLAYBACK_POSITIONS } from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { SettingsStore } from '@iptvnator/services'; +import { + PlaybackPositionData, + PlayerContentInfo, + playlistDisplayLabel, + reportsPlaybackFailures, + type VodSourceCandidate, + type VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; +import { TranslateService } from '@ngx-translate/core'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodMultiSourceHostService } from './vod-multi-source-host.service'; +import { + createPrimaryActionPosition, + formatPlaybackPosition, +} from './vod-primary-action-position'; + +/** + * The VOD details route's multi-source concerns. + * + * Everything here answers one of two questions: what is TRUE about playback + * right now, and what does the primary button act on. Both are easy to get + * subtly wrong — the controller's "active source" means selected rather than + * playing, and positions are keyed per (playlist, stream) so the row the page + * loaded is not necessarily the one a click will start. + * + * Component-provided, like the host service whose session it reads. + */ +@Injectable() +export class VodDetailsMultiSourceUiService { + private readonly multiSource = inject(VodMultiSourceHostService); + private readonly playback = inject(VodDetailsPlaybackService); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly xtreamStore = inject(XtreamStore); + private readonly settingsStore = inject(SettingsStore); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); + + /** The vod id the route is showing; bound once by the host component. */ + private routeContentId: Signal = signal(NaN); + /** The movie identity, for the sources popover header. */ + private movieTitle: Signal = signal(''); + + bind(bindings: { + routeContentId: Signal; + movieTitle: Signal; + }): void { + this.routeContentId = bindings.routeContentId; + this.movieTitle = bindings.movieTitle; + } + + /** True from a playback failure until something plays again. */ + private readonly playbackFailed = signal(false); + + /** + * Cleared on every start; set by the first timeupdate. + * + * `inlinePlayback()` is the REQUEST to play — it is non-null while the + * engine is still opening the stream, and stays non-null after it fails. + * A timeupdate is the engine reporting that it is producing frames, which + * is the only evidence the page has that something is really playing. + */ + private readonly inlineTimeSeen = signal(false); + + /** + * Whether a stream is on screen right now. + * + * Both the "Playing from" caption and the row's Playing badge are claims + * about the present, and discovery marks a source active the moment the + * page opens — long before anything plays, and again after the player is + * closed. Gating both on this keeps them from lying. + */ + readonly playbackLive = computed(() => { + if (this.playback.inlinePlayback()) { + return !this.playbackFailed() && this.inlineTimeSeen(); + } + + // The external player is a window of its own: once it is open the film + // is on screen, and only 'launching' means it is not there yet. + const status = this.playback.matchedExternalPlayback()?.status; + return status === 'opened' || status === 'playing'; + }); + + /** + * The source playing when it is NOT the route's own. + * + * Both halves of the comparison matter: a pinned copy can live in the + * route's OWN playlist, and matching on playlist alone would call it "the + * route source" — losing the Stop state and every position update for it. + */ + readonly activeAlternativeSource = computed( + () => { + const active = this.multiSource + .sources() + .find((source) => source.isActive); + const routePlaylistId = this.xtreamStore.currentPlaylist()?.id; + + if ( + !active || + (active.playlistId === routePlaylistId && + active.contentId === this.routeContentId()) + ) { + return null; + } + + return { + playlistId: active.playlistId, + contentXtreamId: active.contentId, + contentType: 'vod' as const, + }; + } + ); + + private readonly primaryAction = createPrimaryActionPosition({ + sources: this.multiSource.sources, + routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id), + routeContentId: computed(() => this.routeContentId()), + routePosition: this.playback.routePlaybackPosition, + livePosition: this.playback.vodPlaybackPosition, + load: (source) => this.positionFor(source), + }); + + readonly hasPlaybackPosition = this.primaryAction.hasPosition; + + formatPosition(): string { + return formatPlaybackPosition(this.primaryAction.position()); + } + + /** + * Where a specific source was last watched. + * + * Positions are keyed by (playlist, stream), so a pinned alternative has + * its own row — the one this page loaded belongs to the route's copy. + */ + readonly positionFor = ( + source: VodSourceCandidate | VodSourceDescriptor + ): Promise => + this.playbackPositions.getPlaybackPosition( + source.playlistId, + source.contentId, + 'vod' + ); + + readonly resumeSecondsFor = async ( + source: VodSourceCandidate + ): Promise => + (await this.positionFor(source))?.positionSeconds ?? null; + + /** Title shown in the sources popover header. */ + readonly multiSourceTitle = computed(() => this.movieTitle()); + + /** The ".srcbar" caption under the action row: where this is playing from. */ + readonly activeSourceCaption = computed(() => { + const active = this.multiSource + .sources() + .find((source) => source.isActive); + // "Playing from" only while something actually is. Discovery marks a + // source active as soon as the page opens, so gating on that alone + // makes the line a claim about a player that has not started, one the + // user has since closed, or one that failed and is showing an error. + if (!active || !this.playbackLive()) { + return null; + } + + // Only FACTS reach the caption. A guessed quality would read as a + // claim about the stream the user is watching right now. + const facts = [ + active.quality?.provenance !== 'parsed' + ? active.quality?.value + : null, + active.container?.provenance !== 'parsed' + ? active.container?.value + : null, + ].filter(Boolean); + + return { + // Never the raw playlist name: it is routinely the pasted URL, + // credentials and all, and this line sits in the open on every + // screenshot of the detail page. + source: [ + playlistDisplayLabel(active.playlistName, active.playlistId), + ...facts, + ].join(' · '), + // The caption speaks of PLAYLISTS ("also found in 2 others"), so + // three copies inside one portal must not read as three portals. + alternativeCount: this.multiSource.alternativePlaylistCount(), + }; + }); + + /** + * Only the built-in web players raise a playback diagnostic, so on MPV, + * VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The + * toggle is hidden there rather than left promising a switch that cannot + * happen. + */ + readonly autoFailoverSupported = computed(() => + reportsPlaybackFailures(this.settingsStore.player?.()) + ); + + /** A new stream is starting: nothing yet vouches for it. */ + beginPlayback(): void { + this.playbackFailed.set(false); + this.inlineTimeSeen.set(false); + } + + /** Route reuse — the previous movie's evidence must not carry over. */ + reset(): void { + this.inlineTimeSeen.set(false); + } + + playFromSource(sourceId: string): void { + // Only once the switch actually starts something. A source picked off + // the error screen that cannot be resolved leaves the diagnostic up, + // and clearing eagerly would have the caption claim playback again. + void this.multiSource.play(sourceId).then((switched) => { + if (switched) { + this.playbackFailed.set(false); + } + }); + } + + pinSource(sourceId: string): void { + void this.multiSource.togglePin(sourceId); + } + + checkSource(sourceId: string): void { + void this.multiSource.check(sourceId); + } + + setAutoFailover(enabled: boolean): void { + // `updateSettings` patches memory first and REJECTS if the write + // fails, so without this the toggle looks saved, reverts on restart, + // and the rejection surfaces only as an unhandled promise. + this.settingsStore + .updateSettings({ vodAutoFailover: enabled }) + .catch(() => + this.snackBar.open( + this.translateService.instant( + 'SETTINGS.SETTINGS_SAVE_FAILED' + ), + this.translateService.instant('CLOSE'), + { duration: 10000 } + ) + ); + } + + /** + * A source failed. With auto-failover on we move to the best untried + * source and ANNOUNCE it; otherwise the player's own error overlay — which + * is already showing the alternatives — is left to do its job. + */ + async onPlaybackFailed(): Promise { + // The error screen is up: nothing is playing from anywhere until a + // source actually starts again. + this.playbackFailed.set(true); + const notice = await this.multiSource.failover(); + if (!notice) { + return; + } + + this.snackBar + .open( + this.translateService.instant( + 'PORTALS.MULTI_SOURCE.SWITCHED_TO', + { playlist: notice.playlistName } + ) + + (notice.audioMayDiffer + ? ` — ${this.translateService.instant( + 'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING' + )}` + : ''), + this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'), + { duration: 10000 } + ) + .onAction() + .subscribe(() => this.undoFailover()); + } + + /** Return to the source that was playing before the automatic switch. */ + private undoFailover(): void { + const previousId = this.multiSource.previousSourceId(); + if (previousId) { + void this.multiSource.play(previousId); + } + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + // The engine is producing time, so whatever failed before is over. + this.playbackFailed.set(false); + this.inlineTimeSeen.set(true); + const settled = this.playback.handleInlineTimeUpdate(event); + + // Ahead of the service's 15s persistence throttle, so a source switch + // resumes from where playback actually is rather than up to 15s back. + // + // Until the engine has finished seeking to the resume point it reports + // ~0, and that is not where the film is — it is where it has not got + // to yet. Feeding it to multi-source would make a switch or a failure + // during those first seconds restart the movie from the beginning, so + // the position we asked the engine for stands in until it arrives. + this.multiSource.reportPosition( + settled + ? event.currentTime + : (this.playback.inlinePlayback()?.startTime ?? 0) + ); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index d012d5374..de2b94df7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -163,4 +163,21 @@ describe('VodDetailsRouteComponent', () => { ).componentInstance as ContentHeroComponent; expect(hero.backdropUrl()).toBeUndefined(); }); + + it('downloads the movie the route currently shows', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + fixture.detectChanges(); + + await fixture.componentInstance.downloadVod({ + movie_data: { stream_id: 111, name: 'Example' }, + } as never); + + // The id comes from the route params SIGNAL, not `snapshot.params`: + // the router reuses this component for detail-to-detail navigation + // (the Similar rail), and the snapshot still names the film the user + // came from — so the download would fetch the wrong movie. + expect(stubs.startDownload).toHaveBeenCalledWith( + expect.objectContaining({ xtreamId: 650020 }) + ); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 67707cb5d..deab57b3f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -62,6 +62,9 @@ import { hasUsableXtreamVodMetadata, } from './vod-details-fallback.util'; import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodDetailsMultiSourceUiService } from './vod-details-multi-source-ui.service'; +import { VodDetailsDownloadsService } from './vod-details-downloads.service'; +import { VodDetailsSimilarService } from './vod-details-similar.service'; import { VodMultiSourceHostService } from './vod-multi-source-host.service'; import { resolveVodMultiSourceMovie } from './vod-multi-source-identity'; import { @@ -76,7 +79,13 @@ import { './vod-details-route.component.scss', ], changeDetection: ChangeDetectionStrategy.OnPush, - providers: [VodDetailsPlaybackService, VodMultiSourceHostService], + providers: [ + VodDetailsPlaybackService, + VodMultiSourceHostService, + VodDetailsMultiSourceUiService, + VodDetailsSimilarService, + VodDetailsDownloadsService, + ], imports: [ DetailActionsTemplateDirective, DetailMetaTemplateDirective, @@ -104,6 +113,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); /** Alternative sources for this movie in the user's other playlists */ readonly multiSource = inject(VodMultiSourceHostService); + private readonly msUi = inject(VodDetailsMultiSourceUiService); + private readonly similar = inject(VodDetailsSimilarService); + private readonly downloads = inject(VodDetailsDownloadsService); private readonly logger = createLogger('VodDetailsRoute'); /** `playlistId:vodId` of the last initialized detail view */ private readonly lastInitKey = signal(null); @@ -236,130 +248,41 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.externalPrimaryButtonState; readonly vodPlaybackProgress = this.playback.vodPlaybackProgress; - /** - * What the primary button acts on — the PINNED copy's position when a pin - * names another copy, since that is the stream the button will play. - */ - private readonly primaryAction = createPrimaryActionPosition({ - sources: this.multiSource.sources, - routePlaylistId: computed(() => this.xtreamStore.currentPlaylist()?.id), - routeContentId: this.selectedVodId, - routePosition: this.playback.routePlaybackPosition, - livePosition: this.playback.vodPlaybackPosition, - load: (source) => this.positionFor(source), - }); - readonly hasPlaybackPosition = this.primaryAction.hasPosition; + readonly hasPlaybackPosition = this.msUi.hasPlaybackPosition; - readonly isDownloaded = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isDownloaded(vodId, playlistId, 'vod'); - }); - - readonly isDownloading = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isDownloading(vodId, playlistId, 'vod'); - }); - - readonly isPausedDownload = computed(() => { - const vodId = this.selectedVodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return false; - this.downloadsService.downloads(); - return this.downloadsService.isPaused(vodId, playlistId, 'vod'); - }); + readonly isDownloaded = this.downloads.isDownloaded; + readonly isDownloading = this.downloads.isDownloading; + readonly isPausedDownload = this.downloads.isPausedDownload; readonly trailerEmbedUrl = computed(() => youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer) ); - /** TMDB recommendations matched against the loaded VOD catalog */ - readonly similarItems = computed(() => { - const info = this.selectedVodInfo(); - if (!info?.tmdb_recommendations?.length) { - return []; - } - return matchRecommendationsToCatalog( - info.tmdb_recommendations, - this.xtreamStore.vodStreams(), - { excludeId: this.selectedVodId() } - ); - }); - - /** Recommendations found in the user's OTHER portals (Electron only) */ - private readonly crossPortalItems = signal([]); - readonly similarInPortals = computed(() => { - const localTitles = new Set( - this.similarItems().map( - (item) => normalizeTitleKeys(item.title).exact - ) - ); - return this.crossPortalItems().filter( - (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) - ); - }); - - private readonly loadCrossPortalSimilar = effect(() => { - const recommendations = this.selectedVodInfo()?.tmdb_recommendations; - const playlistId = this.xtreamStore.currentPlaylist()?.id; - untracked(() => { - this.crossPortalItems.set([]); - if ( - !recommendations?.length || - !this.crossPortalSimilar.isAvailable - ) { - return; - } - void this.crossPortalSimilar - .matchRecommendations(recommendations, 'movie', { - excludePlaylistId: playlistId, - }) - .then((items) => { - if ( - this.selectedVodInfo()?.tmdb_recommendations === - recommendations - ) { - this.crossPortalItems.set(items); - } - }); - }); - }); + readonly similarItems = this.similar.similarItems; + readonly similarInPortals = this.similar.similarInPortals; /** * The alternative the player is on, in playback's terms — null while the * route's own source is playing, which the matcher already recognises. */ - private readonly activeAlternativeSource = computed(() => { - const active = this.multiSource.sources().find((s) => s.isActive); - const routePlaylistId = this.xtreamStore.currentPlaylist()?.id; - // Both halves: a pinned copy can now live in the route's OWN playlist, - // and comparing the playlist alone would call it "the route source" — - // losing the Stop state and every position update for it. - if ( - !active || - (active.playlistId === routePlaylistId && - active.contentId === this.selectedVodId()) - ) { - return null; - } - - return { - playlistId: active.playlistId, - contentXtreamId: active.contentId, - contentType: 'vod' as const, - }; - }); constructor() { + this.downloads.bind({ routeContentId: this.selectedVodId }); + + this.similar.bind({ + vodInfo: this.selectedVodInfo, + routeContentId: this.selectedVodId, + }); + + this.msUi.bind({ + routeContentId: this.selectedVodId, + movieTitle: computed(() => this.multiSourceMovie()?.title ?? ''), + }); + this.playback.bind({ vodId: this.selectedVodId, vodInfo: this.selectedVodInfo, - activeSource: this.activeAlternativeSource, + activeSource: this.msUi.activeAlternativeSource, }); effect(() => { @@ -391,7 +314,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // evidence that the previous one was playing says nothing // about this one — without clearing it the caption and the // badge would claim the new source while it is still opening. - this.inlineTimeSeen.set(false); + this.msUi.reset(); this.playback.startResolvedPlayback(playback); }, movie: this.multiSourceMovie, @@ -416,7 +339,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // lookup cannot repopulate either one. this.vodPlaybackPosition.set(null); this.playback.routePlaybackPosition.set(null); - this.inlineTimeSeen.set(false); + this.msUi.reset(); this.initializeVodDetails(playlistId, vodId); }); @@ -492,15 +415,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // timeupdate would otherwise resolve the next source back at it. this.multiSource.reportPosition(0); this.multiSource.markRouteSourceActive(); - this.playbackFailed.set(false); - this.inlineTimeSeen.set(false); + this.msUi.beginPlayback(); this.playback.playVod(vodItem); } resumeVod(vodItem: XtreamVodDetails | null): void { this.multiSource.markRouteSourceActive(); - this.playbackFailed.set(false); - this.inlineTimeSeen.set(false); + this.msUi.beginPlayback(); // The controller can still hold an ALTERNATIVE's timecode. A failure // before the first timeupdate would otherwise resolve the next source // at a position that belongs to a different copy. @@ -522,7 +443,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // A pinned source is an explicit "play this movie from here", so it // outranks the playlist the route happens to be on. Falls through to // the normal path when nothing is pinned or the pin cannot resolve. - if (await this.multiSource.playPinnedSource(this.resumeSecondsFor)) { + if ( + await this.multiSource.playPinnedSource(this.msUi.resumeSecondsFor) + ) { return; } @@ -537,32 +460,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playVod(vodItem); } - /** - * Where a specific source was last watched. - * - * Positions are keyed by (playlist, stream), so a pinned alternative has - * its own row — the one this page loaded belongs to the route's copy. - */ - private readonly positionFor = ( - source: VodSourceCandidate | VodSourceDescriptor - ): Promise => - this.playbackPositions.getPlaybackPosition( - source.playlistId, - source.contentId, - 'vod' - ); - - private readonly resumeSecondsFor = async ( - source: VodSourceCandidate - ): Promise => - (await this.positionFor(source))?.positionSeconds ?? null; stopExternalPlayback(): Promise { return this.playback.stopExternalPlayback(); } formatPosition(): string { - return formatPlaybackPosition(this.primaryAction.position()); + return this.msUi.formatPosition(); } toggleFavorite(): void { @@ -592,187 +496,38 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.closeInlinePlayer(); } - /** Title shown in the sources popover header. */ - readonly multiSourceTitle = computed( - () => this.multiSourceMovie()?.title ?? '' - ); - - /** The ".srcbar" caption under the action row: where this is playing from. */ - readonly activeSourceCaption = computed(() => { - const active = this.multiSource.sources().find((s) => s.isActive); - // "Playing from" only while something actually is. Discovery marks a - // source active as soon as the page opens, so gating on that alone - // makes the line a claim about a player that has not started, one the - // user has since closed, or one that failed and is showing an error. - if (!active || !this.playbackLive()) { - return null; - } - - // Only FACTS reach the caption. A guessed quality would read as a - // claim about the stream the user is watching right now. - const facts = [ - active.quality?.provenance !== 'parsed' - ? active.quality?.value - : null, - active.container?.provenance !== 'parsed' - ? active.container?.value - : null, - ].filter(Boolean); - - return { - // Never the raw playlist name: it is routinely the pasted URL, - // credentials and all, and this line sits in the open on every - // screenshot of the detail page. - source: [ - playlistDisplayLabel(active.playlistName, active.playlistId), - ...facts, - ].join(' · '), - // The caption speaks of PLAYLISTS ("also found in 2 others"), so - // three copies inside one portal must not read as three portals. - alternativeCount: this.multiSource.alternativePlaylistCount(), - }; - }); + readonly multiSourceTitle = this.msUi.multiSourceTitle; + readonly activeSourceCaption = this.msUi.activeSourceCaption; playFromSource(sourceId: string): void { - // Only once the switch actually starts something. A source picked off - // the error screen that cannot be resolved leaves the diagnostic up, - // and clearing eagerly would have the caption claim playback again. - void this.multiSource.play(sourceId).then((switched) => { - if (switched) { - this.playbackFailed.set(false); - } - }); + this.msUi.playFromSource(sourceId); } pinSource(sourceId: string): void { - void this.multiSource.togglePin(sourceId); + this.msUi.pinSource(sourceId); } checkSource(sourceId: string): void { - void this.multiSource.check(sourceId); + this.msUi.checkSource(sourceId); } - /** - * Only the built-in web players raise a playback diagnostic, so on MPV, - * VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The - * toggle is hidden there rather than left promising a switch that cannot - * happen. - */ - readonly autoFailoverSupported = computed(() => - reportsPlaybackFailures(this.settingsStore.player?.()) - ); + readonly autoFailoverSupported = this.msUi.autoFailoverSupported; setAutoFailover(enabled: boolean): void { - // `updateSettings` patches memory first and REJECTS if the write - // fails, so without this the toggle looks saved, reverts on restart, - // and the rejection surfaces only as an unhandled promise. - this.settingsStore - .updateSettings({ vodAutoFailover: enabled }) - .catch(() => - this.snackBar.open( - this.translateService.instant( - 'SETTINGS.SETTINGS_SAVE_FAILED' - ), - this.translateService.instant('CLOSE'), - { duration: 10000 } - ) - ); + this.msUi.setAutoFailover(enabled); } - /** - * A source failed. With auto-failover on we move to the best untried - * source and ANNOUNCE it; otherwise the player's own error overlay — which - * is already showing the alternatives — is left to do its job. - */ - async onPlaybackFailed(): Promise { - // The error screen is up: nothing is playing from anywhere until a - // source actually starts again. - this.playbackFailed.set(true); - const notice = await this.multiSource.failover(); - if (!notice) { - return; - } - - this.snackBar - .open( - this.translateService.instant( - 'PORTALS.MULTI_SOURCE.SWITCHED_TO', - { playlist: notice.playlistName } - ) + - (notice.audioMayDiffer - ? ` — ${this.translateService.instant( - 'PORTALS.MULTI_SOURCE.SWITCH_AUDIO_WARNING' - )}` - : ''), - this.translateService.instant('PORTALS.MULTI_SOURCE.UNDO'), - { duration: 10000 } - ) - .onAction() - .subscribe(() => this.undoFailover()); + onPlaybackFailed(): Promise { + return this.msUi.onPlaybackFailed(); } - /** Return to the source that was playing before the automatic switch. */ - private undoFailover(): void { - const previousId = this.multiSource.previousSourceId(); - if (previousId) { - void this.multiSource.play(previousId); - } - } - - /** True from a playback failure until something plays again. */ - private readonly playbackFailed = signal(false); - - /** - * Cleared on every start; set by the first timeupdate. - * - * `inlinePlayback()` is the REQUEST to play — it is non-null while the - * engine is still opening the stream, and stays non-null after it fails. - * A timeupdate is the engine reporting that it is producing frames, which - * is the only evidence the page has that something is really playing. - */ - private readonly inlineTimeSeen = signal(false); - - /** - * Whether a stream is on screen right now. - * - * Both the "Playing from" caption and the row's Playing badge are claims - * about the present, and discovery marks a source active the moment the - * page opens — long before anything plays, and again after the player is - * closed. Gating both on this keeps them from lying. - */ - readonly playbackLive = computed(() => { - if (this.inlinePlayback()) { - return !this.playbackFailed() && this.inlineTimeSeen(); - } - - // The external player is a window of its own: once it is open the film - // is on screen, and only 'launching' means it is not there yet. - const status = this.matchedExternalPlayback()?.status; - return status === 'opened' || status === 'playing'; - }); + readonly playbackLive = this.msUi.playbackLive; handleInlineTimeUpdate(event: { currentTime: number; duration: number; }): void { - // The engine is producing time, so whatever failed before is over. - this.playbackFailed.set(false); - this.inlineTimeSeen.set(true); - const settled = this.playback.handleInlineTimeUpdate(event); - - // Ahead of the service's 15s persistence throttle, so a source switch - // resumes from where playback actually is rather than up to 15s back. - // - // Until the engine has finished seeking to the resume point it reports - // ~0, and that is not where the film is — it is where it has not got - // to yet. Feeding it to multi-source would make a switch or a failure - // during those first seconds restart the movie from the beginning, so - // the position we asked the engine for stands in until it arrives. - this.multiSource.reportPosition( - settled - ? event.currentTime - : (this.inlinePlayback()?.startTime ?? 0) - ); + this.msUi.handleInlineTimeUpdate(event); } showCopyNotification(): void { @@ -789,67 +544,16 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { this.playback.handleExternalFallbackRequest(request); } - async resumePausedDownload(): Promise { - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) { - return; - } - await this.downloadsService.resumeDownloadByContent( - this.selectedVodId(), - playlistId, - 'vod' - ); + resumePausedDownload(): Promise { + return this.downloads.resumePaused(); } - async downloadVod(vodItem: XtreamVodDetails | null): Promise { - if (!vodItem) { - return; - } - - const info = getXtreamVodInfo(vodItem); - const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); - const routeVodId = this.route.snapshot.params.vodId; - const id = routeVodId - ? Number(routeVodId) - : Number( - vodItem.movie_data?.stream_id || - (vodItem as { stream_id?: number }).stream_id - ); - - const playlist = this.xtreamStore.currentPlaylist(); - if (!playlist) { - return; - } - - await this.downloadsService.startDownload({ - playlistId: playlist.id, - xtreamId: id, - contentType: 'vod', - title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', - url: streamUrl, - posterUrl: info?.movie_image, - headers: { - userAgent: playlist.userAgent, - referer: playlist.referrer, - origin: playlist.origin, - }, - }); + downloadVod(vodItem: XtreamVodDetails | null): Promise { + return this.downloads.start(vodItem); } - async playFromLocal(): Promise { - const vodId = Number(this.route.snapshot.params.vodId); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - if (!playlistId) return; - - const filePath = this.downloadsService.getDownloadedFilePath( - vodId, - playlistId, - 'vod' - ); - - if (filePath) { - await this.downloadsService.playDownload(filePath); - } + playFromLocal(): Promise { + return this.downloads.playLocal(); } private initializeVodDetails(playlistId: string, vodId: number): void { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index 66c0a73ae..5ccfc9c9a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -60,6 +60,7 @@ export function createVodDetailsRouteStubs() { isEmbeddedPlayer: jest.fn().mockReturnValue(false), openResolvedPlayback: jest.fn(), snackBarOpen: jest.fn(), + startDownload: jest.fn().mockResolvedValue(undefined), locationBack: jest.fn(), selectedPlayer: signal(VideoPlayer.Html5Player), updateSettings: jest.fn().mockResolvedValue(undefined), @@ -190,7 +191,7 @@ export async function configureVodDetailsRouteTestBed( downloads: stubs.downloads, isDownloaded: jest.fn().mockReturnValue(false), isDownloading: jest.fn().mockReturnValue(false), - startDownload: jest.fn(), + startDownload: stubs.startDownload, getDownloadedFilePath: jest.fn(), playDownload: jest.fn(), }, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts new file mode 100644 index 000000000..ec95e85c1 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-similar.service.ts @@ -0,0 +1,108 @@ +import { + Injectable, + Signal, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + CrossPortalSimilarItem, + CrossPortalSimilarService, +} from '@iptvnator/services'; +import { + normalizeTitleKeys, + XtreamVodInfo, +} from '@iptvnator/shared/interfaces'; +import { + SimilarCatalogItem, + matchRecommendationsToCatalog, +} from '../tmdb-similar.util'; + +/** + * The "Similar" rail on the VOD details page. + * + * Two sources feed it: TMDB recommendations matched against the catalog this + * playlist already loaded, and — in Electron — the same recommendations + * matched across every OTHER imported playlist. The second list is filtered + * against the first so one film does not appear twice under different portals. + * + * Component-provided: the cross-portal lookup is per-movie and its result must + * die with the page rather than leak into the next film. + */ +@Injectable() +export class VodDetailsSimilarService { + private readonly xtreamStore = inject(XtreamStore); + private readonly crossPortalSimilar = inject(CrossPortalSimilarService); + + private vodInfo: Signal = signal(null); + private routeContentId: Signal = signal(NaN); + + bind(bindings: { + vodInfo: Signal; + routeContentId: Signal; + }): void { + this.vodInfo = bindings.vodInfo; + this.routeContentId = bindings.routeContentId; + } + + /** TMDB recommendations matched against the loaded VOD catalog. */ + readonly similarItems = computed(() => { + const info = this.vodInfo(); + if (!info?.tmdb_recommendations?.length) { + return []; + } + + return matchRecommendationsToCatalog( + info.tmdb_recommendations, + this.xtreamStore.vodStreams(), + { excludeId: this.routeContentId() } + ); + }); + + /** Recommendations found in the user's OTHER portals (Electron only). */ + private readonly crossPortalItems = signal([]); + + readonly similarInPortals = computed(() => { + const localTitles = new Set( + this.similarItems().map( + (item) => normalizeTitleKeys(item.title).exact + ) + ); + + return this.crossPortalItems().filter( + (item) => !localTitles.has(normalizeTitleKeys(item.title).exact) + ); + }); + + private readonly loadCrossPortalSimilar = effect(() => { + const recommendations = this.vodInfo()?.tmdb_recommendations; + const playlistId = this.xtreamStore.currentPlaylist()?.id; + + untracked(() => { + this.crossPortalItems.set([]); + if ( + !recommendations?.length || + !this.crossPortalSimilar.isAvailable + ) { + return; + } + + void this.crossPortalSimilar + .matchRecommendations(recommendations, 'movie', { + excludePlaylistId: playlistId, + }) + .then((items) => { + // The user can open another film across the lookup, and + // the rail must not describe the one they left. + if ( + this.vodInfo()?.tmdb_recommendations === recommendations + ) { + this.crossPortalItems.set(items); + } + }); + }); + }); +} diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index ab0091223..b79290249 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -867,13 +867,22 @@ export class PlaylistBackupService { // The match key identifies the film and survives untouched; only the // playlist has a new id in this installation. for (const pin of state.sourcePins ?? []) { - await this.vodSourcePinService.set({ + const written = await this.vodSourcePinService.set({ matchKey: pin.matchKey, playlistId, contentId: pin.contentId, portalType: 'xtream', ...(pin.updatedAt ? { updatedAt: pin.updatedAt } : {}), }); + + // `set` reports a failed write rather than throwing, so ignoring + // it would drop the preference while the summary claims the + // import succeeded. + if (!written) { + throw new PlaylistBackupError( + `Restoring the pinned source for "${pin.matchKey}" failed.` + ); + } } } diff --git a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts index f008c0858..99ec37b62 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-restore.spec.ts @@ -254,6 +254,31 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => { }); }); + it('reports a pin that could not be written', async () => { + const collaborators = createRestoreCollaborators(); + const service = createPlaylistBackupService({ + ...collaborators, + vodSourcePinService: { + listForPlaylist: jest.fn().mockResolvedValue([]), + // `set` reports failure rather than throwing, so ignoring the + // result would drop the preference while the summary claims + // the import succeeded. + set: jest.fn().mockResolvedValue(false), + }, + }); + + const manifest = createXtreamManifest( + [], + [{ matchKey: 'tmdb:603', contentId: 501 }] + ); + + const summary = await service.importBackup(JSON.stringify(manifest)); + + expect(summary).toEqual( + expect.objectContaining({ merged: 0, failed: 1 }) + ); + }); + it('imports an archive written before pins existed', async () => { const collaborators = createRestoreCollaborators(); const setPin = jest.fn().mockResolvedValue(true);