mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
fix(portals): tell a superseded pinned play from an unusable pin
Double-clicking Play while a pinned source resolves put both handlers into `playPinnedSource()`. The second supersedes the first, so the first returned `false` — which the route read as "no usable pin" and answered by starting the route source over the playback the second click had just begun. `playPinnedSource` now reports `played` / `superseded` / `unavailable`, and only `unavailable` falls through. This is the same distinction `runFailover` already draws between "keep going" and "stop, something newer owns the screen"; the pinned path simply never had it. The host crossed the 400-line cap again on the way, so the pinned-play errand (wait out an in-flight discovery, re-check the session, start the source) moved into the pin module beside `playPinned`, and the pin-toggle commit went with it. 388 lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dfbff5b815
commit
d3b52cf574
8 files changed
+193
-63
No files matched your search
@@ -499,6 +499,13 @@ downloads sit in their own component-provided services beside it.
|
||||
|
||||
## Backup
|
||||
|
||||
`playPinnedSource` reports `played` / `superseded` / `unavailable`, and only
|
||||
`unavailable` may fall through to the route source. Collapsing the first two
|
||||
meant a double-click on Play — where the second attempt supersedes the first —
|
||||
had the losing attempt conclude "no usable pin" and start the route stream over
|
||||
the playback the winning one had just begun. Same distinction as `runFailover`'s,
|
||||
for the same reason.
|
||||
|
||||
A pin is written and its old aliases retired in ONE transaction
|
||||
(`setVodSourcePin(db, pin, retireKeys)`). Split in two there is no honest
|
||||
outcome for a half-failure: a surviving alias is read before the canonical key
|
||||
|
||||
@@ -252,12 +252,31 @@ describe('VodDetailsRouteComponent — playback actions', () => {
|
||||
expect(reported).toHaveBeenLastCalledWith(120);
|
||||
});
|
||||
|
||||
it('does not start the route source when a newer action owns the screen', async () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
const component = fixture.componentInstance;
|
||||
withActiveSource('playlist-1', 650020);
|
||||
// Double-clicking Play while the pin is resolving: the second click
|
||||
// supersedes the first, and the first must not conclude "no usable
|
||||
// pin" and start the route source over what the second just began.
|
||||
jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue(
|
||||
'superseded'
|
||||
);
|
||||
const reported = jest.spyOn(component.multiSource, 'reportPosition');
|
||||
|
||||
await component.onPrimaryAction({
|
||||
movie_data: { stream_id: 650020, name: 'Example' },
|
||||
} as never);
|
||||
|
||||
expect(reported).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('takes the route wrappers when the primary button falls through', () => {
|
||||
currentPlaylist.set({ id: 'playlist-1' });
|
||||
const component = fixture.componentInstance;
|
||||
withActiveSource('playlist-1', 650020);
|
||||
jest.spyOn(component.multiSource, 'playPinnedSource').mockResolvedValue(
|
||||
false
|
||||
'unavailable'
|
||||
);
|
||||
const reported = jest.spyOn(component.multiSource, 'reportPosition');
|
||||
|
||||
|
||||
@@ -443,9 +443,13 @@ 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.msUi.resumeSecondsFor)
|
||||
) {
|
||||
const pinned = await this.multiSource.playPinnedSource(
|
||||
this.msUi.resumeSecondsFor
|
||||
);
|
||||
// Only "no usable pin" falls through. A superseded attempt means a
|
||||
// newer action already owns the screen — starting the route source
|
||||
// here would override the playback that action just began.
|
||||
if (pinned !== 'unavailable') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -460,7 +464,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
this.playVod(vodItem);
|
||||
}
|
||||
|
||||
|
||||
stopExternalPlayback(): Promise<void> {
|
||||
return this.playback.stopExternalPlayback();
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
|
||||
// Without this the persisted preference is only an icon: reopening the
|
||||
// movie would still start the playlist the route is on.
|
||||
await expect(service.playPinnedSource()).resolves.toBe(true);
|
||||
await expect(service.playPinnedSource()).resolves.toBe('played');
|
||||
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
@@ -121,7 +121,7 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
// the user is about to press says "Resume".
|
||||
service.seedResumePosition(2538);
|
||||
|
||||
await expect(service.playPinnedSource()).resolves.toBe(true);
|
||||
await expect(service.playPinnedSource()).resolves.toBe('played');
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: ALT_TWO.id }),
|
||||
@@ -179,7 +179,9 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
service.seedResumePosition(12);
|
||||
|
||||
const resumeFor = jest.fn().mockResolvedValue(3600);
|
||||
await expect(service.playPinnedSource(resumeFor)).resolves.toBe(true);
|
||||
await expect(service.playPinnedSource(resumeFor)).resolves.toBe(
|
||||
'played'
|
||||
);
|
||||
|
||||
expect(resumeFor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: ALT_TWO.id })
|
||||
@@ -225,7 +227,7 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
|
||||
await expect(
|
||||
service.playPinnedSource(jest.fn().mockResolvedValue(null))
|
||||
).resolves.toBe(true);
|
||||
).resolves.toBe('played');
|
||||
|
||||
// Positions are keyed by (playlist, stream). Carrying the route copy's
|
||||
// timecode into a copy the user has never opened drops them 42 minutes
|
||||
@@ -249,7 +251,7 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
|
||||
// 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);
|
||||
await expect(service.playPinnedSource()).resolves.toBe('played');
|
||||
|
||||
expect(resolver.resolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: ALT_TWO.id }),
|
||||
@@ -319,14 +321,14 @@ describe('VodMultiSourceHostService — pinning', () => {
|
||||
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
|
||||
await loading;
|
||||
|
||||
await expect(playing).resolves.toBe(true);
|
||||
await expect(playing).resolves.toBe('played');
|
||||
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves Play alone when nothing is pinned', async () => {
|
||||
await loadMovie([ALT_TWO]);
|
||||
|
||||
await expect(service.playPinnedSource()).resolves.toBe(false);
|
||||
await expect(service.playPinnedSource()).resolves.toBe('unavailable');
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,34 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
|
||||
expect(rowFor(ALT_TWO.id)?.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('tells a superseded pinned play apart from an unusable pin', 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]);
|
||||
|
||||
const slow = createDeferred<ReturnType<typeof resolvedFor>>();
|
||||
resolver.resolve.mockReturnValueOnce(slow.promise);
|
||||
const pinnedPlay = service.playPinnedSource();
|
||||
|
||||
// Wait until the pinned play is genuinely inside its resolve, so the
|
||||
// second click is what supersedes it rather than a race on setup.
|
||||
while (resolver.resolve.mock.calls.length === 0) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
// A second Play (double-click) supersedes the first while it resolves.
|
||||
await service.play(ALT_THREE.id);
|
||||
slow.resolve(resolvedFor(ALT_TWO, 0));
|
||||
|
||||
// Reporting "unavailable" here would have the caller start the ROUTE
|
||||
// source over the playback the newer click just began.
|
||||
await expect(pinnedPlay).resolves.toBe('superseded');
|
||||
});
|
||||
|
||||
it('drops a slower switch that a newer selection already superseded', async () => {
|
||||
await loadMovie([ALT_TWO, ALT_THREE]);
|
||||
|
||||
@@ -289,7 +317,7 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
|
||||
|
||||
lookup.resolve(3600);
|
||||
|
||||
await expect(playing).resolves.toBe(false);
|
||||
await expect(playing).resolves.toBe('superseded');
|
||||
expect(startPlayback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
runDiscovery,
|
||||
runFailover,
|
||||
toPinnedOutcome,
|
||||
switchToSource,
|
||||
type SwitchOutcome,
|
||||
} from './vod-multi-source-session';
|
||||
@@ -33,11 +34,12 @@ import { currentSourceRow } from './vod-multi-source-current-row';
|
||||
import { probeSource } from './vod-multi-source-probe';
|
||||
import {
|
||||
pinnedSourceAwaitingPlay,
|
||||
type PinnedPlayOutcome,
|
||||
pinnedSourceIdOf,
|
||||
playPinned,
|
||||
startPinnedSource,
|
||||
pinKeysFor,
|
||||
readPin,
|
||||
togglePinnedSource,
|
||||
commitPinToggle,
|
||||
type PinKeySets,
|
||||
} from './vod-multi-source-pin';
|
||||
import {
|
||||
@@ -236,50 +238,39 @@ export class VodMultiSourceHostService {
|
||||
);
|
||||
|
||||
/**
|
||||
* Start from the pinned source if there is one. Returns false when there is
|
||||
* nothing pinned to honour, leaving the caller's own Play path in charge.
|
||||
* Start from the pinned source if there is one. `unavailable` means there
|
||||
* is nothing pinned to honour, leaving the caller's own Play path in
|
||||
* charge; a superseded attempt must NOT fall through that way.
|
||||
*/
|
||||
async playPinnedSource(
|
||||
playPinnedSource(
|
||||
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>
|
||||
): Promise<boolean> {
|
||||
// The pin arrives with discovery; answering before the lookup lands
|
||||
// would make a persisted preference lose to worker latency.
|
||||
if (!(await this.stillOwnsScreen())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
): Promise<PinnedPlayOutcome> {
|
||||
const session = this.sessionToken;
|
||||
return playPinned({
|
||||
return startPinnedSource({
|
||||
controller: this.controller,
|
||||
pinnedSourceId: this.pendingPinnedSourceId(),
|
||||
loadInFlight: this.loadInFlight,
|
||||
pinnedSourceId: () => this.pendingPinnedSourceId(),
|
||||
resumeFor,
|
||||
isCurrent: () => session === this.sessionToken,
|
||||
play: (sourceId) => this.play(sourceId),
|
||||
play: (sourceId) => this.runPlay(sourceId),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a discovery still in flight, then say whether this film is
|
||||
* still the one on screen. Both callers touch the controller afterwards,
|
||||
* and the user can navigate during the wait — acting then would answer
|
||||
* one film's question with another film's sources.
|
||||
*/
|
||||
private async stillOwnsScreen(): Promise<boolean> {
|
||||
const session = this.sessionToken;
|
||||
await this.loadInFlight;
|
||||
return session === this.sessionToken;
|
||||
}
|
||||
|
||||
/** Play from a specific source once; does not change the pin. */
|
||||
async play(sourceId: string): Promise<boolean> {
|
||||
return (await this.runPlay(sourceId)) === 'played';
|
||||
}
|
||||
|
||||
/** As `play`, but keeping the distinction the pinned path needs. */
|
||||
private async runPlay(sourceId: string): Promise<PinnedPlayOutcome> {
|
||||
const candidate = this.controller.findSource(sourceId);
|
||||
if (!candidate || !this.bindings) {
|
||||
return false;
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
this._busySourceId.set(sourceId);
|
||||
try {
|
||||
return (await this.switchTo(candidate)) === 'switched';
|
||||
return toPinnedOutcome(await this.switchTo(candidate));
|
||||
} finally {
|
||||
// Only while this attempt still owns the spinner, or a slower
|
||||
// pick would clear the row that is still resolving.
|
||||
@@ -292,21 +283,17 @@ export class VodMultiSourceHostService {
|
||||
/** Pin or unpin this source as the movie's preferred one. */
|
||||
async togglePin(sourceId: string): Promise<void> {
|
||||
const session = this.sessionToken;
|
||||
const isPinned = this._sources().some(
|
||||
(source) => source.id === sourceId && source.isPinned
|
||||
);
|
||||
const pinned = await togglePinnedSource(
|
||||
this.pins,
|
||||
this.pinKeys,
|
||||
this.controller.findSource(sourceId),
|
||||
isPinned
|
||||
);
|
||||
const pinned = await commitPinToggle({
|
||||
pins: this.pins,
|
||||
keys: this.pinKeys,
|
||||
candidate: this.controller.findSource(sourceId),
|
||||
isPinned: this._sources().some(
|
||||
(source) => source.id === sourceId && source.isPinned
|
||||
),
|
||||
isCurrent: () => session === this.sessionToken,
|
||||
});
|
||||
|
||||
// The write is an IPC round-trip, and another movie can own the screen
|
||||
// by the time it returns. Committing then would apply THIS film's
|
||||
// answer to THAT film's controller — an unpin would clear the pin it
|
||||
// just loaded, and its Play button would stop honouring it.
|
||||
if (pinned === undefined || session !== this.sessionToken) {
|
||||
if (pinned === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -315,8 +302,8 @@ export class VodMultiSourceHostService {
|
||||
}
|
||||
|
||||
/** User-triggered availability check for one row. */
|
||||
async check(sourceId: string): Promise<void> {
|
||||
await probeSource(sourceId, {
|
||||
check(sourceId: string): Promise<void> {
|
||||
return probeSource(sourceId, {
|
||||
controller: this.controller,
|
||||
resolver: this.resolver,
|
||||
probes: this.probes,
|
||||
@@ -340,7 +327,9 @@ export class VodMultiSourceHostService {
|
||||
// "nowhere to go" against a controller whose discovery has not landed
|
||||
// yet would strand the user on the error screen with alternatives
|
||||
// arriving a moment later and nothing left to retry them.
|
||||
if (!(await this.stillOwnsScreen())) {
|
||||
const session = this.sessionToken;
|
||||
await this.loadInFlight;
|
||||
if (session !== this.sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,38 @@ export async function togglePinnedSource(
|
||||
return (await writePin(pins, keys, candidate)) ? candidate.id : undefined;
|
||||
}
|
||||
|
||||
/** What committing a pin toggle needs from the host. */
|
||||
export interface PinToggleDeps {
|
||||
pins: Pick<VodSourcePinService, 'set' | 'clear'>;
|
||||
keys: PinKeySets;
|
||||
candidate: VodSourceCandidate | null;
|
||||
isPinned: boolean;
|
||||
/** False once another movie owns the screen — the write is awaited. */
|
||||
isCurrent: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the pin and report the id the controller should hold, or `undefined`
|
||||
* when nothing should change.
|
||||
*
|
||||
* The write is an IPC round-trip and another movie can own the screen by the
|
||||
* time it returns. Committing then would apply THIS film's answer to THAT
|
||||
* film's controller — an unpin would clear the pin it just loaded, and its
|
||||
* Play button would stop honouring it.
|
||||
*/
|
||||
export async function commitPinToggle(
|
||||
deps: PinToggleDeps
|
||||
): Promise<string | null | undefined> {
|
||||
const pinned = await togglePinnedSource(
|
||||
deps.pins,
|
||||
deps.keys,
|
||||
deps.candidate,
|
||||
deps.isPinned
|
||||
);
|
||||
|
||||
return deps.isCurrent() ? pinned : undefined;
|
||||
}
|
||||
|
||||
/** Which keys a pin may be looked up under, stored under, and removed from. */
|
||||
export interface PinKeySets {
|
||||
/** Every alias the pin may be sitting under, most-trusted first. */
|
||||
@@ -189,6 +221,28 @@ function retirablePinKeys(keys: PinKeySets): string[] {
|
||||
return retire;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full pinned-play errand: wait out a discovery still in flight, confirm
|
||||
* this film still owns the screen, then start the pinned source.
|
||||
*
|
||||
* The wait matters — the pin arrives WITH discovery, so answering before the
|
||||
* lookup lands would make a persisted preference lose to worker latency — and
|
||||
* so does the re-check after it, because the user can navigate across it.
|
||||
*/
|
||||
export async function startPinnedSource(
|
||||
deps: Omit<PinnedPlayDeps, 'pinnedSourceId'> & {
|
||||
loadInFlight: Promise<void> | null;
|
||||
pinnedSourceId: () => string | null;
|
||||
}
|
||||
): Promise<PinnedPlayOutcome> {
|
||||
await deps.loadInFlight;
|
||||
if (!deps.isCurrent()) {
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
return playPinned({ ...deps, pinnedSourceId: deps.pinnedSourceId() });
|
||||
}
|
||||
|
||||
/** What starting the pinned source needs from the host. */
|
||||
export interface PinnedPlayDeps {
|
||||
controller: VodMultiSourceController;
|
||||
@@ -197,9 +251,19 @@ export interface PinnedPlayDeps {
|
||||
resumeFor?: (source: VodSourceCandidate) => Promise<number | null>;
|
||||
/** False once another movie owns the screen — the lookup is awaited. */
|
||||
isCurrent: () => boolean;
|
||||
play: (sourceId: string) => Promise<boolean>;
|
||||
play: (sourceId: string) => Promise<PinnedPlayOutcome>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a pinned play ended.
|
||||
*
|
||||
* `unavailable` is the ONLY outcome the caller may fall through on. A
|
||||
* superseded attempt means something newer already owns the screen — treating
|
||||
* that as "no usable pin" and starting the route source overrides the playback
|
||||
* the newer action just began.
|
||||
*/
|
||||
export type PinnedPlayOutcome = 'played' | 'superseded' | 'unavailable';
|
||||
|
||||
/**
|
||||
* Start the movie from its pinned source, at the position that source was
|
||||
* last left at.
|
||||
@@ -209,10 +273,12 @@ export interface PinnedPlayDeps {
|
||||
* row, which for this source is stale or missing entirely — resuming from it
|
||||
* would restart the film or jump to where a different copy was left.
|
||||
*/
|
||||
export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
|
||||
export async function playPinned(
|
||||
deps: PinnedPlayDeps
|
||||
): Promise<PinnedPlayOutcome> {
|
||||
const pinned = deps.controller.findSource(deps.pinnedSourceId ?? '');
|
||||
if (!pinned) {
|
||||
return false;
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
const looked = !!deps.resumeFor;
|
||||
@@ -221,7 +287,7 @@ export async function playPinned(deps: PinnedPlayDeps): Promise<boolean> {
|
||||
// it. Playing then would hand THIS film's source id to whatever movie now
|
||||
// owns the screen — starting it, if the id happens to exist there.
|
||||
if (!deps.isCurrent()) {
|
||||
return false;
|
||||
return 'superseded';
|
||||
}
|
||||
|
||||
if (looked) {
|
||||
|
||||
@@ -85,6 +85,22 @@ export function applyDiscoveredSources(
|
||||
controller.markPlaying(switchedTo.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch outcome in the pinned-play vocabulary.
|
||||
*
|
||||
* Only `unresolvable` may fall through to the route source; a superseded
|
||||
* attempt means something newer already owns the screen.
|
||||
*/
|
||||
export function toPinnedOutcome(
|
||||
outcome: SwitchOutcome
|
||||
): 'played' | 'superseded' | 'unavailable' {
|
||||
if (outcome === 'switched') {
|
||||
return 'played';
|
||||
}
|
||||
|
||||
return outcome === 'superseded' ? 'superseded' : 'unavailable';
|
||||
}
|
||||
|
||||
/** What `switchToSource` needs from the host, without reaching into it. */
|
||||
export interface SwitchDeps {
|
||||
controller: VodMultiSourceController;
|
||||
|
||||
Reference in new issue
Block a user