fix(portals): probe like playback, and stop the pin answering for a remake

Five findings from the latest review pass.

Writing a pin to every alias — last round's fix for stale aliases — was
wrong in the other direction: `title:{base}:` is shared by every remake, so
a known-year decision stored there answers for a different film. Pin Dune
(2021), open Dune (1984) before its year arrives, and it would start the
2021 source. A write now clears every alias and stores only the canonical
key, which retires the stale ones without making any of them ambiguous.

The probe checked a bare URL while playback sends the playlist's User-Agent,
Referer and Origin. A panel that requires them answers 401/403, so a stream
that plays perfectly was reported dead and penalised in failover ranking.

The switch toast interpolated the raw playlist name. Users routinely name a
playlist after the URL they pasted, so that line could put credentials over
the video; the notice now carries the same safe label the rows use.

External players have no timeupdate, so their polled position IS the live
one. Feeding it through the seed — which stops at the first value — froze
the resume point where playback started, and a switch an hour in rewound to
the beginning.

And auto-failover concluded "nowhere to go" when a stream failed before
discovery answered, stranding the user on the error screen.

Moves `switchTo` into the session module, which is where the rest of the
switch mechanics already live, and splits the route spec along the same
rendering/behaviour seam the other suites use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 01:16:56 +02:00
1 parent e3465d3a45
commit b1ad9657c3
20 files changed
+718 -188

No files matched your search

