fix(portals): restart honours the pin, and a switch replaces the player

Three from review, all in the pinned-playback seam.

A pinned copy watched through resolved to its stored seconds, so the button
read Play — the label uses the in-progress rule — and then started near the
end. Both now go through one `isResumablePosition`, so the label and the start
point cannot disagree.

Restart sat beside a Resume that honours a foreign pin, but called `playVod`
and started the ROUTE copy — silently switching the user's playlist. It now
restarts whatever the primary button acts on, falling back to the route source
only when there is no usable pin.

Switching sources left a running external player alone. With MPV or VLC and
instance reuse off the backend spawns a second detached process, so both
sources kept playing and Stop owned only the newer one.

Also merges master, and puts the five host specs on a shared harness — they
each carried the same 31-line TestBed, which is what pushed two of them over
the file-size cap as cases were added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-29 00:26:53 +02:00
1 parent 26b820cc29
commit e1c150e7bb
12 files changed
+399 -135

No files matched your search

+11 -1
View File
@@ -453,7 +453,12 @@ which replace whatever timecode an alternative left in the controller.
While the pinned copy is the one playing, its live position wins over the row
that was stored before this session started.
A pin means the button plays a copy the page did not load a position for.
A pin means the button plays a copy the page did not load a position for. A
watched-through copy resolves to zero rather than its stored seconds, through
the same `isResumablePosition` rule the label uses — otherwise the button reads
Play and then seeks back to where the film ended. Restart follows the pin for
the same reason: with Resume honouring a pinned copy, a Restart that started
the route copy would silently switch the user's playlist.
`createPrimaryActionPosition` therefore looks that copy's row up and lets it
govern the label, the timecode and the Restart affordance — including when the
lookup comes back empty, because "never watched" is an answer: the button must
@@ -468,6 +473,11 @@ object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both —
reading only the object silently lost the codec on every array response, and
with it the "dub may differ" warning, which compares stated audio tracks.
Switching sources through `startResolvedPlayback` closes a matched external
session first. It REPLACES what is playing — with MPV or VLC and instance
reuse off, the backend would otherwise spawn a second detached player, leaving
both sources running and Stop owning only the newer one.
## Claims about the present
`isActive` means "the source a switch or Play would use" — selection, not
@@ -17,6 +17,7 @@ import { VodMultiSourceHostService } from './vod-multi-source-host.service';
import {
createPrimaryActionPosition,
formatPlaybackPosition,
isResumablePosition,
} from './vod-primary-action-position';
/**
@@ -145,10 +146,24 @@ export class VodDetailsMultiSourceUiService {
'vod'
);
/**
* The seconds a pinned play should resume from — `null` once the copy has
* been watched through, so a row the button offers as Play does not seek
* back to where it ended.
*/
readonly resumeSecondsFor = async (
source: VodSourceCandidate
): Promise<number | null> =>
(await this.positionFor(source))?.positionSeconds ?? null;
): Promise<number | null> => {
const position = await this.positionFor(source);
return isResumablePosition(position)
? (position?.positionSeconds ?? null)
: null;
};
/** True when the primary button acts on a pinned copy, not the route's. */
readonly primaryIsPinnedCopy = computed(
() => this.primaryAction.foreignPin() !== null
);
/** Title shown in the sources popover header. */
readonly multiSourceTitle = computed(() => this.movieTitle());
@@ -29,6 +29,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const addRecentItem = jest.fn();
const activeSession = signal<unknown>(null);
const closeSession = jest.fn().mockResolvedValue(undefined);
const activeSource = signal<PlayerContentInfo | null>(null);
function sessionFor(playlistId: string, contentXtreamId: number) {
@@ -61,7 +62,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession: jest.fn() },
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
@@ -141,6 +142,25 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
expect(service.matchedExternalPlayback()).toBeNull();
});
it('stops the running external player before switching sources', async () => {
activeSession.set(sessionFor(ROUTE_PLAYLIST, ROUTE_VOD_ID));
await service.startResolvedPlayback({
streamUrl: 'https://example.com/alt.mkv',
title: 'Example Movie',
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
});
// A switch REPLACES what is playing. With MPV/VLC and instance reuse
// off the backend spawns a second detached player otherwise: both
// sources keep running, and Stop owns only the newer one.
expect(closeSession).toHaveBeenCalled();
});
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
@@ -342,7 +342,17 @@ export class VodDetailsPlaybackService {
* template already renders through `@if`, so the player component and its
* engine survive and simply re-seek to `playback.startTime`.
*/
startResolvedPlayback(playback: ResolvedPortalPlayback): void {
async startResolvedPlayback(
playback: ResolvedPortalPlayback
): Promise<void> {
// A switch REPLACES what is playing. With MPV or VLC and instance
// reuse off, the backend spawns a second detached player otherwise —
// both sources keep running and Stop owns only the newer one.
const running = this.matchedExternalPlayback();
if (running) {
await this.externalPlayback.closeSession(running);
}
// Same movie, different source: still a view.
this.addToRecentlyViewed();
this.startPlayback(playback);
@@ -0,0 +1,92 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { VideoPlayer } from '@iptvnator/shared/interfaces';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* Who gets offered auto-failover, and what happens when the preference cannot
* be stored. Split from the playback-actions spec to keep both inside the
* repository's file-size rule.
*/
describe('VodDetailsRouteComponent — auto-failover', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const { selectedPlayer, snackBarOpen, updateSettings } = stubs;
beforeEach(async () => {
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
restoreLogging?.();
});
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
false
);
}
);
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
});
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
});
@@ -291,64 +291,68 @@ describe('VodDetailsRouteComponent — playback actions', () => {
});
});
describe('auto-failover toggle', () => {
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
it('offers no resume point for a pinned copy watched through', async () => {
const component = fixture.componentInstance;
const resumeSecondsFor = component['msUi'].resumeSecondsFor;
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 6900,
durationSeconds: 7200,
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
// The button reads Play at 95%, so the start must mean zero — not a
// seek back to where the film ended.
await expect(
resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBeNull();
});
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
false
);
}
);
it('still resumes a pinned copy left mid-film', async () => {
const component = fixture.componentInstance;
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
getPlaybackPosition.mockResolvedValue({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
positionSeconds: 2538,
durationSeconds: 7200,
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
await expect(
component['msUi'].resumeSecondsFor({
playlistId: 'playlist-2',
contentId: 991,
} as never)
).resolves.toBe(2538);
});
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
it('restarts the pinned copy, not the route copy', async () => {
currentPlaylist.set({ id: 'playlist-1' });
const component = fixture.componentInstance;
withActiveSource('playlist-1', 650020);
// Resume honours the pin, so the Restart beside it must too —
// otherwise the button quietly switches the user's playlist.
Object.defineProperty(component['msUi'], 'primaryIsPinnedCopy', {
configurable: true,
value: () => true,
});
const pinnedPlay = jest
.spyOn(component.multiSource, 'playPinnedSource')
.mockResolvedValue('played');
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
await component.restartVod({
movie_data: { stream_id: 650020, name: 'Example' },
} as never);
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
expect(pinnedPlay).toHaveBeenCalled();
// Restart means zero, whichever copy it starts.
const resumeFor = pinnedPlay.mock.calls[0][0];
await expect(resumeFor?.({} as never)).resolves.toBe(0);
});
});
@@ -158,7 +158,7 @@
@if (!isExternalStopAction()) {
<button
class="play-btn play-btn--restart"
(click)="playVod(item)"
(click)="restartVod(item)"
>
<mat-icon>replay</mat-icon>
{{ 'XTREAM.RESTART' | translate }}
@@ -315,7 +315,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
// about this one — without clearing it the caption and the
// badge would claim the new source while it is still opening.
this.msUi.reset();
this.playback.startResolvedPlayback(playback);
void this.playback.startResolvedPlayback(playback);
},
movie: this.multiSourceMovie,
playbackLive: this.playbackLive,
@@ -419,6 +419,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.playback.playVod(vodItem);
}
/**
* Restart from the beginning — of whatever the primary button acts on.
*
* When a pin points at another copy, Resume honours it, so Restart sitting
* beside it must too; calling `playVod` there would quietly switch the
* user to the route's playlist.
*/
async restartVod(vodItem: XtreamVodDetails | null): Promise<void> {
if (this.msUi.primaryIsPinnedCopy()) {
const outcome = await this.multiSource.playPinnedSource(async () =>
Promise.resolve(0)
);
if (outcome !== 'unavailable') {
return;
}
}
this.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
this.multiSource.markRouteSourceActive();
this.msUi.beginPlayback();
@@ -1,26 +1,10 @@
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,
setupVodMultiSourceHost,
} from './vod-multi-source-host.fixtures';
/**
@@ -31,65 +15,21 @@ import {
* actually holds — under every alias the movie can be looked up by.
*/
describe('VodMultiSourceHostService — pinning', () => {
let service: VodMultiSourceHostService;
const movie = signal<VodMultiSourceMovie | null>(null);
// Whatever is on screen; the pin path distinguishes it from selection.
const playbackLive = signal(false);
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<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await service.load(target);
}
function rowFor(sourceId: string) {
return service.sources().find((source) => source.id === sourceId);
}
const harness = setupVodMultiSourceHost();
const {
discovery,
pins,
playbackLive,
resolver,
startPlayback,
vodAutoFailover,
} = harness;
const loadMovie = harness.loadMovie;
const rowFor = harness.rowFor;
let service = harness.service;
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, playbackLive })
);
service = harness.reset();
});
it('plays the pinned source instead of the route playlist', async () => {
@@ -214,6 +154,29 @@ describe('VodMultiSourceHostService — pinning', () => {
expect(service.pendingPinnedSourceId()).toBe(ALT_TWO.id);
});
it('restarts a pinned copy that was watched through', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
await loadMovie([ALT_TWO]);
service.seedResumePosition(2538);
// The caller applies the same in-progress rule the button uses, so a
// finished copy arrives here as null. Anything else would show Play
// and then start near the end.
await expect(
service.playPinnedSource(jest.fn().mockResolvedValue(null))
).resolves.toBe('played');
expect(resolver.resolve).toHaveBeenCalledWith(
expect.objectContaining({ id: ALT_TWO.id }),
{ startTime: 0 }
);
});
it('starts a pinned source that was never watched from the beginning', async () => {
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
@@ -1,3 +1,15 @@
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 { VodMultiSourceHostService } from './vod-multi-source-host.service';
/**
* Shared fixtures for the multi-source host specs, extracted so the
* race-condition suite can live in its own file rather than duplicating setup.
@@ -82,3 +94,88 @@ export function createDeferred<T>() {
});
return { promise, resolve };
}
/**
* The TestBed every host spec needs.
*
* Five specs carried their own identical copy of this — the same five stubs,
* the same reset, the same bind. One harness so they cannot drift, and so a
* new case does not cost sixty lines of setup.
*/
export function setupVodMultiSourceHost() {
const movie = signal<VodMultiSourceMovie | null>(null);
/** Whatever is on screen; the pin path distinguishes it from selection. */
const playbackLive = signal(false);
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() };
const harness = {
movie,
playbackLive,
vodAutoFailover,
startPlayback,
discovery,
resolver,
pins,
probes,
service: null as unknown as VodMultiSourceHostService,
/** Call from `beforeEach`; returns the freshly bound service. */
reset(): VodMultiSourceHostService {
jest.resetAllMocks();
movie.set(null);
playbackLive.set(false);
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 } },
],
});
harness.service = TestBed.inject(VodMultiSourceHostService);
TestBed.runInInjectionContext(() =>
harness.service.bind({ startPlayback, movie, playbackLive })
);
return harness.service;
},
async loadMovie(
sources: VodSourceCandidate[],
target: VodMultiSourceMovie = MOVIE_A
): Promise<void> {
discovery.discover.mockResolvedValue({
sources,
matchKind: 'title-year',
});
await harness.service.load(target);
},
rowFor(sourceId: string) {
return harness.service
.sources()
.find((source) => source.id === sourceId);
},
};
return harness;
}
@@ -7,6 +7,7 @@ import type {
import {
createPrimaryActionPosition,
formatPlaybackPosition,
isResumablePosition,
} from './vod-primary-action-position';
/**
@@ -222,6 +223,25 @@ describe('createPrimaryActionPosition', () => {
});
});
describe('isResumablePosition', () => {
it('is false for a copy watched through', () => {
// The primary button reads Play at this point, so a pinned start must
// not seek back to where the film ended.
expect(
isResumablePosition({ ...position(6900), durationSeconds: 7200 })
).toBe(false);
});
it('is false at the very beginning', () => {
expect(isResumablePosition(position(0))).toBe(false);
expect(isResumablePosition(null)).toBe(false);
});
it('is true in the middle', () => {
expect(isResumablePosition(position(2538))).toBe(true);
});
});
describe('formatPlaybackPosition', () => {
it('drops the hour when there is none', () => {
expect(formatPlaybackPosition(position(123))).toBe('02:03');
@@ -39,6 +39,22 @@ export interface PrimaryActionPosition {
position: Signal<PlaybackPositionData | null>;
/** Whether the button should read Resume rather than Play. */
hasPosition: Signal<boolean>;
/** The pinned copy the button acts on, when it is not the route's own. */
foreignPin: Signal<VodSourceDescriptor | null>;
}
/**
* Whether a stored position is worth resuming from.
*
* Shared so the label and the start point cannot disagree: a copy watched to
* the end shows Play, and starting it must then mean starting it, not seeking
* back to the 95% mark it was left at.
*/
export function isResumablePosition(
position: PlaybackPositionData | null
): boolean {
const progress = getPortalPlaybackProgressPercent(position);
return progress > 0 && progress < 90;
}
export function createPrimaryActionPosition(
@@ -101,12 +117,9 @@ export function createPrimaryActionPosition(
return isPinnedCopy ? live : pinnedPosition();
});
const hasPosition = computed(() => {
const progress = getPortalPlaybackProgressPercent(position());
return progress > 0 && progress < 90;
});
const hasPosition = computed(() => isResumablePosition(position()));
return { position, hasPosition };
return { position, hasPosition, foreignPin };
}
/** `01:02:03`, or `02:03` for anything under an hour. */