fix(portals): start a never-watched pinned source from the beginning

Positions are keyed by (playlist, stream). When the pin points at a copy the
user has never opened, the lookup returns nothing and the controller was left
holding the ROUTE copy's position — so Play dropped them 42 minutes into an
unstarted film, and the first save wrote that timecode back under the pinned
source's key, making it permanent.

The spec asserted the old behaviour, so it is flipped rather than extended; a
second case covers the host that supplies no lookup at all, where "never
watched" was never established and the position must be left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 10:28:48 +02:00
1 parent cf4a342193
commit 2d8b6857ce
3 files changed
+48 -6

No files matched your search

+12
View File
@@ -411,6 +411,18 @@ Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching
`CatalogTitleMatchService`. In the PWA the chip renders nothing and the
auto-failover setting is hidden.
## Resuming a pinned copy
Playback positions are keyed by (playlist, stream), so a pinned alternative
carries its own. `playPinned` looks that position up and applies it — and
applies **zero** when the lookup comes back empty, because the controller is
still holding the ROUTE copy's position at that moment. Carrying it across
would drop the user into the middle of a film they never started here, and the
first save would write that timecode under the pinned source's key.
When no lookup function is supplied at all, nothing is applied: "never watched"
was never established, so there is nothing to correct.
## Claims about the present
`isActive` means "the source a switch or Play would use". Discovery sets it the
@@ -186,7 +186,32 @@ describe('VodMultiSourceHostService — pinning', () => {
);
});
it('keeps the page position when the pinned source has none', async () => {
it('starts a pinned source that was never watched from the beginning', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
// The position the PAGE loaded belongs to the route's copy.
service.seedResumePosition(2538);
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe(true);
// Positions are keyed by (playlist, stream). Carrying the route copy's
// timecode into a copy the user has never opened drops them 42 minutes
// into an unstarted film — and the first save writes it back under
// this source's key, making the mistake permanent.
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 0 }
);
});
it('leaves the position alone when the host cannot look one up', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
@@ -196,9 +221,9 @@ describe('VodMultiSourceHostService — pinning', () => {
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe(true);
// No lookup was performed, so "never watched" was never established —
// zeroing here would throw away a position nobody contradicted.
await expect(service.playPinnedSource()).resolves.toBe(true);
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
@@ -210,6 +210,7 @@ export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
return false;
}
const looked = !!deps.resumeFor;
const stored = await deps.resumeFor?.(pinned);
// That lookup is a database round-trip, and the user can navigate across
// it. Playing then would hand THIS film's source id to whatever movie now
@@ -218,8 +219,12 @@ export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
return false;
}
if (stored !== null && stored !== undefined) {
deps.controller.setResumeSeconds(stored);
if (looked) {
// `null` means this copy has never been watched, and the controller is
// still holding the ROUTE copy's position. Carrying that across would
// drop the user an hour into a film they have not started here — and
// the first save would write that timecode under this source's key.
deps.controller.setResumeSeconds(stored ?? 0);
}
return deps.play(pinned.id);