+2 -2
View File
@@ -826,8 +826,8 @@ engine` (restart required) or
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` lets `matchedExternalPlayback` still recognise it as this page's session. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist, offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup). No ffprobe — the binary is not bundled.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only; it awaits a discovery still in flight before concluding there is nowhere to go, since a stream can fail faster than SQLite answers. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
**Radio Player**:
@@ -48,6 +48,7 @@ import type {
Settings,
TmdbCacheEntry,
TmdbCacheMediaType,
StreamProbeHeaders,
VodSourcePin,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
@@ -610,8 +611,11 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
probeStreamUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('STREAM_PROBE_URL', { url, method }),
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD',
headers?: StreamProbeHeaders
) => ipcRenderer.invoke('STREAM_PROBE_URL', { url, method, ...headers }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
@@ -61,6 +61,51 @@ describe('stream probe', () => {
expect(request.responseType).toBeUndefined();
});
it('probes with the playlist headers the stream needs', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
await probeHandler?.(
{},
{
url: 'http://example.com/movie.mkv',
userAgent: 'MyPlayer/2.0',
referer: 'http://example.com/',
origin: 'http://example.com',
}
);
// A panel configured to require these answers 401/403 without them,
// and reporting a stream that plays fine as dead is the one thing
// this probe must not do.
const request = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
};
expect(request.headers?.['User-Agent']).toBe('MyPlayer/2.0');
expect(request.headers?.['Referer']).toBe('http://example.com/');
expect(request.headers?.['Origin']).toBe('http://example.com');
});
it('keeps its own client User-Agent when the playlist sets none', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv', userAgent: ' ' }
);
const request = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
};
expect(request.headers?.['User-Agent']).toContain('VLC');
expect(request.headers?.['Referer']).toBeUndefined();
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
@@ -25,6 +25,17 @@ const PROBE_TIMEOUT_MS = 10000;
export interface StreamProbePayload {
url: string;
method?: 'GET' | 'HEAD';
/**
* The headers the playlist would actually play this stream with.
*
* A panel that requires its configured User-Agent, Referer or Origin
* answers 401/403 to a request without them — and reporting that as a
* dead source would be a lie about a stream that plays fine. Probing the
* way playback requests is the only honest check.
*/
userAgent?: string | null;
referer?: string | null;
origin?: string | null;
}
export interface StreamProbeResponse {
@@ -50,7 +61,16 @@ export async function runStreamProbe(
method,
url: payload.url,
headers: {
'User-Agent': PROBE_CLIENT_USER_AGENT,
// The playlist's own User-Agent wins when it has one: the default
// below is a guess that merely gets past most WAFs, while that
// one is what the server was configured to expect.
'User-Agent': payload.userAgent?.trim() || PROBE_CLIENT_USER_AGENT,
...(payload.referer?.trim()
? { Referer: payload.referer.trim() }
: {}),
...(payload.origin?.trim()
? { Origin: payload.origin.trim() }
: {}),
// Some servers reject a bare HEAD but answer a tiny ranged GET.
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
+23 -8
View File
@@ -16,7 +16,7 @@ current source is dead, serves an unsupported codec, or buffers badly.
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`) |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency. **No codec probing** |
| Stream probe | HEAD → reachable + latency, sent with the playlist's own playback headers. **No codec probing** |
Stalker never reaches the `content` table (it would need a live authenticated
`get_ordered_list&search=` per portal), and M3U playlists are stored as a JSON
@@ -128,12 +128,20 @@ pinned under any poorer form of itself:
| before the TMDB id | `title:{base}:{year}` |
| before the year too | `title:{base}:` |
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first, and
**writes go to every one of them**. Reading every alias is what keeps a late
TMDB id from orphaning an earlier pin; writing every alias is what stops the
lower-trust ones from still pointing at the source the user just replaced —
which a reopen before enrichment lands would then play. Unpinning clears them
all, so a stale row cannot resurrect it.
`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first.
Reading every alias is what keeps a late TMDB id from orphaning an earlier pin.
A write **clears every alias and then stores only the canonical key** — both
halves matter, and each rules out the other's shortcut:
- Writing only the top key would leave the lower-trust aliases pointing at
whatever was pinned before, and a reopen that reads one of those (enrichment
has not landed, or its request failed) starts the source the user replaced.
- Writing the decision *into* every alias is not the fix either: the yearless
form is shared by every remake, so a known-year pin stored there would answer
for a different film — pin Dune (2021), open Dune (1984) before its year
arrives, and it starts the 2021 source. That alias stays readable and
unwritten.
The row only changes once the write lands. A pin the database refused is worse
than no pin at all — the icon promises the preference will be there next time,
@@ -228,6 +236,9 @@ Three details make the position survive:
- The carried position is the **live** one. `handleInlineTimeUpdate` reports to
`VodMultiSourceHostService.reportPosition()` *before* the 15-second
persistence throttle, so a switch does not rewind by up to 15 seconds.
External players have no `timeupdate` at all, so for them the polled
`playback_positions` value IS the live one and is reported directly —
seeding it would freeze the resume point where playback started.
- It is a single `.set()`, never `null` then set — a null in between would
destroy the player subtree and lose the engine.
- `playback_positions` is keyed `(playlistId, contentXtreamId, contentType)`, so
@@ -251,7 +262,11 @@ serves both, because two would eventually disagree.
## Failover
Only fires when `Settings.vodAutoFailover` is on. Ranking (`pickFailoverTarget`):
Only fires when `Settings.vodAutoFailover` is on, and it first awaits a
discovery still in flight — a stream can fail faster than SQLite answers,
and concluding "nowhere to go" against an empty controller would strand the
user on the error screen with alternatives landing a moment later.
Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
2. probed reachable; probed failing is penalised
@@ -0,0 +1,303 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Location } from '@angular/common';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
/**
* What the primary button and the resume point do, as opposed to what the
* page renders. Split from the rendering spec to keep both inside the
* repository's file-size rule.
*/
describe('VodDetailsRouteComponent — playback actions', () => {
let fixture: ComponentFixture<VodDetailsRouteComponent>;
let consoleDebugSpy: jest.SpyInstance | undefined;
let consoleWarnSpy: jest.SpyInstance | undefined;
const selectedItem = signal<XtreamVodDetails | null>(null);
const isLoadingDetails = signal(false);
const detailsError = signal<string | null>(null);
const isFavorite = signal(false);
const currentPlaylist = signal<{
id: string;
userAgent?: string;
referrer?: string;
origin?: string;
} | null>(null);
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
const fetchVodDetailsWithMetadata = jest.fn();
const checkFavoriteStatus = jest.fn();
const setSelectedItem = jest.fn();
const toggleFavorite = jest.fn();
const constructVodStreamUrl = jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4');
const addRecentItem = jest.fn();
const downloads = signal([]);
const getPlaybackPosition = jest.fn().mockResolvedValue(null);
const activeSession = signal<unknown>(null);
const closeSession = jest.fn();
beforeEach(async () => {
const consoleDebug = console.debug.bind(console);
const consoleWarn = console.warn.bind(console);
consoleDebugSpy = jest
.spyOn(console, 'debug')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' ||
args[0] === '[VodDetailsPlayback]'
) {
return;
}
consoleDebug(...args);
});
consoleWarnSpy = jest
.spyOn(console, 'warn')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' &&
args[1] === 'Deferring VOD details init: playlist not ready'
) {
return;
}
consoleWarn(...args);
});
selectedItem.set(null);
isLoadingDetails.set(false);
detailsError.set(null);
isFavorite.set(false);
currentPlaylist.set(null);
vodStreams.set([]);
vodCategories.set([]);
fetchVodDetailsWithMetadata.mockClear();
checkFavoriteStatus.mockClear();
setSelectedItem.mockClear();
toggleFavorite.mockClear();
constructVodStreamUrl.mockClear();
addRecentItem.mockClear();
getPlaybackPosition.mockClear();
activeSession.set(null);
closeSession.mockClear();
await TestBed.configureTestingModule({
imports: [VodDetailsRouteComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({
vodId: '650020',
categoryId: '235',
}),
snapshot: {
params: {
vodId: '650020',
categoryId: '235',
},
},
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
{
provide: XtreamStore,
useValue: {
selectedItem,
isLoadingDetails,
detailsError,
isFavorite,
currentPlaylist,
vodStreams,
vodCategories,
fetchVodDetailsWithMetadata,
checkFavoriteStatus,
setSelectedItem,
toggleFavorite,
constructVodStreamUrl,
addRecentItem,
},
},
{
provide: SettingsStore,
useValue: {
theme: signal('dark'),
},
},
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
downloads,
isDownloaded: jest.fn().mockReturnValue(false),
isDownloading: jest.fn().mockReturnValue(false),
startDownload: jest.fn(),
getDownloadedFilePath: jest.fn(),
playDownload: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession, closeSession },
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition,
savePlaybackPosition: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: Location,
useValue: {
back: jest.fn(),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
consoleDebugSpy?.mockRestore();
consoleWarnSpy?.mockRestore();
});
it('stops the external player when the button says Stop', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const component = fixture.componentInstance;
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
expect(component.isExternalStopAction()).toBe(true);
await component.onPrimaryAction({} as XtreamVodDetails);
// Consulting the pin first would launch a second player while the
// first keeps running — the control doing the opposite of its label.
expect(playPinned).not.toHaveBeenCalled();
expect(closeSession).toHaveBeenCalled();
});
it('follows external playback progress, which has no timeupdate', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// MPV/VLC report only through the polled position, so this IS their
// live timecode. Treating it as a one-shot seed would freeze the
// resume point at the start and rewind a later source switch by
// however long the user had been watching.
playback.vodPlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 120,
durationSeconds: 7744,
});
fixture.detectChanges();
expect(reported).toHaveBeenLastCalledWith(120);
playback.vodPlaybackPosition.set({
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
positionSeconds: 3600,
durationSeconds: 7744,
});
fixture.detectChanges();
expect(reported).toHaveBeenLastCalledWith(3600);
});
it('holds the resume point until the engine has seeked to it', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie/650020.mp4',
title: 'City of McFarland',
startTime: 2538,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// A resuming engine emits timeupdates at ~0 on its way to 2538. That
// is not where the film is, and multi-source must not switch or fail
// over back to the beginning because of it.
component.handleInlineTimeUpdate({ currentTime: 0.2, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2538);
component.handleInlineTimeUpdate({ currentTime: 2540, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2540);
// One-shot latch, not a filter: a deliberate seek backwards counts.
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(12);
});
});
@@ -299,64 +299,6 @@ describe('VodDetailsRouteComponent', () => {
expect(host.querySelector('button.play-btn')).not.toBeNull();
});
it('stops the external player when the button says Stop', async () => {
currentPlaylist.set({ id: 'playlist-1' });
activeSession.set({
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const component = fixture.componentInstance;
const playPinned = jest.spyOn(
component.multiSource,
'playPinnedSource'
);
expect(component.isExternalStopAction()).toBe(true);
await component.onPrimaryAction({} as XtreamVodDetails);
// Consulting the pin first would launch a second player while the
// first keeps running — the control doing the opposite of its label.
expect(playPinned).not.toHaveBeenCalled();
expect(closeSession).toHaveBeenCalled();
});
it('holds the resume point until the engine has seeked to it', () => {
const component = fixture.componentInstance;
const playback = fixture.debugElement.injector.get(
VodDetailsPlaybackService
);
playback.inlinePlayback.set({
streamUrl: 'http://example.com/movie/650020.mp4',
title: 'City of McFarland',
startTime: 2538,
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 650020,
contentType: 'vod',
},
});
const reported = jest.spyOn(component.multiSource, 'reportPosition');
// A resuming engine emits timeupdates at ~0 on its way to 2538. That
// is not where the film is, and multi-source must not switch or fail
// over back to the beginning because of it.
component.handleInlineTimeUpdate({ currentTime: 0.2, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2538);
component.handleInlineTimeUpdate({ currentTime: 2540, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(2540);
// One-shot latch, not a filter: a deliberate seek backwards counts.
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(12);
});
it('renders usable metadata when backdrop_path is absent at runtime', () => {
selectedItem.set({
info: {
@@ -330,14 +330,26 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
activeSource: this.activeAlternativeSource,
});
// Nothing reports a live position until the player emits its first
// timeupdate, so a switch made straight off the Resume button would
// otherwise resolve at zero and restart the film.
effect(() => {
const position = this.playback.vodPlaybackPosition();
if (position) {
this.multiSource.seedResumePosition(position.positionSeconds);
if (!position) {
return;
}
if (this.inlinePlayback()) {
// Seeding only: the inline player reports the live timecode
// itself, and this stored value lags it by up to the save
// throttle — applying it would rewind the switch. Before the
// first timeupdate there is nothing to protect, so a switch
// made straight off the Resume button still resumes.
this.multiSource.seedResumePosition(position.positionSeconds);
return;
}
// MPV and VLC have no timeupdate to report; this polled position
// IS their live one, so a source switch after an hour in an
// external player must not rewind to where it started.
this.multiSource.reportPosition(position.positionSeconds);
});
this.multiSource.bind({
// Route every switch through the same inline-vs-external fork a
@@ -124,26 +124,27 @@ describe('VodMultiSourceHostService — pinning', () => {
);
});
it('points every alias of the movie at the newly pinned source', async () => {
it('retires every stale alias and writes only the canonical key', async () => {
await loadMovie([ALT_TWO]);
const matchKeys: string[] = pins.get.mock.calls[0][0];
expect(matchKeys.length).toBeGreaterThan(1);
await service.togglePin(ALT_TWO.id);
// Writing only the most-trusted key leaves the others pointing at
// whatever was pinned before, and a reopen that reads a lower-trust
// alias — because enrichment has not landed yet — starts the source
// the user just replaced.
expect(pins.set).toHaveBeenCalledTimes(matchKeys.length);
for (const matchKey of matchKeys) {
expect(pins.set).toHaveBeenCalledWith({
matchKey,
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
}
// Leaving the other aliases alone would keep them pointing at whatever
// was pinned before, and a reopen that reads one — because enrichment
// has not landed yet — starts the source the user just replaced.
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
// But the decision is NOT written back into them: `title:{base}:` is
// shared by every remake, so a known-year pin stored there would
// answer for a different film.
expect(pins.set).toHaveBeenCalledTimes(1);
expect(pins.set).toHaveBeenCalledWith({
matchKey: matchKeys[0],
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
});
it('does not show a pin the database refused to store', async () => {
@@ -213,9 +214,8 @@ describe('VodMultiSourceHostService — pinning', () => {
await service.togglePin(ALT_TWO.id);
expect(pins.clear).toHaveBeenCalledWith(matchKeys);
// Unpinning writes nothing further — the pin round-trip is one write
// per alias, then a single clear of all of them.
expect(pins.set).toHaveBeenCalledTimes(matchKeys.length);
// Unpinning writes nothing further.
expect(pins.set).toHaveBeenCalledTimes(1);
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false);
pins.get.mockResolvedValue(pin);
@@ -177,6 +177,27 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('waits for a discovery still in flight before giving up', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
vodAutoFailover.set(true);
const loading = service.load(MOVIE_A);
// The stream died faster than the database answered. Concluding
// "nowhere to go" here would strand the user on the error screen with
// alternatives landing a moment later and nothing left to retry them.
const failingOver = service.failover();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loading;
await expect(failingOver).resolves.not.toBeNull();
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('leaves the spinner on the row that is still resolving', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
@@ -157,6 +157,24 @@ describe('VodMultiSourceHostService', () => {
);
});
it('never puts a credential-bearing playlist name in the notice', async () => {
// Users routinely name a playlist after the URL they pasted, and this
// string goes straight into a toast over the video.
const leaky = {
...ALT_TWO,
playlistName:
'http://portal.example.com:8080/get.php?username=alice&password=hunter2',
};
await loadMovie([leaky]);
await service.play(leaky.id);
const notice = service.lastSwitch();
expect(notice?.playlistName).not.toContain('hunter2');
expect(notice?.playlistName).not.toContain('alice');
expect(notice?.playlistName).toBe('portal.example.com:8080');
});
it('announces every switch with the target playlist', async () => {
await loadMovie([ALT_TWO]);
service.reportPosition(2538);
@@ -259,8 +277,12 @@ describe('VodMultiSourceHostService', () => {
await service.check(ALT_TWO.id);
// Probed with the playlist's own playback headers, or a panel that
// requires them answers 403 and a working source looks dead.
expect(probes.probe).toHaveBeenCalledWith(
`http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`
`http://${ALT_TWO.playlistId}/${ALT_TWO.contentId}.mkv`,
'HEAD',
expect.objectContaining({ userAgent: undefined })
);
expect(rowFor(ALT_TWO.id)?.probe).toEqual(PROBE_OK);
});
@@ -24,12 +24,10 @@ import {
import {
applyDiscoveredSources,
runFailover,
switchToSource,
type SwitchOutcome,
} from './vod-multi-source-session';
import {
buildSwitchNotice,
type VodMultiSourceSwitchNotice,
} from './vod-multi-source-notice';
import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice';
import { currentSourceRow } from './vod-multi-source-current-row';
import { probeSource } from './vod-multi-source-probe';
import {
@@ -90,6 +88,8 @@ export class VodMultiSourceHostService {
private switchToken = 0;
private lastMovieKey: string | null = null;
private movieIdentity: string | null = null;
/** Resolves once the discovery on the way has published its sources. */
private loadInFlight: Promise<void> | null = null;
/** True while `session`/`switch` still describe the operation in flight. */
private isCurrentSwitch(session: number, attempt: number): boolean {
@@ -183,6 +183,18 @@ export class VodMultiSourceHostService {
* failover a clean tried-set for sources it has already burned.
*/
async load(movie: VodMultiSourceMovie): Promise<void> {
const finished = this.discover(movie);
this.loadInFlight = finished;
try {
await finished;
} finally {
if (this.loadInFlight === finished) {
this.loadInFlight = null;
}
}
}
private async discover(movie: VodMultiSourceMovie): Promise<void> {
const token = ++this.discoveryToken;
const identity = vodMultiSourceSessionKey(movie);
const sameMovie = identity === this.movieIdentity;
@@ -319,6 +331,12 @@ export class VodMultiSourceHostService {
return null;
}
// A stream can fail faster than the database answers. Concluding
// "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.
await this.loadInFlight;
const switched = await runFailover(this.controller, (candidate) =>
this.switchTo(candidate)
);
@@ -334,60 +352,25 @@ export class VodMultiSourceHostService {
this.controller.seedResumeSeconds(seconds);
}
private async switchTo(
candidate: VodSourceCandidate
): Promise<SwitchOutcome> {
if (!this.bindings) {
return 'superseded';
private switchTo(candidate: VodSourceCandidate): Promise<SwitchOutcome> {
const bindings = this.bindings;
if (!bindings) {
return Promise.resolve('superseded');
}
const session = this.sessionToken;
const attempt = ++this.switchToken;
// Snapshot the controller: `load()` swaps in a fresh one for a new
// movie, and the continuation below must never touch that one.
const controller = this.controller;
// Read the LIVE position, not the persisted one: the DB value lags by
// up to 15 seconds and switching would visibly rewind.
const resumeSeconds = Math.floor(controller.getResumeSeconds());
const previous = controller.findSource(
controller.activeSourceId() ?? ''
);
const resolved = await this.resolver.resolve(candidate, {
startTime: resumeSeconds,
return switchToSource(candidate, {
controller: this.controller,
resolve: (target, options) =>
this.resolver.resolve(target, options),
startPlayback: (playback) => bindings.startPlayback(playback),
isCurrent: () => this.isCurrentSwitch(session, attempt),
setPreviousSource: (id) => this._previousSourceId.set(id),
setNotice: (notice) => this._lastSwitch.set(notice),
publish: () => this.publish(),
});
if (!this.isCurrentSwitch(session, attempt)) {
// Superseded mid-flight — dropping the result is the whole point.
return 'superseded';
}
if (!resolved) {
// Mark it tried without making it active: a source we cannot even
// build a URL for must not be offered again by failover, but it
// never started playing either.
controller.markTried(candidate.id);
this.publish();
return 'unresolvable';
}
controller.updateSource(resolved.candidate);
this._previousSourceId.set(previous?.id ?? null);
controller.setActiveSource(candidate.id);
controller.setResumeSeconds(resumeSeconds);
this.bindings.startPlayback(resolved.playback);
this._lastSwitch.set(
buildSwitchNotice(
candidate,
resolved.candidate,
previous,
resumeSeconds
)
);
this.publish();
return 'switched';
}
private publish(): void {
@@ -1,5 +1,8 @@
import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access';
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
import {
playlistDisplayLabel,
type VodSourceCandidate,
} from '@iptvnator/shared/interfaces';
/** What a switch tells the user. Lives with the code that builds it. */
export interface VodMultiSourceSwitchNotice {
@@ -25,7 +28,13 @@ export function buildSwitchNotice(
resumeSeconds: number
): VodMultiSourceSwitchNotice {
return {
playlistName: candidate.playlistName,
// Safe by construction, not by call site: users routinely name a
// playlist after the URL they pasted, credentials and all, and this
// string goes into a toast that sits over the video.
playlistName: playlistDisplayLabel(
candidate.playlistName,
candidate.playlistId
),
resumeSeconds,
audioMayDiffer: audioDiffersFactually(previous, resolved),
quality: resolved.quality?.value,
@@ -26,46 +26,42 @@ export async function readPin(
}
/**
* Persist the pin for `candidate` under EVERY alias of the movie.
* Persist the pin for `candidate` under the movie's most-trusted key, having
* first cleared every alias it could otherwise be found under.
*
* Writing only the most-trusted key would leave the others pointing at
* whatever was pinned before: reopening the movie before enrichment lands —
* or when that request fails — reads a lower-trust alias and starts the source
* the user just replaced. Since lookups accept any alias, every alias has to
* agree.
* Both halves are load-bearing, and each rules out the other's obvious
* shortcut:
*
* Success is the most-trusted key's: it is the one a later lookup reaches
* first, and a half-written alias set is no worse than the stale one it
* replaced (unpinning clears them all regardless).
* - Writing ONLY the top key leaves the lower-trust aliases pointing at
* whatever was pinned before, and a reopen that reads one of those —
* because enrichment has not landed yet — starts the source the user just
* replaced. Hence the clear.
* - Writing the decision INTO every alias is not the fix either: the yearless
* `title:{base}:` form is shared by every remake, so a known-year pin stored
* there would answer for a different film — pin Dune (2021), open Dune
* (1984) before its year arrives, and it would start the 2021 source. That
* alias stays readable, for genuinely pre-enrichment pins, and unwritten.
*/
export async function writePin(
pins: Pick<VodSourcePinService, 'set'>,
pins: Pick<VodSourcePinService, 'set' | 'clear'>,
matchKeys: readonly string[],
candidate: VodSourceCandidate
): Promise<boolean> {
const keys = matchKeys.length
? matchKeys
: [buildVodSourceMatchKey(candidate)].filter(
(key): key is string => !!key
);
if (keys.length === 0) {
const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate);
if (!matchKey) {
return false;
}
await erasePin(pins, matchKeys);
// The write can fail — no bridge, or the DB refused it. Reporting success
// then would show a pin the next visit does not have.
const written = await Promise.all(
keys.map((matchKey) =>
pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
})
)
);
return written[0] === true;
return pins.set({
matchKey,
playlistId: candidate.playlistId,
contentId: candidate.contentId,
portalType: candidate.portalType,
});
}
/** Clears every alias, so unpinning is not undone by a stale row. */
@@ -47,7 +47,18 @@ export async function probeSource(
}
controller.updateSource(resolved.candidate);
const result = await deps.probes.probe(resolved.playback.streamUrl);
// Probe the way this playlist actually plays: a panel that requires its
// own User-Agent, Referer or Origin refuses a bare request, and calling a
// working stream unavailable would poison the failover ranking too.
const result = await deps.probes.probe(
resolved.playback.streamUrl,
'HEAD',
{
userAgent: resolved.playback.userAgent,
referer: resolved.playback.referer,
origin: resolved.playback.origin,
}
);
if (!isCurrent(session)) {
return;
}
@@ -1,8 +1,13 @@
import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access';
import type {
ResolvedPortalPlayback,
VodSourceCandidate,
VodSourceMatchKind,
} from '@iptvnator/shared/interfaces';
import {
buildSwitchNotice,
type VodMultiSourceSwitchNotice,
} from './vod-multi-source-notice';
/**
* Session mechanics for one open movie: how a discovery folds into the state
@@ -71,6 +76,77 @@ export function applyDiscoveredSources(
controller.setActiveSource(switchedTo.id);
}
/** What `switchToSource` needs from the host, without reaching into it. */
export interface SwitchDeps {
controller: VodMultiSourceController;
resolve: (
candidate: VodSourceCandidate,
options: { startTime: number }
) => Promise<{
playback: ResolvedPortalPlayback;
candidate: VodSourceCandidate;
} | null>;
startPlayback: (playback: ResolvedPortalPlayback) => void;
/** False once a newer switch, or another movie, owns the screen. */
isCurrent: () => boolean;
setPreviousSource: (sourceId: string | null) => void;
setNotice: (notice: VodMultiSourceSwitchNotice) => void;
publish: () => void;
}
/**
* Put one source on screen, carrying the timecode across.
*
* The controller is taken from `deps` rather than read live, because `load()`
* swaps in a fresh one for a new movie and the continuation after the await
* must never touch that one.
*/
export async function switchToSource(
candidate: VodSourceCandidate,
deps: SwitchDeps
): Promise<SwitchOutcome> {
const { controller } = deps;
// The LIVE position, not the persisted one: the stored value lags by up to
// the save throttle and switching would visibly rewind.
const resumeSeconds = Math.floor(controller.getResumeSeconds());
const previous = controller.findSource(controller.activeSourceId() ?? '');
const resolved = await deps.resolve(candidate, {
startTime: resumeSeconds,
});
if (!deps.isCurrent()) {
// Superseded mid-flight — dropping the result is the whole point.
return 'superseded';
}
if (!resolved) {
// Mark it tried without making it active: a source we cannot even
// build a URL for must not be offered again by failover, but it never
// started playing either.
controller.markTried(candidate.id);
deps.publish();
return 'unresolvable';
}
controller.updateSource(resolved.candidate);
deps.setPreviousSource(previous?.id ?? null);
controller.setActiveSource(candidate.id);
controller.setResumeSeconds(resumeSeconds);
deps.startPlayback(resolved.playback);
deps.setNotice(
buildSwitchNotice(
candidate,
resolved.candidate,
previous,
resumeSeconds
)
);
deps.publish();
return 'switched';
}
/**
* Move to the best untried source after a playback failure.
*
@@ -1,9 +1,10 @@
import { Injector, runInInjectionContext } from '@angular/core';
import type { StreamProbeHeaders } from '@iptvnator/shared/interfaces';
import { StreamProbeService } from './stream-probe.service';
type ProbeFn = jest.Mock<
Promise<{ status: number; url: string; latencyMs?: number }>,
[string, ('GET' | 'HEAD')?]
[string, ('GET' | 'HEAD')?, StreamProbeHeaders?]
>;
/**
@@ -45,12 +46,38 @@ describe('StreamProbeService', () => {
const result = await service.probe('http://x/y.mkv');
expect(probe).toHaveBeenCalledWith('http://x/y.mkv', 'HEAD');
expect(probe).toHaveBeenCalledWith('http://x/y.mkv', 'HEAD', undefined);
expect(result).toEqual(
expect.objectContaining({ status: 'ok', latencyMs: 42 })
);
});
it('carries the playlist headers into both attempts', async () => {
const headers = { userAgent: 'MyPlayer/2.0', referer: 'http://x/' };
const probe = jest
.fn()
.mockResolvedValueOnce({ status: 405, url: 'http://x/y.mkv' })
.mockResolvedValueOnce({ status: 200, url: 'http://x/y.mkv' });
const service = withBridge(probe as ProbeFn);
await service.probe('http://x/y.mkv', 'HEAD', headers);
// The GET retry has to carry them too, or the fallback answers 403
// for a stream the first attempt was merely not allowed to HEAD.
expect(probe).toHaveBeenNthCalledWith(
1,
'http://x/y.mkv',
'HEAD',
headers
);
expect(probe).toHaveBeenNthCalledWith(
2,
'http://x/y.mkv',
'GET',
headers
);
});
it('maps a refusal to fail', async () => {
const service = withBridge(ok(403));
@@ -83,8 +110,18 @@ describe('StreamProbeService', () => {
const result = await service.probe('http://x/y.mkv');
expect(probe).toHaveBeenNthCalledWith(1, 'http://x/y.mkv', 'HEAD');
expect(probe).toHaveBeenNthCalledWith(2, 'http://x/y.mkv', 'GET');
expect(probe).toHaveBeenNthCalledWith(
1,
'http://x/y.mkv',
'HEAD',
undefined
);
expect(probe).toHaveBeenNthCalledWith(
2,
'http://x/y.mkv',
'GET',
undefined
);
expect(result.status).toBe('ok');
}
);
+24 -6
View File
@@ -1,5 +1,8 @@
import { Injectable } from '@angular/core';
import type { VodSourceProbeResult } from '@iptvnator/shared/interfaces';
import type {
StreamProbeHeaders,
VodSourceProbeResult,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
/**
@@ -43,9 +46,15 @@ export class StreamProbeService {
return entry.result;
}
/**
* @param headers what the owning playlist plays this stream with. A panel
* that requires them answers 401/403 without them, and a source that plays
* fine must never be reported as dead.
*/
async probe(
url: string,
method: 'GET' | 'HEAD' = 'HEAD'
method: 'GET' | 'HEAD' = 'HEAD',
headers?: StreamProbeHeaders
): Promise<VodSourceProbeResult> {
if (!this.isAvailable || !url) {
// Not "offline" — we are simply unable to find out.
@@ -62,7 +71,7 @@ export class StreamProbeService {
return pending;
}
const request = this.runProbe(url, method).finally(() => {
const request = this.runProbe(url, method, headers).finally(() => {
this.inFlight.delete(url);
});
this.inFlight.set(url, request);
@@ -76,19 +85,28 @@ export class StreamProbeService {
private async runProbe(
url: string,
method: 'GET' | 'HEAD'
method: 'GET' | 'HEAD',
headers?: StreamProbeHeaders
): Promise<VodSourceProbeResult> {
let result: VodSourceProbeResult;
try {
let response = await window.electron.probeStreamUrl(url, method);
let response = await window.electron.probeStreamUrl(
url,
method,
headers
);
// Plenty of stream servers reject HEAD outright yet serve the media
// happily over GET. Reporting those as unavailable would be a
// confident lie, so retry once with the ranged GET the main process
// already supports.
if (method === 'HEAD' && refusesHeadRequests(response.status)) {
response = await window.electron.probeStreamUrl(url, 'GET');
response = await window.electron.probeStreamUrl(
url,
'GET',
headers
);
}
result = {
@@ -33,6 +33,7 @@ import {
import { PortalDebugEvent } from './portal-debug.interface';
import { CatalogTitleMatch } from './catalog-title-match.interface';
import {
StreamProbeHeaders,
VodSourceCandidateRow,
VodSourcePin,
} from './vod-source.interface';
@@ -688,7 +689,9 @@ export interface ElectronBridgeApi {
/** Generic stream reachability probe (VOD multi-source availability) */
probeStreamUrl: (
url: string,
method?: 'GET' | 'HEAD'
method?: 'GET' | 'HEAD',
/** Playback headers the owning playlist requires, when it has any. */
headers?: StreamProbeHeaders
) => Promise<ElectronBridgeXtreamProbeResult>;
refreshPlaylist: (
payload: PlaylistRefreshPayload
@@ -51,6 +51,19 @@ export type VodSourceProbeStatus =
*/
| 'unknown';
/**
* Playback headers a playlist requires, carried into the probe.
*
* A panel configured to demand its own User-Agent, Referer or Origin answers
* 401/403 without them, and calling a stream that plays fine "unavailable" is
* exactly the confident lie this feature refuses to tell.
*/
export interface StreamProbeHeaders {
userAgent?: string | null;
referer?: string | null;
origin?: string | null;
}
export interface VodSourceProbeResult {
status: VodSourceProbeStatus;
/** HTTP status when one was actually received; 0 when none was. */