mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
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:
1 parent
e3465d3a45
commit
b1ad9657c3
20 files changed
+718
-188
No files matched your search
@@ -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' } : {}),
|
||||
},
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
+23
-1
@@ -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');
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user