diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index f789e13a7..a87f6abec 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -151,6 +151,11 @@ matter, and each rules out the other's shortcut: for a different film — pin Dune (2021), open Dune (1984) before its year arrives, and it starts the 2021 source. +A write stores the new key **before** retiring the old rows. The other order +destroys the stored preference and can then fail to replace it, leaving nothing +persisted while the row still shows the old pin; lookups are most-trusted-first, +so a leftover alias never outranks the key just written. + The pin a rediscovery reads is applied **immediately**, not after its source lookup returns: holding that snapshot across the await lets it overwrite a pin the user makes in the meantime, leaving the row and the primary Play naming a @@ -355,6 +360,13 @@ a page that shows a Stop button for a session whose progress it discards keeps the resume point at wherever playback began, so a switch an hour later rewinds the whole session. +Whichever source ends up playing, the "playing" badge follows it: starting the +route's own stream (Play, Resume, Restart, or the fallback after a pin does not +apply) hands the badge back to the route row, or the picker and caption go on +naming an alternative that is no longer running. And a source started through +the picker or a pin is recorded in Recently Viewed exactly as an ordinary Play +is — it is the same film, watched. + Stop then has to win over the pin. The primary action consults the pin first — that is what makes "make this the main source" decide where playback starts — but when a session is already running the same button reads Stop, and doing diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 18c966985..41a1411a6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -27,6 +27,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { let service: VodDetailsPlaybackService; /** The bridge callback the service registers at construction. */ let positionListener: ((data: PlaybackPositionData) => void) | undefined; + const addRecentItem = jest.fn(); const activeSession = signal(null); const activeSource = signal(null); @@ -46,6 +47,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { activeSession.set(null); activeSource.set(null); positionListener = undefined; + addRecentItem.mockClear(); TestBed.configureTestingModule({ providers: [ @@ -54,7 +56,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { provide: XtreamStore, useValue: { currentPlaylist: signal({ id: ROUTE_PLAYLIST }), - addRecentItem: jest.fn(), + addRecentItem, }, }, { @@ -139,6 +141,23 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(service.matchedExternalPlayback()).toBeNull(); }); + it('records a source started through multi-source as recently viewed', () => { + // Playing an alternative from the picker, or letting a pin decide the + // primary Play, is still watching the movie — it belongs in Recently + // Viewed exactly as an ordinary Play does. + service.startResolvedPlayback({ + streamUrl: 'https://example.com/alt.mkv', + title: 'Example Movie', + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }); + + expect(addRecentItem).toHaveBeenCalled(); + }); + describe('position updates from the bridge', () => { function emit(playlistId: string, contentXtreamId: number, at: number) { positionListener?.({ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 0b3e442c3..461c2cc6d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -85,10 +85,9 @@ export class VodDetailsPlaybackService { * Whether this page owns the content an external session or a position * update refers to. * - * Multi-source can put playback on a movie in ANOTHER playlist, and its - * session ids and position rows then carry that playlist's identity. One - * predicate for both consumers: when they disagree, the page shows a Stop - * button for a session whose progress it is throwing away. + * Multi-source can put playback on a movie in ANOTHER playlist, whose ids + * its session and position rows then carry. One predicate for both: when + * they disagree the page shows Stop for a session whose progress it drops. */ private ownsContent( info: @@ -174,10 +173,8 @@ export class VodDetailsPlaybackService { const unsubscribePositionUpdates = this.playbackPositionBridge.onPlaybackPositionUpdate( (data: PlaybackPositionData) => { - // An external player running an ALTERNATIVE reports under - // that playlist's ids. Dropping those updates would leave - // the resume point at wherever playback started, and a - // later switch would rewind the whole session. + // An external player on an ALTERNATIVE reports under + // that playlist's ids; dropping those rewinds a switch. if (this.ownsContent(data)) { this.vodPlaybackPosition.set(data); } @@ -383,6 +380,9 @@ export class VodDetailsPlaybackService { * engine survive and simply re-seek to `playback.startTime`. */ startResolvedPlayback(playback: ResolvedPortalPlayback): void { + // Same movie, different source: still a view, and the picker and + // pin paths would otherwise leave it out of Recently Viewed. + this.addToRecentlyViewed(); this.startPlayback(playback); } 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 6396dc1c7..1e53a2f13 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 @@ -458,10 +458,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // position this page was seeded with, and a failure before the first // timeupdate would otherwise resolve the next source back at it. this.multiSource.reportPosition(0); + this.multiSource.markRouteSourceActive(); this.playback.playVod(vodItem); } resumeVod(vodItem: XtreamVodDetails | null): void { + this.multiSource.markRouteSourceActive(); this.playback.resumeVod(vodItem); } @@ -481,6 +483,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { return; } + // Falling through to the route's own stream: whatever alternative was + // last active is not what will be playing. + this.multiSource.markRouteSourceActive(); this.playback.onPrimaryAction(vodItem); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts new file mode 100644 index 000000000..619d54e71 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts @@ -0,0 +1,210 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + VodSourceDiscoveryService, + VodSourceResolverService, +} from '@iptvnator/portal/shared/data-access'; +import { + SettingsStore, + StreamProbeService, + VodSourcePinService, +} from '@iptvnator/services'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { VodMultiSourceHostService } from './vod-multi-source-host.service'; +import type { VodMultiSourceMovie } from './vod-multi-source-identity'; + +import { + ALT_THREE, + ALT_TWO, + CURRENT_A_ID, + MOVIE_A, + PROBE_OK, + createDeferred, + resolveWith, +} from './vod-multi-source-host.fixtures'; + +/** + * What the database ends up holding. + * + * The row on screen is a promise that the preference survives reopening the + * movie, so a write that did not land must not be shown as one — and the + * aliases a movie can be looked up by are not all safe to write to or delete. + */ +describe('VodMultiSourceHostService — pin persistence', () => { + let service: VodMultiSourceHostService; + + const movie = signal(null); + const vodAutoFailover = signal(false); + const startPlayback = jest.fn(); + const discovery = { isAvailable: true, discover: jest.fn() }; + const resolver = { resolve: jest.fn() }; + const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() }; + const probes = { probe: jest.fn() }; + + async function loadMovie( + sources: VodSourceCandidate[], + target: VodMultiSourceMovie = MOVIE_A + ): Promise { + discovery.discover.mockResolvedValue({ + sources, + matchKind: 'title-year', + }); + await service.load(target); + } + + function rowFor(sourceId: string) { + return service.sources().find((source) => source.id === sourceId); + } + + beforeEach(() => { + jest.resetAllMocks(); + movie.set(null); + vodAutoFailover.set(false); + discovery.isAvailable = true; + discovery.discover.mockResolvedValue({ + sources: [], + matchKind: 'title-year', + }); + resolver.resolve.mockImplementation(resolveWith()); + pins.get.mockResolvedValue(null); + pins.set.mockResolvedValue(true); + pins.clear.mockResolvedValue(true); + probes.probe.mockResolvedValue(PROBE_OK); + + TestBed.configureTestingModule({ + providers: [ + VodMultiSourceHostService, + { provide: VodSourceDiscoveryService, useValue: discovery }, + { provide: VodSourceResolverService, useValue: resolver }, + { provide: VodSourcePinService, useValue: pins }, + { provide: StreamProbeService, useValue: probes }, + { provide: SettingsStore, useValue: { vodAutoFailover } }, + ], + }); + + service = TestBed.inject(VodMultiSourceHostService); + TestBed.runInInjectionContext(() => + service.bind({ startPlayback, movie }) + ); + }); + + it('lets a pin made during a rediscovery win over the old one', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + // Enrichment reruns discovery for the same film... + const slow = createDeferred<{ + sources: VodSourceCandidate[]; + matchKind: string; + }>(); + discovery.discover.mockReturnValueOnce(slow.promise); + const reloading = service.load({ ...MOVIE_A, tmdbId: 603 }); + while (discovery.discover.mock.calls.length < 2) { + await Promise.resolve(); + } + + // ...and the user pins a different source while it is still out. + await service.togglePin(ALT_THREE.id); + + slow.resolve({ + sources: [ALT_TWO, ALT_THREE], + matchKind: 'title-year', + }); + await reloading; + + // The snapshot that rerun started with is now stale. Restoring it + // would leave the row and Play on a source the database no longer + // holds. + expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('retires its own aliases but never another remake’s', async () => { + // With a TMDB id there are two keys naming this film and one shared + // with every remake, so the retire set is worth asserting on. + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + expect(pins.get.mock.calls[0][0]).toEqual([ + 'tmdb:603', + 'title:the matrix:1999', + 'title:the matrix:', + ]); + + await service.togglePin(ALT_TWO.id); + + expect(pins.set).toHaveBeenCalledWith( + expect.objectContaining({ matchKey: 'tmdb:603' }) + ); + const [retired] = pins.clear.mock.calls[0] as [string[]]; + // This film's other key goes, so a reopen before enrichment cannot + // read a row still pointing at the source just replaced. + expect(retired).toContain('title:the matrix:1999'); + // The yearless form is shared by every remake: a Dune (2021) pin must + // not delete — or answer for — a row that may be Dune (1984)'s. + expect(retired).not.toContain('title:the matrix:'); + // And never the row just written. + expect(retired).not.toContain('tmdb:603'); + }); + + it('keeps the stored pin when the replacement write fails', async () => { + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + pins.set.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // Retiring first would destroy the stored preference and then fail to + // replace it: nothing persisted, while the row still shows the old pin. + expect(pins.clear).not.toHaveBeenCalled(); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('retires the ambiguous row it actually read', async () => { + // A pin set before the year was known lives under the yearless key. + // This session read it, so the user is unpinning THAT row — leaving it + // would make the unpin come back on the next open. + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + + await service.togglePin(ALT_TWO.id); + + const [retired] = pins.clear.mock.calls[0] as [string[]]; + expect(retired).toContain('title:the matrix:'); + }); + + it('does not show a pin the database refused to store', async () => { + await loadMovie([ALT_TWO]); + pins.set.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // The icon promises the preference survives reopening the movie. A + // write that failed makes that a lie, so the row must not change. + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('keeps the pin when clearing it fails', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + pins.clear.mockResolvedValue(false); + await service.togglePin(ALT_TWO.id); + + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 695b1c59e..971e3d5e7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -206,111 +206,18 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); - it('lets a pin made during a rediscovery win over the old one', async () => { - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO, ALT_THREE]); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - - // Enrichment reruns discovery for the same film... - const slow = createDeferred<{ - sources: VodSourceCandidate[]; - matchKind: string; - }>(); - discovery.discover.mockReturnValueOnce(slow.promise); - const reloading = service.load({ ...MOVIE_A, tmdbId: 603 }); - while (discovery.discover.mock.calls.length < 2) { - await Promise.resolve(); - } - - // ...and the user pins a different source while it is still out. - await service.togglePin(ALT_THREE.id); - - slow.resolve({ - sources: [ALT_TWO, ALT_THREE], - matchKind: 'title-year', - }); - await reloading; - - // The snapshot that rerun started with is now stale. Restoring it - // would leave the row and Play on a source the database no longer - // holds. - expect(rowFor(ALT_THREE.id)?.isPinned).toBe(true); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); - }); - - it('retires its own aliases but never another remake’s', async () => { + it('hands the playing badge back when the route stream starts', async () => { await loadMovie([ALT_TWO]); - const lookupKeys: string[] = pins.get.mock.calls[0][0]; - const yearless = lookupKeys[lookupKeys.length - 1]; - expect(yearless).toBe('title:the matrix:'); + await expect(service.play(ALT_TWO.id)).resolves.toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); - await service.togglePin(ALT_TWO.id); + // The user closed that player and pressed Play. The route's own stream + // is what runs now, so the picker and caption must not keep naming the + // alternative as "Playing". + service.markRouteSourceActive(); - // Stale aliases of THIS movie go, or a reopen before enrichment reads - // one and starts the source the user just replaced. - const [retired] = pins.clear.mock.calls[0] as [string[]]; - expect(retired).toContain('title:the matrix:1999'); - // The yearless form is shared by every remake: a Dune (2021) pin must - // not delete — or answer for — a row that may be Dune (1984)'s. - expect(retired).not.toContain(yearless); - - expect(pins.set).toHaveBeenCalledTimes(1); - expect(pins.set).toHaveBeenCalledWith({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - }); - - it('retires the ambiguous row it actually read', async () => { - // A pin set before the year was known lives under the yearless key. - // This session read it, so the user is unpinning THAT row — leaving it - // would make the unpin come back on the next open. - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO]); - - await service.togglePin(ALT_TWO.id); - - const [retired] = pins.clear.mock.calls[0] as [string[]]; - expect(retired).toContain('title:the matrix:'); - }); - - it('does not show a pin the database refused to store', async () => { - await loadMovie([ALT_TWO]); - pins.set.mockResolvedValue(false); - - await service.togglePin(ALT_TWO.id); - - // The icon promises the preference survives reopening the movie. A - // write that failed makes that a lie, so the row must not change. - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); - }); - - it('keeps the pin when clearing it fails', async () => { - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO]); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - - pins.clear.mockResolvedValue(false); - await service.togglePin(ALT_TWO.id); - - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(false); }); it('waits for the stored pin before letting Play fall through', async () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 250192313..789a65dcc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -22,7 +22,7 @@ import { type VodMultiSourceMovie, } from './vod-multi-source-identity'; import { - applyDiscoveredSources, + runDiscovery, runFailover, switchToSource, type SwitchOutcome, @@ -31,7 +31,6 @@ import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; import { currentSourceRow } from './vod-multi-source-current-row'; import { probeSource } from './vod-multi-source-probe'; import { - pinnedCopyInPlaylist, pinnedSourceAwaitingPlay, pinnedSourceIdOf, playPinned, @@ -84,6 +83,8 @@ export class VodMultiSourceHostService { private switchToken = 0; private lastMovieKey: string | null = null; private movieIdentity: string | null = null; + /** The row standing for the playlist the route is on. */ + private routeSourceId: string | null = null; /** Resolves once the discovery on the way has published its sources. */ private loadInFlight: Promise | null = null; @@ -114,9 +115,8 @@ export class VodMultiSourceHostService { /** Alternative STREAMS — what the "Sources N" chip counts. */ readonly alternativeCount = computed(() => this.alternatives().length); /** - * Alternative PLAYLISTS. The popover groups one playlist's three copies - * under that playlist, so "also found in N other playlists" must count - * portals, not copies, or it contradicts the list it opens. + * Alternative PLAYLISTS: the popover groups a playlist's copies under it, + * so "also found in N other playlists" counts portals, not copies. */ readonly alternativePlaylistCount = computed( () => new Set(this.alternatives().map((s) => s.playlistId)).size @@ -168,12 +168,10 @@ export class VodMultiSourceHostService { * * A DIFFERENT film starts a fresh session — the router REUSES this * component for detail→detail navigation, so everything resets, above all - * the tried-source set that makes failover terminate. - * - * The SAME film arriving again is a refresh, not a new session: enrichment - * changes the movie key on purpose, but nothing about what is playing. - * Resetting there would take the film off the source the user switched to - * and hand failover a clean tried-set for sources it has already burned. + * the tried-source set that makes failover terminate. The SAME film + * arriving again is a refresh: enrichment changes the movie key on + * purpose, but nothing about what is playing, and resetting would take the + * film off the source the user switched to. */ async load(movie: VodMultiSourceMovie): Promise { const finished = this.discover(movie); @@ -207,39 +205,29 @@ export class VodMultiSourceHostService { return; } - // Read FIRST, because discovery has to be told which copy to keep. - const pin = await readPin(this.pins, this.pinKeys.lookup); - if (token !== this.discoveryToken) { - return; - } + const routeSource = currentSourceRow(movie); + this.routeSourceId = routeSource.id; - // Applied NOW, not after the discovery below: holding this snapshot - // across that await lets it overwrite a pin made in the meantime, and - // the row would name a source the database no longer holds. `loaded` - // records where it was found — the only ambiguous key an unpin may - // remove, being the row the user can actually see. - if (pin) { - this.controller.setPinnedSource(pinnedSourceIdOf(pin)); - this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; - } - - const result = await this.discovery.discover({ - title: movie.title, - year: movie.year, - currentPlaylistId: movie.playlistId, - keepContentId: pinnedCopyInPlaylist(pin, movie.playlistId), + await runDiscovery({ + controller: this.controller, + isCurrent: () => token === this.discoveryToken, + readPin: () => readPin(this.pins, this.pinKeys.lookup), + applyPin: (pin) => { + this.controller.setPinnedSource(pinnedSourceIdOf(pin)); + // Where it was found is the only ambiguous key an unpin may + // remove, being the row the user can actually see. + this.pinKeys = { ...this.pinKeys, loaded: pin.matchKey }; + }, + discover: (keepContentId) => + this.discovery.discover({ + title: movie.title, + year: movie.year, + currentPlaylistId: movie.playlistId, + keepContentId, + }), + routeSource, + publish: () => this.publish(), }); - if (token !== this.discoveryToken) { - return; - } - - applyDiscoveredSources( - this.controller, - currentSourceRow(movie), - result.sources, - result.matchKind - ); - this.publish(); } /** The pinned source, when it is not the one the route already plays. */ @@ -364,6 +352,18 @@ export class VodMultiSourceHostService { return switched ? this._lastSwitch() : null; } + /** + * Hand the "playing" badge back to the route's own row: closing an + * alternative and pressing Play starts the route stream, and the picker + * and caption must stop naming a source that is not running. + */ + markRouteSourceActive(): void { + if (this.routeSourceId) { + this.controller.setActiveSource(this.routeSourceId); + this.publish(); + } + } + /** The live position, fed ahead of the persist throttle. */ reportPosition(seconds: number): void { this.controller.setResumeSeconds(seconds); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index bf9e531b6..252f6b6bd 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -84,16 +84,26 @@ export async function writePin( return false; } - await erasePin(pins, retirablePinKeys(keys)); - - // The write can fail — no bridge, or the DB refused it. Reporting success - // then would show a pin the next visit does not have. - return pins.set({ + // The write goes FIRST, and the old rows only go once it landed. The + // other order destroys the stored preference and then fails to replace it + // — leaving nothing persisted while the UI still shows the old pin. + // Lookups are most-trusted-first, so a leftover alias never outranks the + // key just written. + const stored = await pins.set({ matchKey, playlistId: candidate.playlistId, contentId: candidate.contentId, portalType: candidate.portalType, }); + if (!stored) { + return false; + } + + await erasePin( + pins, + retirablePinKeys(keys).filter((key) => key !== matchKey) + ); + return true; } /** Clears every alias, so unpinning is not undone by a stale row. */ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts index fff564c16..71418eaf3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts @@ -3,11 +3,13 @@ import type { ResolvedPortalPlayback, VodSourceCandidate, VodSourceMatchKind, + VodSourcePin, } from '@iptvnator/shared/interfaces'; import { buildSwitchNotice, type VodMultiSourceSwitchNotice, } from './vod-multi-source-notice'; +import { pinnedCopyInPlaylist } from './vod-multi-source-pin'; /** * Session mechanics for one open movie: how a discovery folds into the state @@ -178,3 +180,54 @@ export async function runFailover( // is strictly a different source and the loop terminates. } } + +/** What one discovery pass needs from the host, without reaching into it. */ +export interface DiscoveryRunDeps { + controller: VodMultiSourceController; + /** False once a newer `load()` superseded this one. */ + isCurrent: () => boolean; + readPin: () => Promise; + /** Applied the moment it is read — see below. */ + applyPin: (pin: VodSourcePin) => void; + discover: (keepContentId: number | null) => Promise<{ + sources: VodSourceCandidate[]; + matchKind: VodSourceMatchKind; + }>; + routeSource: VodSourceCandidate; + publish: () => void; +} + +/** + * One discovery pass, from the pin lookup to the published source list. + * + * The pin is read first because discovery has to be told which copy of the + * film to keep, and applied immediately rather than after the source lookup: + * holding that snapshot across the await lets it overwrite a pin the user + * makes in the meantime, leaving the row naming a source the database no + * longer holds. Applying it first makes the later write simply win. + */ +export async function runDiscovery(deps: DiscoveryRunDeps): Promise { + const pin = await deps.readPin(); + if (!deps.isCurrent()) { + return; + } + + if (pin) { + deps.applyPin(pin); + } + + const result = await deps.discover( + pinnedCopyInPlaylist(pin, deps.routeSource.playlistId) + ); + if (!deps.isCurrent()) { + return; + } + + applyDiscoveredSources( + deps.controller, + deps.routeSource, + result.sources, + result.matchKind + ); + deps.publish(); +}