From b1ad9657c3b0e1455d8e2786aa0304cdb044b87e Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 01:16:56 +0200 Subject: [PATCH] fix(portals): probe like playback, and stop the pin answering for a remake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 +- .../src/app/api/main.preload.ts | 8 +- .../src/app/events/stream-probe.spec.ts | 45 +++ .../src/app/events/stream-probe.ts | 22 +- docs/architecture/vod-multi-source.md | 31 +- .../vod-details-route-playback.spec.ts | 303 ++++++++++++++++++ .../vod-details-route.component.spec.ts | 58 ---- .../vod-details-route.component.ts | 22 +- .../vod-multi-source-host-pin.spec.ts | 34 +- .../vod-multi-source-host-races.spec.ts | 21 ++ .../vod-multi-source-host.service.spec.ts | 24 +- .../vod-multi-source-host.service.ts | 87 ++--- .../vod-details/vod-multi-source-notice.ts | 13 +- .../lib/vod-details/vod-multi-source-pin.ts | 52 ++- .../lib/vod-details/vod-multi-source-probe.ts | 13 +- .../vod-details/vod-multi-source-session.ts | 76 +++++ .../src/lib/stream-probe.service.spec.ts | 45 ++- libs/services/src/lib/stream-probe.service.ts | 30 +- .../src/lib/electron-api.interface.ts | 5 +- .../src/lib/vod-source.interface.ts | 13 + 20 files changed, 718 insertions(+), 188 deletions(-) create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index a90e5de11..11a21c7dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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**: diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index a087cb384..d80e1cee3 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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) => diff --git a/apps/electron-backend/src/app/events/stream-probe.spec.ts b/apps/electron-backend/src/app/events/stream-probe.spec.ts index 6f98ee085..c7ac98ce0 100644 --- a/apps/electron-backend/src/app/events/stream-probe.spec.ts +++ b/apps/electron-backend/src/app/events/stream-probe.spec.ts @@ -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; + }; + 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; + }; + 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(); diff --git a/apps/electron-backend/src/app/events/stream-probe.ts b/apps/electron-backend/src/app/events/stream-probe.ts index 10be027b9..760b6f20f 100644 --- a/apps/electron-backend/src/app/events/stream-probe.ts +++ b/apps/electron-backend/src/app/events/stream-probe.ts @@ -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' } : {}), }, diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 490ed72dc..9b71fe1a2 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts new file mode 100644 index 000000000..4f823fded --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -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; + let consoleDebugSpy: jest.SpyInstance | undefined; + let consoleWarnSpy: jest.SpyInstance | undefined; + const selectedItem = signal(null); + const isLoadingDetails = signal(false); + const detailsError = signal(null); + const isFavorite = signal(false); + const currentPlaylist = signal<{ + id: string; + userAgent?: string; + referrer?: string; + origin?: string; + } | null>(null); + const vodStreams = signal[]>([]); + const vodCategories = signal[]>([]); + 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(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); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index fcb0f8905..319386657 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -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: { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 21332fd0a..6d9bfd748 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index f79c05c3b..11d354950 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -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); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index 3e6aaf8a3..06773e3db 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -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]); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index cac4feb3a..9bf0e0096 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -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); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index e8664c2ef..00a8a9a77 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -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 | 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 { + 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 { 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 { - if (!this.bindings) { - return 'superseded'; + private switchTo(candidate: VodSourceCandidate): Promise { + 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 { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts index 57697fa14..2d0efa981 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-notice.ts @@ -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, diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index ef46f96fb..f779002cf 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -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, + pins: Pick, matchKeys: readonly string[], candidate: VodSourceCandidate ): Promise { - 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. */ diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts index dc82285e9..910136796 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-probe.ts @@ -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; } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts index c9d86779b..fff564c16 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-session.ts @@ -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 { + 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. * diff --git a/libs/services/src/lib/stream-probe.service.spec.ts b/libs/services/src/lib/stream-probe.service.spec.ts index dd0f77790..c556dd2c7 100644 --- a/libs/services/src/lib/stream-probe.service.spec.ts +++ b/libs/services/src/lib/stream-probe.service.spec.ts @@ -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'); } ); diff --git a/libs/services/src/lib/stream-probe.service.ts b/libs/services/src/lib/stream-probe.service.ts index df5cf617f..8c9b16ab3 100644 --- a/libs/services/src/lib/stream-probe.service.ts +++ b/libs/services/src/lib/stream-probe.service.ts @@ -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 { 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 { 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 = { diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 972a15052..3b08a53dd 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; refreshPlaylist: ( payload: PlaylistRefreshPayload diff --git a/libs/shared/interfaces/src/lib/vod-source.interface.ts b/libs/shared/interfaces/src/lib/vod-source.interface.ts index 82b4656b1..1e938d341 100644 --- a/libs/shared/interfaces/src/lib/vod-source.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-source.interface.ts @@ -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. */