fix(portals): gate VOD auto-failover on engines that can report failures

Merges master and resolves the collision with its shared playback helpers,
then closes two Codex findings.

Master extracted the Play/Stop button state and the inline position writer
that this branch had modified. Rather than forking private copies back out of
Xtream, both behaviours move into the shared helpers: `alsoOwns` lets a page
own an external session launched for a copy of the same film in another
playlist, and the resume latch — which stops a timeupdate emitted before the
engine reaches `startTime` from overwriting the point being resumed from —
now protects Stalker too, which had the same bug.

Auto-failover was offered on every engine, but only the built-in web players
raise the playback diagnostic that reaches `onPlaybackFailed()`: Embedded MPV
has its diagnostics suppressed and MPV/VLC play outside the app. The toggle is
now hidden there, in settings and in the sources menu, instead of promising a
switch that can never happen.

`setAutoFailover` also ignored `updateSettings()`, which patches memory first
and rejects if the write fails — the toggle looked saved, silently reverted on
restart, and the rejection surfaced only as an unhandled promise. It now
reports the failure like the settings form's own save paths.

The three VOD-details route specs each carried a near-identical 150-line
TestBed; they now share one harness, which is what makes room for the new
cases (1012 -> 584 lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 10:20:02 +02:00
commit 56733db1ec
37 files changed
+1795 -910

No files matched your search

+6 -6
View File
@@ -59,7 +59,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
@@ -86,7 +86,7 @@ jobs:
- name: Restore pinned Linux runtime and immutable source inputs
id: linux-runtime-cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
vendor/embedded-mpv/linux-x64/include
@@ -376,7 +376,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
@@ -525,7 +525,7 @@ jobs:
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: |
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include
@@ -1119,7 +1119,7 @@ jobs:
# TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: |
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include
@@ -1532,7 +1532,7 @@ jobs:
- name: Create Draft Release
id: draft-release
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: true
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
+2 -2
View File
@@ -101,7 +101,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
@@ -139,7 +139,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
+2 -2
View File
@@ -55,7 +55,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
@@ -109,7 +109,7 @@ jobs:
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '22'
cache: 'pnpm'
+1 -1
View File
@@ -837,7 +837,7 @@ 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. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. 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 — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. 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. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. 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; it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. 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.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. 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`
@@ -0,0 +1,204 @@
import { test, expect } from '@playwright/test';
import { spawn, ChildProcess } from 'child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
utimesSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
dataDirHardMaxAgeMs,
dataDirOwnerMarker,
dataDirPrefix,
orphanDataDirMaxAgeMs,
readDataDirOwner,
reapOrphanedDataDirs,
writeDataDirOwnerMarker,
} from './data-dir-reaper';
/**
* Coverage for the orphaned-data-directory sweep.
*
* These are filesystem-level rather than app-level, but they belong in the
* Electron suite: it runs on Linux, macOS and Windows, and the sweep turns on
* `process.kill(pid, 0)` semantics that differ per platform.
*/
const roots: string[] = [];
const children: ChildProcess[] = [];
function makeRoot(): string {
const root = mkdtempSync(join(tmpdir(), 'reaper-spec-'));
roots.push(root);
return root;
}
/** A data directory shaped like a real one, optionally aged and/or owned. */
function makeDataDir(
root: string,
name: string,
options: { ageHours?: number; pid?: number | string } = {}
): string {
const dir = join(root, `${dataDirPrefix}${name}`);
mkdirSync(join(dir, 'databases'), { recursive: true });
writeFileSync(join(dir, 'databases', 'iptvnator.db'), 'db');
if (options.pid !== undefined) {
writeFileSync(join(dir, dataDirOwnerMarker), String(options.pid));
}
if (options.ageHours !== undefined) {
const when = new Date(Date.now() - options.ageHours * 3600_000);
utimesSync(dir, when, when);
}
return dir;
}
function spawnLiveProcess(): ChildProcess {
const child =
process.platform === 'win32'
? spawn('cmd', ['/c', 'ping -n 30 127.0.0.1 > NUL'], {
stdio: 'ignore',
})
: spawn('sleep', ['30'], { stdio: 'ignore' });
children.push(child);
return child;
}
test.afterAll(() => {
for (const child of children) {
child.kill();
}
for (const root of roots) {
rmSync(root, { force: true, recursive: true });
}
});
test.describe('orphaned data directory reaper', () => {
test('keeps a live owner well past the unmarked cutoff', () => {
const root = makeRoot();
const child = spawnLiveProcess();
// Age is deliberately past the cutoff: writes land under databases/ and
// never refresh the root's mtime, so a running suite can look this old.
const dir = makeDataDir(root, 'live', {
ageHours: 48,
pid: child.pid,
});
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(true);
});
test('reaps a live-looking owner once past the hard cap, since the pid must be recycled', () => {
const root = makeRoot();
// A pid that resolves to a live process, but on a directory far older
// than any suite could run — so the pid belongs to a stranger now.
const child = spawnLiveProcess();
const dir = makeDataDir(root, 'recycled', {
ageHours: 24 * 8,
pid: child.pid,
});
expect(readDataDirOwner(dir)).toBe('alive');
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('reaps a dead owner immediately, without waiting out the cutoff', async () => {
const root = makeRoot();
const child = spawnLiveProcess();
const dir = makeDataDir(root, 'dead', {
ageHours: 0,
pid: child.pid,
});
child.kill('SIGKILL');
// The pid only leaves the table once the parent reaps the exit status,
// so wait for that rather than assuming kill() is synchronous.
await expect
.poll(() => readDataDirOwner(dir), { timeout: 10_000 })
.toBe('dead');
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('reaps an unmarked directory once it is past the cutoff', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'legacy', { ageHours: 48 });
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(false);
});
test('keeps a fresh unmarked directory', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'fresh', { ageHours: 1 });
reapOrphanedDataDirs(root);
expect(existsSync(dir)).toBe(true);
});
test('treats an empty or malformed marker as undeterminable, not dead', () => {
const root = makeRoot();
// A half-written marker must not bypass the age guard and take out a
// suite that is still starting up.
const empty = makeDataDir(root, 'empty', { ageHours: 1, pid: '' });
const partial = makeDataDir(root, 'partial', {
ageHours: 1,
pid: 'not-a-pid',
});
expect(readDataDirOwner(empty)).toBe('unknown');
expect(readDataDirOwner(partial)).toBe('unknown');
reapOrphanedDataDirs(root);
expect(existsSync(empty)).toBe(true);
expect(existsSync(partial)).toBe(true);
});
test('publishes the owner marker atomically', () => {
const root = makeRoot();
const dir = makeDataDir(root, 'atomic', { ageHours: 0 });
writeDataDirOwnerMarker(dir);
// No `.pending` residue, and the marker resolves to this live process.
expect(existsSync(`${join(dir, dataDirOwnerMarker)}.pending`)).toBe(
false
);
expect(readDataDirOwner(dir)).toBe('alive');
});
test('ignores directories that do not carry the suite prefix', () => {
const root = makeRoot();
const foreign = join(root, 'some-other-tool-XYZ');
mkdirSync(foreign, { recursive: true });
const when = new Date(Date.now() - 99 * 3600_000);
utimesSync(foreign, when, when);
reapOrphanedDataDirs(root);
expect(existsSync(foreign)).toBe(true);
});
test('survives a missing root instead of throwing', () => {
expect(() =>
reapOrphanedDataDirs(join(tmpdir(), 'reaper-spec-does-not-exist'))
).not.toThrow();
});
test('cutoffs are a day and a week, and the hard cap is the looser one', () => {
expect(orphanDataDirMaxAgeMs).toBe(24 * 60 * 60 * 1000);
expect(dataDirHardMaxAgeMs).toBe(7 * 24 * 60 * 60 * 1000);
expect(dataDirHardMaxAgeMs).toBeGreaterThan(orphanDataDirMaxAgeMs);
});
});
@@ -0,0 +1,123 @@
import {
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
export const dataDirPrefix = 'iptvnator-electron-e2e-';
export const dataDirOwnerMarker = '.e2e-owner-pid';
/** Fallback cutoff, used only for leftovers whose owner cannot be determined. */
export const orphanDataDirMaxAgeMs = 24 * 60 * 60 * 1000;
/**
* Backstop for pid reuse. A pid that looks alive is normally decisive, but the
* OS recycles pids — aggressively so on the long-lived Windows runners this
* sweep exists for — and an unrelated service inheriting an abandoned run's pid
* would otherwise pin that directory forever, defeating the whole point. No
* suite survives a week, so past this age a live-looking pid is a stranger.
*/
export const dataDirHardMaxAgeMs = 7 * 24 * 60 * 60 * 1000;
export type DataDirOwner = 'alive' | 'dead' | 'unknown';
/**
* Records the current process as the owner of `dataDir`.
*
* Written to a temporary name and renamed into place, because `writeFileSync`
* creates the file before its bytes land: a concurrent sweep could otherwise
* observe an empty marker and misjudge a starting run. `rename` is atomic
* within a filesystem, so the marker is either absent or complete.
*/
export function writeDataDirOwnerMarker(dataDir: string): void {
const markerPath = join(dataDir, dataDirOwnerMarker);
const pendingPath = `${markerPath}.pending`;
writeFileSync(pendingPath, String(process.pid));
renameSync(pendingPath, markerPath);
}
/**
* Resolves whether the run that created `dataDir` is still alive.
*
* Directory age cannot answer this. The suite writes beneath `databases/` and
* `user-data/`, which never refreshes the root's mtime, so a run paused in a
* debugger or blocked on a native process looks arbitrarily old while still
* using its data — and Unix would let a sweep unlink files the live Electron
* still has open. So the owner is asked of the OS directly.
*
* `unknown` covers a missing, unreadable, empty or malformed marker. Callers
* must treat it as "cannot tell", never as "dead".
*/
export function readDataDirOwner(dataDir: string): DataDirOwner {
let raw: string;
try {
raw = readFileSync(join(dataDir, dataDirOwnerMarker), 'utf8').trim();
} catch {
return 'unknown';
}
const pid = Number.parseInt(raw, 10);
if (!raw || !Number.isInteger(pid) || pid <= 0 || String(pid) !== raw) {
return 'unknown';
}
try {
// Signal 0 runs the existence/permission check without delivering.
process.kill(pid, 0);
return 'alive';
} catch (error) {
// EPERM means the pid exists but belongs to another user — still alive.
return (error as NodeJS.ErrnoException).code === 'EPERM'
? 'alive'
: 'dead';
}
}
/**
* Best-effort sweep of data directories abandoned by earlier runs.
*
* `removeDataDir` tolerates a locked directory rather than failing the run, but
* then abandons it, and nothing collects it on our behalf: Windows never clears
* %TEMP% on process exit, and the Unix equivalents only run on a schedule. So
* every teardown that loses that race leaks a database and user-data tree on a
* developer machine or a long-lived self-hosted runner, invisibly.
*
* A live owner is kept — this repo is routinely checked out into several
* worktrees at once — up to `dataDirHardMaxAgeMs`, past which the pid is
* assumed recycled rather than still ours. A dead owner is collected at once,
* since the pid settles what age only guesses at. An undeterminable owner
* falls back to `orphanDataDirMaxAgeMs`.
*/
export function reapOrphanedDataDirs(root: string = tmpdir()): void {
const now = Date.now();
let entries: string[];
try {
entries = readdirSync(root);
} catch {
return;
}
for (const entry of entries) {
if (!entry.startsWith(dataDirPrefix)) {
continue;
}
const candidate = join(root, entry);
try {
const owner = readDataDirOwner(candidate);
const age = now - statSync(candidate).mtimeMs;
if (owner === 'alive' && age < dataDirHardMaxAgeMs) {
continue;
}
if (owner === 'unknown' && age < orphanDataDirMaxAgeMs) {
continue;
}
rmSync(candidate, { force: true, recursive: true, maxRetries: 3 });
} catch {
// Still locked, or owned by another user — leave it for next time.
}
}
}
@@ -22,6 +22,11 @@ import {
} from 'fs';
import { tmpdir } from 'os';
import { dirname, join, resolve } from 'path';
import {
dataDirPrefix,
reapOrphanedDataDirs,
writeDataDirOwnerMarker,
} from './data-dir-reaper';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
@@ -154,10 +159,17 @@ function removeDataDir(dataDir: string): void {
}
}
let reapedOrphanedDataDirs = false;
export const test = base.extend<ElectronFixtures>({
dataDir: async ({ browserName }, use) => {
void browserName;
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-electron-e2e-'));
if (!reapedOrphanedDataDirs) {
reapedOrphanedDataDirs = true;
reapOrphanedDataDirs();
}
const dataDir = mkdtempSync(join(tmpdir(), dataDirPrefix));
writeDataDirOwnerMarker(dataDir);
await use(dataDir);
@@ -0,0 +1,91 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
/**
* Who gets offered VOD auto-failover.
*
* Only the built-in web players raise the playback diagnostic that triggers a
* source switch: Embedded MPV has its diagnostics suppressed and MPV/VLC play
* outside the app entirely. Offering the control there would let a user enable
* a feature that can never fire.
*/
describe('SettingsPlaybackSectionComponent — VOD auto-failover', () => {
let fixture: ComponentFixture<SettingsPlaybackSectionComponent>;
const settingRow = (): Element | null =>
fixture.nativeElement.querySelector(
'[data-test-id="vod-auto-failover-setting"]'
);
function render(player: VideoPlayer, supported = true): void {
fixture.componentRef.setInput('form', createForm(player));
fixture.componentRef.setInput('supportsVodMultiSource', supported);
fixture.detectChanges();
}
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
SettingsPlaybackSectionComponent,
NoopAnimationsModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
}).compileComponents();
fixture = TestBed.createComponent(SettingsPlaybackSectionComponent);
fixture.componentRef.setInput('activeSection', 'playback');
fixture.componentRef.setInput('players', []);
fixture.componentRef.setInput('streamFormatEnum', StreamFormat);
});
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('offers the toggle on %s', (player) => {
render(player);
expect(settingRow()).not.toBeNull();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'hides the toggle on %s, which never reports a playback failure',
(player) => {
render(player);
expect(settingRow()).toBeNull();
}
);
it('stays hidden in the PWA, where there is nothing to fail over to', () => {
render(VideoPlayer.Html5Player, false);
expect(settingRow()).toBeNull();
});
});
function createForm(player: VideoPlayer): FormGroup {
return new FormGroup({
player: new FormControl(player),
webPlayerSharedControls: new FormControl(false),
playerAmbientMode: new FormControl(false),
playerUpNextRail: new FormControl(true),
streamFormat: new FormControl(StreamFormat.AutoStreamFormat),
openStreamOnDoubleClick: new FormControl(false),
showExternalPlaybackBar: new FormControl(true),
embeddedMpvFrameCopy: new FormControl(false),
mpvPlayerPath: new FormControl(''),
mpvPlayerArguments: new FormControl(''),
mpvReuseInstance: new FormControl(false),
vlcPlayerPath: new FormControl(''),
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -133,7 +133,9 @@
</div>
}
@if (supportsVodMultiSource()) {
<!-- Only the web players raise a playback diagnostic, so under MPV, VLC
or Embedded MPV nothing would ever trigger a failover. -->
@if (supportsVodMultiSource() && isWebPlayerSelected()) {
<div class="setting-item" data-test-id="vod-auto-failover-setting">
<div class="setting-item__meta">
<h4>
@@ -394,5 +394,6 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup {
vlcPlayerArguments: new FormControl(''),
vlcReuseInstance: new FormControl(false),
recordingFolder: new FormControl(''),
vodAutoFailover: new FormControl(false),
});
}
@@ -8,7 +8,11 @@ import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { TranslateModule } from '@ngx-translate/core';
import { StreamFormat, VideoPlayer } from '@iptvnator/shared/interfaces';
import {
StreamFormat,
VideoPlayer,
reportsPlaybackFailures,
} from '@iptvnator/shared/interfaces';
import { SettingsPlayerOption } from './settings.models';
@Component({
@@ -59,12 +63,7 @@ export class SettingsPlaybackSectionComponent {
readonly selectRecordingFolder = output<void>();
isWebPlayerSelected(): boolean {
const player = this.form().value.player;
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
);
return reportsPlaybackFailures(this.form().value.player);
}
isExternalPlayerSelected(): boolean {
+14 -1
View File
@@ -14,7 +14,7 @@ current source is dead, serves an unsupported codec, or buffers badly.
| Source types | **Xtream ↔ Xtream only** |
| Content | Movies only — series are not offered a source chip |
| Environment | **Electron only** — the chip renders nothing in the PWA |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`) |
| Auto-failover | Opt-in, **off by default** (`Settings.vodAutoFailover`); offered only on the built-in web players |
| Pin scope | Per movie (a global portal priority is out of scope) |
| Stream probe | HEAD → reachable + latency, sent with the playlist's own playback headers. **No codec probing** |
@@ -410,3 +410,16 @@ than merely lacking a convenience.
Every entry point is gated on a bridge `typeof` check (`isAvailable`), matching
`CatalogTitleMatchService`. In the PWA the chip renders nothing and the
auto-failover setting is hidden.
## Which engines can fail over
Only the built-in web players (HTML5, Video.js, ArtPlayer) raise the playback
diagnostic that reaches `onPlaybackFailed()`. `WebPlayerViewComponent`
suppresses it for Embedded MPV, and external MPV/VLC never mount that component
at all — a stream that dies there is invisible to the app.
So the toggle is hidden, not merely inert, on those engines: in
`Settings > Playback` (`reportsPlaybackFailures()` gating the row) and in the
sources menu (`autoFailoverSupported`). Leaving it visible would let a user
switch on a feature that can never fire. The stored preference is untouched by
the change — switching back to a web player restores whatever was set.
+2
View File
@@ -26,3 +26,5 @@ export * from './lib/navigation/portal-rail-links';
export * from './lib/navigation/portal-route.utils';
export * from './lib/navigation/workspace-portal-navigation';
export * from './lib/collection';
export * from './lib/external-playback-button-state';
export * from './lib/inline-playback-position-writer';
@@ -0,0 +1,210 @@
import { signal } from '@angular/core';
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { createExternalPlaybackButtonState } from './external-playback-button-state';
function session(
overrides: Partial<ExternalPlayerSession> = {}
): ExternalPlayerSession {
return {
player: 'mpv',
status: 'playing',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
},
...overrides,
} as ExternalPlayerSession;
}
function setup(initial: ExternalPlayerSession | null = null) {
const current = signal<ExternalPlayerSession | null>(initial);
const api = createExternalPlaybackButtonState({
session: current,
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
});
return { current, api };
}
describe('createExternalPlaybackButtonState', () => {
it('is idle with no session', () => {
const { api } = setup(null);
expect(api.matchedSession()).toBeNull();
expect(api.buttonState()).toBe('idle');
expect(api.primaryIcon()).toBe('play_arrow');
expect(api.primaryLabel()).toBeNull();
});
it('matches a session for the item on screen', () => {
const { api } = setup(session());
expect(api.matchedSession()).not.toBeNull();
expect(api.buttonState()).toBe('stop');
expect(api.primaryIcon()).toBe('stop_circle');
expect(api.primaryLabel()).toBe('Stop MPV');
expect(api.isStopAction()).toBe(true);
});
it('ignores a session playing a different movie', () => {
// The whole point of the match: another movie playing in MPV must not
// turn this page's Play button into Stop.
const { api } = setup(
session({
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 999,
contentType: 'vod',
},
} as Partial<ExternalPlayerSession>)
);
expect(api.matchedSession()).toBeNull();
expect(api.buttonState()).toBe('idle');
});
it('ignores a session from a different playlist', () => {
const { api } = setup(
session({
contentInfo: {
playlistId: 'other-playlist',
contentXtreamId: 42,
contentType: 'vod',
},
} as Partial<ExternalPlayerSession>)
);
expect(api.matchedSession()).toBeNull();
});
it('ignores a session of a different content type', () => {
const { api } = setup(
session({
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'episode',
},
} as Partial<ExternalPlayerSession>)
);
expect(api.matchedSession()).toBeNull();
});
it.each(['closed', 'error'] as const)(
'treats a %s session as nothing playing',
(status) => {
const { api } = setup(session({ status }));
expect(api.matchedSession()).toBeNull();
expect(api.buttonState()).toBe('idle');
}
);
it('reports a launching session', () => {
const { api } = setup(session({ status: 'launching' }));
expect(api.buttonState()).toBe('launching');
expect(api.primaryIcon()).toBe('hourglass_top');
expect(api.primaryLabel()).toBe('Opening in MPV...');
expect(api.isLaunchPending()).toBe(true);
expect(api.isStopAction()).toBe(false);
});
it('reacts to the session changing', () => {
const { current, api } = setup(null);
expect(api.buttonState()).toBe('idle');
current.set(session({ status: 'launching' }));
expect(api.buttonState()).toBe('launching');
current.set(session({ status: 'playing' }));
expect(api.buttonState()).toBe('stop');
current.set(null);
expect(api.buttonState()).toBe('idle');
});
it('does not match while the item id is unknown', () => {
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(session()),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(null),
});
expect(api.matchedSession()).toBeNull();
});
it('also owns a session launched for another playlist it switched to', () => {
// Multi-source: same film, other playlist, other stream id. Without
// this the page shows Play while its own switch is streaming, and
// clicking it opens a second player instead of stopping the first.
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
}),
});
expect(api.matchedSession()).not.toBeNull();
expect(api.isStopAction()).toBe(true);
});
it('disowns that session once playback moved back', () => {
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>(null),
});
expect(api.matchedSession()).toBeNull();
});
it('still checks the content type of an owned alternative', () => {
const api = createExternalPlaybackButtonState({
session: signal<ExternalPlayerSession | null>(
session({
contentInfo: {
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'episode',
},
})
),
playlistId: signal<string | null>('playlist-1'),
contentId: signal<number | null>(42),
alsoOwns: signal<PlayerContentInfo | null>({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
}),
});
expect(api.matchedSession()).toBeNull();
});
});
@@ -0,0 +1,137 @@
import { computed, type Signal } from '@angular/core';
import type {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/**
* Derives the primary Play/Stop button's state from the active external
* (MPV/VLC) session.
*
* Extracted because Xtream and Stalker each carried a near-identical private
* copy of these six computeds. A single Play button that behaves differently
* per portal is a bug waiting to happen, so both now read from here.
*/
export type ExternalPlaybackButtonState = 'idle' | 'launching' | 'stop';
export interface ExternalPlaybackButtonStateConfig {
/** The currently active external player session, if any. */
session: Signal<ExternalPlayerSession | null>;
/** Playlist the detail view is showing. */
playlistId: Signal<string | null | undefined>;
/** Provider-side id of the item on screen. */
contentId: Signal<number | null | undefined>;
/** Defaults to `'vod'`. */
contentType?: 'vod' | 'episode';
/**
* A second pair of ids this page also owns, if any.
*
* Multi-source can launch an external player on a copy of the same film in
* ANOTHER playlist, and the session then carries that playlist's ids.
* Without this the page shows Play while its own switch is streaming, and
* the user has no way to stop it from here.
*/
alsoOwns?: Signal<PlayerContentInfo | null>;
}
export interface ExternalPlaybackButtonStateApi {
/**
* The session, but only when it belongs to the item on screen. A session
* playing a different movie must not turn this page's Play into Stop.
*/
matchedSession: Signal<ExternalPlayerSession | null>;
primaryLabel: Signal<string | null>;
primaryIcon: Signal<string>;
isLaunchPending: Signal<boolean>;
isStopAction: Signal<boolean>;
buttonState: Signal<ExternalPlaybackButtonState>;
}
export function createExternalPlaybackButtonState(
config: ExternalPlaybackButtonStateConfig
): ExternalPlaybackButtonStateApi {
const contentType = config.contentType ?? 'vod';
const matchedSession = computed(() => {
const session = config.session();
// A closed or errored session says nothing about what is playing now.
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
return null;
}
const info = session.contentInfo;
if (info.contentType !== contentType) {
return null;
}
const alsoOwns = config.alsoOwns?.();
const owned =
(info.playlistId === config.playlistId() &&
info.contentXtreamId === config.contentId()) ||
(!!alsoOwns &&
info.playlistId === alsoOwns.playlistId &&
info.contentXtreamId === alsoOwns.contentXtreamId);
return owned ? session : null;
});
const primaryLabel = computed(() => {
const session = matchedSession();
if (!session) {
return null;
}
const player = session.player.toUpperCase();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
case 'playing':
return `Stop ${player}`;
default:
return null;
}
});
const primaryIcon = computed(() => {
switch (matchedSession()?.status) {
case 'launching':
return 'hourglass_top';
case 'opened':
case 'playing':
return 'stop_circle';
default:
return 'play_arrow';
}
});
const isLaunchPending = computed(
() => matchedSession()?.status === 'launching'
);
const isStopAction = computed(() => {
const status = matchedSession()?.status;
return status === 'opened' || status === 'playing';
});
const buttonState = computed<ExternalPlaybackButtonState>(() => {
if (isLaunchPending()) {
return 'launching';
}
return isStopAction() ? 'stop' : 'idle';
});
return {
matchedSession,
primaryLabel,
primaryIcon,
isLaunchPending,
isStopAction,
buttonState,
};
}
@@ -0,0 +1,185 @@
import { signal } from '@angular/core';
import type { ResolvedPortalPlayback } from '@iptvnator/shared/interfaces';
import { createInlinePlaybackPositionWriter } from './inline-playback-position-writer';
function playbackWithInfo(): ResolvedPortalPlayback {
return {
streamUrl: 'http://example.com/movie.mkv',
title: 'Dune',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
},
};
}
function setup(initial: ResolvedPortalPlayback | null = playbackWithInfo()) {
const playback = signal<ResolvedPortalPlayback | null>(initial);
const save = jest.fn();
const onSaved = jest.fn();
const writer = createInlinePlaybackPositionWriter({
playback,
save,
onSaved,
});
return { playback, save, onSaved, writer };
}
describe('createInlinePlaybackPositionWriter', () => {
afterEach(() => {
jest.useRealTimers();
});
it('writes the first update immediately', () => {
const { writer, save, onSaved } = setup();
writer.handleTimeUpdate({ currentTime: 12.7, duration: 100.2 });
expect(save).toHaveBeenCalledWith('playlist-1', {
playlistId: 'playlist-1',
contentXtreamId: 42,
contentType: 'vod',
positionSeconds: 12,
durationSeconds: 100,
});
expect(onSaved).toHaveBeenCalledTimes(1);
});
it('throttles subsequent updates to one per 15s', () => {
jest.useFakeTimers();
const { writer, save } = setup();
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
expect(save).toHaveBeenCalledTimes(1);
// The player fires ~4x/second; none of these may reach storage.
jest.advanceTimersByTime(5000);
writer.handleTimeUpdate({ currentTime: 6, duration: 100 });
jest.advanceTimersByTime(5000);
writer.handleTimeUpdate({ currentTime: 11, duration: 100 });
expect(save).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(6000);
writer.handleTimeUpdate({ currentTime: 17, duration: 100 });
expect(save).toHaveBeenCalledTimes(2);
});
it('reset makes the next update write immediately', () => {
jest.useFakeTimers();
const { writer, save } = setup();
writer.handleTimeUpdate({ currentTime: 1, duration: 100 });
jest.advanceTimersByTime(1000);
writer.handleTimeUpdate({ currentTime: 2, duration: 100 });
expect(save).toHaveBeenCalledTimes(1);
writer.reset();
writer.handleTimeUpdate({ currentTime: 3, duration: 100 });
expect(save).toHaveBeenCalledTimes(2);
});
it('writes nothing without a playback', () => {
const { writer, save } = setup(null);
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
expect(save).not.toHaveBeenCalled();
});
it('writes nothing without contentInfo — there is no key to store under', () => {
const { writer, save } = setup({
streamUrl: 'http://example.com/movie.mkv',
title: 'Dune',
});
writer.handleTimeUpdate({ currentTime: 5, duration: 100 });
expect(save).not.toHaveBeenCalled();
});
it('floors fractional times', () => {
const { writer, save } = setup();
writer.handleTimeUpdate({ currentTime: 9.99, duration: 42.99 });
expect(save.mock.calls[0][1]).toEqual(
expect.objectContaining({
positionSeconds: 9,
durationSeconds: 42,
})
);
});
describe('resume latch', () => {
function resuming(startTime: number): ResolvedPortalPlayback {
return { ...playbackWithInfo(), startTime };
}
it('drops updates emitted before the engine reaches startTime', () => {
// A resuming engine ticks at ~0 while it is still seeking. Writing
// one of those overwrites the very position being resumed from.
const { writer, save } = setup(resuming(2538));
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
expect(save).not.toHaveBeenCalled();
});
it('releases once, so a later seek backwards is still saved', () => {
jest.useFakeTimers();
const { writer, save } = setup(resuming(2538));
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
// Reaching the resume point latches the guard open for good.
writer.handleTimeUpdate({ currentTime: 2540, duration: 7744 });
jest.advanceTimersByTime(16000);
expect(
writer.handleTimeUpdate({ currentTime: 30, duration: 7744 })
).toBe(true);
expect(save).toHaveBeenLastCalledWith(
'playlist-1',
expect.objectContaining({ positionSeconds: 30 })
);
});
it('gives up on a resume point the source cannot reach', () => {
// Two hours carried into a 90-minute cut: the engine can never
// report that time, so latching on it would suppress every save
// for the whole session.
const { writer, save } = setup(resuming(7200));
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 5400 })
).toBe(true);
expect(save).toHaveBeenCalled();
});
it('keeps the latch closed across an update with no contentInfo', () => {
const { writer, playback } = setup(resuming(2538));
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 });
playback.set({ streamUrl: 'http://example.com/x.mkv', title: 'X' });
// Reporting `true` here would tell multi-source that a timecode of
// ~0 can be carried to the next source.
expect(
writer.handleTimeUpdate({ currentTime: 13, duration: 7744 })
).toBe(false);
});
it('is cleared by reset, so a new resume waits again', () => {
const { writer, playback } = setup();
writer.handleTimeUpdate({ currentTime: 40, duration: 7744 });
playback.set(resuming(2538));
writer.reset();
expect(
writer.handleTimeUpdate({ currentTime: 12, duration: 7744 })
).toBe(false);
});
});
});
@@ -0,0 +1,111 @@
import type { Signal } from '@angular/core';
import type {
PlaybackPositionData,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
/**
* Throttled persistence of the inline player's position.
*
* Extracted because Xtream and Stalker each carried their own copy of the same
* "every timeupdate, but at most once per 15s" logic, and a resume point that
* behaves differently per portal is exactly the kind of divergence users
* notice.
*
* Beyond that throttle it holds one resume latch, because a position written
* before the engine reaches `startTime` overwrites the point being resumed
* from — a bug both copies had.
*/
/** The player fires ~4x/second; persisting that often would hammer SQLite. */
const DEFAULT_THROTTLE_MS = 15000;
/** Engines land near, not exactly on, the requested `startTime`. */
const RESUME_TOLERANCE_SECONDS = 5;
export interface InlinePlaybackPositionWriterConfig {
/** The playback currently mounted in the inline player. */
playback: Signal<ResolvedPortalPlayback | null>;
save: (playlistId: string, position: PlaybackPositionData) => void;
/** Called with each persisted position, for local resume state. */
onSaved?: (position: PlaybackPositionData) => void;
throttleMs?: number;
}
export interface InlinePlaybackPositionWriter {
/**
* @returns whether `currentTime` can be believed yet — false while the
* engine is still on its way to `startTime`. Multi-source switching needs
* the same answer to carry a timecode across, and one latch has to serve
* both or the two disagree about where playback is.
*/
handleTimeUpdate(event: { currentTime: number; duration: number }): boolean;
/** Clears the throttle and the resume latch for a new playback. */
reset(): void;
}
export function createInlinePlaybackPositionWriter(
config: InlinePlaybackPositionWriterConfig
): InlinePlaybackPositionWriter {
const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS;
let lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches `startTime`. */
let resumeSettled = false;
return {
handleTimeUpdate(event) {
const playback = config.playback();
// Without contentInfo there is no key to store the position under.
// The latch never ran, so the answer is whatever it already was —
// not a fresh "no".
if (!playback?.contentInfo) {
return resumeSettled;
}
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very
// position being resumed from — which a source switch then
// inherits, restarting the film from the beginning.
//
// This is a one-shot latch, not a filter: once playback has
// reached the resume point the guard is done, so a deliberate seek
// backwards is still saved normally.
if (!resumeSettled) {
const startTime = playback.startTime ?? 0;
// A shorter cut can never reach a position carried into it,
// and latching on that would suppress every save this session.
const reachable =
!(event.duration > 0) || startTime < event.duration;
if (
reachable &&
startTime > 0 &&
event.currentTime < startTime - RESUME_TOLERANCE_SECONDS
) {
return false;
}
resumeSettled = true;
}
const now = Date.now();
if (now - lastSaveTime <= throttleMs) {
return true;
}
lastSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
config.save(playback.contentInfo.playlistId, position);
config.onSaved?.(position);
return true;
},
reset() {
lastSaveTime = 0;
resumeSettled = false;
},
};
}
@@ -15,13 +15,12 @@ import {
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
createInlinePlaybackPositionWriter,
} from '@iptvnator/portal/shared/util';
import {
createPortalFavoritesResource,
createRefreshTrigger,
isSelectedStalkerVodFavorite,
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
StalkerSelectedVodItem,
toggleStalkerVodFavorite,
} from '@iptvnator/portal/stalker/data-access';
@@ -44,15 +43,7 @@ import {
import { StalkerCatalogFacadeService } from '../stalker-catalog-facade.service';
import { StalkerSeriesViewComponent } from '../stalker-series-view/stalker-series-view.component';
interface DownloadVodData {
id?: string | number;
has_files?: unknown;
info?: {
name?: string;
movie_image?: string;
};
title?: string;
}
import { startStalkerVodDownload } from './stalker-vod-download';
@Component({
selector: 'app-stalker-catalog-detail',
@@ -94,7 +85,6 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
private readonly selectedVodPosition = signal<PlaybackPositionData | null>(
null
);
private lastInlineSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly isSeriesDetail = computed(() => {
@@ -226,37 +216,26 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
}
private readonly positionWriter = createInlinePlaybackPositionWriter({
playback: this.inlinePlayback,
save: (playlistId, position) =>
void this.playbackPositions.savePlaybackPosition(
playlistId,
position
),
onSaved: (position) => this.selectedVodPosition.set(position),
});
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): void {
const playback = this.inlinePlayback();
if (!playback?.contentInfo) {
return;
}
const now = Date.now();
if (now - this.lastInlineSaveTime <= 15000) {
return;
}
this.lastInlineSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.selectedVodPosition.set(position);
this.positionWriter.handleTimeUpdate(event);
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastInlineSaveTime = 0;
this.positionWriter.reset();
}
showCopyNotification(): void {
@@ -277,60 +256,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
if (item.type !== 'stalker') {
return;
}
const playlist = this.catalog.playlist();
if (!playlist || !playlist.portalUrl || !playlist.macAddress) {
return;
}
let cmdToUse = item.cmd;
const itemData = item.data as DownloadVodData;
const normalizedItemId = normalizeStalkerEntityId(itemData?.id);
if (
itemData?.has_files !== undefined &&
cmdToUse &&
!cmdToUse.includes('://') &&
cmdToUse.includes('/media/') &&
!cmdToUse.includes('/media/file_')
) {
const fileId =
await this.catalog.fetchMovieFileId(normalizedItemId);
if (fileId) {
cmdToUse = `/media/file_${fileId}.mpg`;
}
}
const url = await this.catalog.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) {
return;
}
const numericId = normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0;
await this.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: numericId,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
await startStalkerVodDownload(item, {
playlist: this.catalog.playlist(),
downloadsService: this.downloadsService,
fetchMovieFileId: (id) => this.catalog.fetchMovieFileId(id),
fetchLinkToPlay: (portalUrl, macAddress, cmd) =>
this.catalog.fetchLinkToPlay(portalUrl, macAddress, cmd),
});
}
@@ -370,7 +301,7 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
startTime
);
this.lastInlineSaveTime = 0;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
@@ -0,0 +1,118 @@
import type { DownloadsService } from '@iptvnator/services';
import type { VodDetailsItem } from '@iptvnator/shared/interfaces';
/** The Stalker branch of the item union — the only one carrying `cmd`. */
type StalkerVodDetailsItem = Extract<VodDetailsItem, { type: 'stalker' }>;
import {
normalizeStalkerEntityId,
normalizeStalkerEntityIdAsNumber,
} from '@iptvnator/portal/stalker/data-access';
/**
* Starting a download of a Stalker VOD item.
*
* Split out of the detail component because it is a self-contained errand: it
* resolves a playable link (Stalker hands out `create_link` URLs, not static
* ones) and hands the result to the download manager. Nothing else in the
* component needs it.
*/
/** The provider payload carried on a Stalker VOD item. */
export interface DownloadVodData {
id?: string | number;
has_files?: unknown;
title?: string;
info?: { name?: string; movie_image?: string };
}
export interface StalkerVodDownloadPlaylist {
id: string;
portalUrl?: string;
macAddress?: string;
title?: string;
userAgent?: string;
referer?: string;
origin?: string;
}
export interface StalkerVodDownloadDeps {
playlist: StalkerVodDownloadPlaylist | null | undefined;
downloadsService: Pick<DownloadsService, 'startDownload'>;
fetchMovieFileId: (id: string) => Promise<string | number | null>;
fetchLinkToPlay: (
portalUrl: string,
macAddress: string,
cmd: string
) => Promise<string | null>;
}
export async function startStalkerVodDownload(
item: VodDetailsItem,
deps: StalkerVodDownloadDeps
): Promise<void> {
if (item.type !== 'stalker') {
return;
}
const { playlist } = deps;
if (!playlist?.portalUrl || !playlist.macAddress) {
return;
}
const itemData = item.data as DownloadVodData;
const cmdToUse = await resolveDownloadCmd(item, itemData, deps);
const url = await deps.fetchLinkToPlay(
playlist.portalUrl,
playlist.macAddress,
cmdToUse
);
if (!url) {
return;
}
await deps.downloadsService.startDownload({
playlistId: playlist.id,
xtreamId: normalizeStalkerEntityIdAsNumber(itemData?.id) ?? 0,
contentType: 'vod',
title: itemData?.info?.name || itemData?.title || 'Unknown',
url,
posterUrl: itemData?.info?.movie_image,
headers: {
userAgent: playlist.userAgent,
referer: playlist.referer,
origin: playlist.origin,
},
playlistName: playlist.title || 'Stalker Portal',
playlistType: 'stalker',
portalUrl: playlist.portalUrl,
macAddress: playlist.macAddress,
});
}
/**
* Ministra portals expose a movie as a folder command that has to be turned
* into a concrete file command before it can be linked.
*/
async function resolveDownloadCmd(
item: StalkerVodDetailsItem,
itemData: DownloadVodData,
deps: StalkerVodDownloadDeps
): Promise<string> {
const cmd = item.cmd ?? '';
const needsFileId =
itemData?.has_files !== undefined &&
cmd &&
!cmd.includes('://') &&
cmd.includes('/media/') &&
!cmd.includes('/media/file_');
if (!needsFileId) {
return cmd;
}
const fileId = await deps.fetchMovieFileId(
normalizeStalkerEntityId(itemData?.id)
);
return fileId ? `/media/file_${fileId}.mpg` : cmd;
}
@@ -10,6 +10,8 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createExternalPlaybackButtonState,
createInlinePlaybackPositionWriter,
createLogger,
getPortalPlaybackProgressPercent,
} from '@iptvnator/portal/shared/util';
@@ -55,103 +57,26 @@ export class VodDetailsPlaybackService {
private readonly logger = createLogger('VodDetailsPlayback');
/** Signals bound from the host component via `bind()` */
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(null);
private lastSaveTime = 0;
/** Cleared on every start; latches once playback reaches startTime. */
private resumeSettled = false;
private readonly bindings = signal<VodDetailsPlaybackBindings | null>(
null
);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback.activeSession();
if (
!session?.contentInfo ||
!this.xtreamStore.currentPlaylist()?.id ||
session.status === 'error' ||
session.status === 'closed'
) {
return null;
}
return this.ownsContent(session.contentInfo) ? session : null;
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback.activeSession,
playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id),
contentId: computed(() => this.bindings()?.vodId()),
alsoOwns: computed(() => this.bindings()?.activeSource?.() ?? null),
});
/**
* Whether this page owns the content an external session or a position
* update refers to.
*
* Multi-source can put playback on a movie in ANOTHER playlist, whose ids
* its session and position rows then carry. One predicate for both: when
* they disagree the page shows Stop for a session whose progress it drops.
*/
private ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined
): boolean {
// An absent playlist id must never match an absent current playlist.
if (!info?.playlistId || info.contentType !== 'vod') {
return false;
}
const active = this.bindings()?.activeSource?.();
return (
(info.playlistId === this.xtreamStore.currentPlaylist()?.id &&
info.contentXtreamId === this.bindings()?.vodId()) ||
(!!active &&
info.playlistId === active.playlistId &&
info.contentXtreamId === active.contentXtreamId)
);
}
readonly externalPrimaryLabel = computed(() => {
const session = this.matchedExternalPlayback();
if (!session) {
return null;
}
const player = session.player.toUpperCase();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
case 'playing':
return `Stop ${player}`;
default:
return null;
}
});
readonly externalPrimaryIcon = computed(() => {
const session = this.matchedExternalPlayback();
switch (session?.status) {
case 'launching':
return 'hourglass_top';
case 'opened':
case 'playing':
return 'stop_circle';
default:
return 'play_arrow';
}
});
readonly isExternalLaunchPending = computed(
() => this.matchedExternalPlayback()?.status === 'launching'
);
readonly isExternalStopAction = computed(() => {
const status = this.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
readonly externalPrimaryButtonState = computed(() => {
if (this.isExternalLaunchPending()) {
return 'launching';
}
return this.isExternalStopAction() ? 'stop' : 'idle';
});
readonly matchedExternalPlayback = this.externalButton.matchedSession;
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
readonly vodPlaybackProgress = computed(() =>
getPortalPlaybackProgressPercent(this.vodPlaybackPosition())
);
@@ -181,6 +106,38 @@ export class VodDetailsPlaybackService {
inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.());
}
/**
* Whether this page owns the content a position update refers to.
*
* Multi-source can put playback on a movie in ANOTHER playlist, whose ids
* the incoming rows then carry, so this has to agree with the Play/Stop
* button's `alsoOwns` — otherwise the page offers Stop for a session whose
* progress it silently drops.
*/
private ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined
): boolean {
// An absent playlist id must never match an absent current playlist.
if (!info?.playlistId || info.contentType !== 'vod') {
return false;
}
const active = this.bindings()?.activeSource?.();
return (
(info.playlistId === this.xtreamStore.currentPlaylist()?.id &&
info.contentXtreamId === this.bindings()?.vodId()) ||
(!!active &&
info.playlistId === active.playlistId &&
info.contentXtreamId === active.contentXtreamId)
);
}
/** Wires the host component's context signals. Call once at construction. */
bind(bindings: VodDetailsPlaybackBindings): void {
this.bindings.set(bindings);
@@ -293,57 +250,29 @@ export class VodDetailsPlaybackService {
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
this.resumeSettled = false;
this.positionWriter.reset();
}
private readonly positionWriter = createInlinePlaybackPositionWriter({
playback: this.inlinePlayback,
save: (playlistId, position) =>
void this.playbackPositions.savePlaybackPosition(
playlistId,
position
),
onSaved: (position) => this.vodPlaybackPosition.set(position),
});
/**
* @returns whether `currentTime` can be believed yet — false while the
* engine is still on its way to `startTime`. Multi-source switching needs
* the same answer, and one latch has to serve both or they disagree.
* @returns whether `currentTime` can be believed yet — see the writer's
* resume latch. Multi-source reads the same answer before carrying a
* timecode to another source.
*/
handleInlineTimeUpdate(event: {
currentTime: number;
duration: number;
}): boolean {
const playback = this.inlinePlayback();
// Nothing to persist against and the latch never ran, so the answer is
// whatever it already was — not a fresh "no".
if (!playback?.contentInfo) return this.resumeSettled;
// A resuming engine can emit timeupdates at ~0 before it finishes
// seeking to startTime. Writing one would overwrite the very position
// we are resuming from — which a source switch would then inherit.
//
// This is a one-shot latch, not a filter: once playback has reached
// the resume point the guard is done, so a deliberate seek backwards
// is still saved normally.
if (!this.resumeSettled) {
const startTime = playback.startTime ?? 0;
// A shorter cut can never reach a position carried into it, and
// latching on that would suppress every save for the session.
const reachable =
!(event.duration > 0) || startTime < event.duration;
if (reachable && startTime > 0 && event.currentTime < startTime - 5)
return false;
this.resumeSettled = true;
}
const now = Date.now();
if (now - this.lastSaveTime <= 15000) return true;
this.lastSaveTime = now;
const position: PlaybackPositionData = {
...playback.contentInfo,
positionSeconds: Math.floor(event.currentTime),
durationSeconds: Math.floor(event.duration),
};
void this.playbackPositions.savePlaybackPosition(
playback.contentInfo.playlistId,
position
);
this.vodPlaybackPosition.set(position);
return true;
return this.positionWriter.handleTimeUpdate(event);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -387,8 +316,7 @@ export class VodDetailsPlaybackService {
}
private startPlayback(playback: ResolvedPortalPlayback): void {
this.lastSaveTime = 0;
this.resumeSettled = false;
this.positionWriter.reset();
if (this.portalPlayer.isEmbeddedPlayer()) {
this.inlinePlayback.set(playback);
return;
@@ -1,24 +1,12 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Location } from '@angular/common';
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';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* What the page CLAIMS is playing.
@@ -29,186 +17,37 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
*/
describe('VodDetailsRouteComponent — source caption', () => {
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();
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
activeSession,
addRecentItem,
checkFavoriteStatus,
closeSession,
constructVodStreamUrl,
currentPlaylist,
detailsError,
fetchVodDetailsWithMetadata,
getPlaybackPosition,
isFavorite,
isLoadingDetails,
selectedItem,
setSelectedItem,
toggleFavorite,
vodCategories,
vodStreams,
} = stubs;
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();
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
consoleDebugSpy?.mockRestore();
consoleWarnSpy?.mockRestore();
restoreLogging?.();
});
/**
@@ -1,24 +1,13 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { Location } from '@angular/common';
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 { VideoPlayer } from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
import { VodDetailsRouteComponent } from './vod-details-route.component';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
/**
* What the primary button and the resume point do, as opposed to what the
@@ -27,186 +16,30 @@ import { VodDetailsRouteComponent } from './vod-details-route.component';
*/
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();
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
currentPlaylist,
activeSession,
addRecentItem,
closeSession,
getPlaybackPosition,
selectedItem,
selectedPlayer,
snackBarOpen,
updateSettings,
} = stubs;
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();
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
consoleDebugSpy?.mockRestore();
consoleWarnSpy?.mockRestore();
restoreLogging?.();
});
/**
@@ -367,4 +200,65 @@ describe('VodDetailsRouteComponent — playback actions', () => {
component.handleInlineTimeUpdate({ currentTime: 12, duration: 7744 });
expect(reported).toHaveBeenLastCalledWith(12);
});
describe('auto-failover toggle', () => {
beforeEach(() => {
selectedPlayer.set(VideoPlayer.Html5Player);
updateSettings.mockReset().mockResolvedValue(undefined);
snackBarOpen.mockClear();
});
it.each([VideoPlayer.MPV, VideoPlayer.VLC, VideoPlayer.EmbeddedMpv])(
'is not offered on %s',
(player) => {
// Those players never raise the playback diagnostic that
// calls onPlaybackFailed(), so the switch could never happen.
selectedPlayer.set(player);
expect(
fixture.componentInstance.autoFailoverSupported()
).toBe(false);
}
);
it.each([
VideoPlayer.Html5Player,
VideoPlayer.VideoJs,
VideoPlayer.ArtPlayer,
])('is offered on %s', (player) => {
selectedPlayer.set(player);
expect(fixture.componentInstance.autoFailoverSupported()).toBe(
true
);
});
it('tells the user when the preference could not be stored', async () => {
// updateSettings patches memory and REJECTS on a failed write, so
// without this the toggle looks saved and silently reverts on the
// next start — and the rejection is unhandled.
updateSettings.mockRejectedValue(new Error('disk full'));
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(snackBarOpen).toHaveBeenCalledWith(
'SETTINGS.SETTINGS_SAVE_FAILED',
'CLOSE',
expect.anything()
);
});
it('stays quiet when the write succeeds', async () => {
fixture.componentInstance.setAutoFailover(true);
await Promise.resolve();
await Promise.resolve();
expect(updateSettings).toHaveBeenCalledWith({
vodAutoFailover: true,
});
expect(snackBarOpen).not.toHaveBeenCalled();
});
});
});
@@ -197,6 +197,7 @@
[matchKind]="multiSource.matchKind()"
[count]="multiSource.alternativeCount()"
[autoFailoverEnabled]="multiSource.autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
(playRequested)="playFromSource($event)"
(pinRequested)="pinSource($event)"
(checkRequested)="checkSource($event)"
@@ -287,6 +288,7 @@
[sourcesAutoFailoverEnabled]="
multiSource.autoFailoverEnabled()
"
[sourcesAutoFailoverSupported]="autoFailoverSupported()"
(sourcesAutoFailoverToggled)="setAutoFailover($event)"
(alternativeSourceRequested)="playFromSource($event)"
(sourcePinRequested)="pinSource($event)"
@@ -1,209 +1,48 @@
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';
import {
configureVodDetailsRouteTestBed,
createVodDetailsRouteStubs,
resetVodDetailsRouteStubs,
silenceRouteLogging,
} from './vod-details-route.harness';
describe('VodDetailsRouteComponent', () => {
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();
let restoreLogging: (() => void) | undefined;
const stubs = createVodDetailsRouteStubs();
const {
activeSession,
addRecentItem,
checkFavoriteStatus,
closeSession,
constructVodStreamUrl,
currentPlaylist,
detailsError,
fetchVodDetailsWithMetadata,
getPlaybackPosition,
isFavorite,
isLoadingDetails,
selectedItem,
setSelectedItem,
toggleFavorite,
vodCategories,
vodStreams,
} = stubs;
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();
restoreLogging = silenceRouteLogging();
resetVodDetailsRouteStubs(stubs);
await configureVodDetailsRouteTestBed(stubs);
fixture = TestBed.createComponent(VodDetailsRouteComponent);
});
afterEach(() => {
consoleDebugSpy?.mockRestore();
consoleWarnSpy?.mockRestore();
restoreLogging?.();
});
it('renders an informational fallback without playback controls when Xtream returns empty metadata', () => {
@@ -40,8 +40,9 @@ import {
} from '@iptvnator/services';
import {
getXtreamVodInfo,
playlistDisplayLabel,
normalizeTitleKeys,
playlistDisplayLabel,
reportsPlaybackFailures,
TmdbEnrichedCastMember,
XtreamCategory,
XtreamVodDetails,
@@ -606,8 +607,31 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
void this.multiSource.check(sourceId);
}
/**
* Only the built-in web players raise a playback diagnostic, so on MPV,
* VLC or Embedded MPV nothing would ever call `onPlaybackFailed()`. The
* toggle is hidden there rather than left promising a switch that cannot
* happen.
*/
readonly autoFailoverSupported = computed(() =>
reportsPlaybackFailures(this.settingsStore.player?.())
);
setAutoFailover(enabled: boolean): void {
this.settingsStore.updateSettings({ vodAutoFailover: enabled });
// `updateSettings` patches memory first and REJECTS if the write
// fails, so without this the toggle looks saved, reverts on restart,
// and the rejection surfaces only as an unhandled promise.
this.settingsStore
.updateSettings({ vodAutoFailover: enabled })
.catch(() =>
this.snackBar.open(
this.translateService.instant(
'SETTINGS.SETTINGS_SAVE_FAILED'
),
this.translateService.instant('CLOSE'),
{ duration: 10000 }
)
);
}
/**
@@ -0,0 +1,223 @@
import { Location } from '@angular/common';
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
VideoPlayer,
XtreamCategory,
XtreamVodDetails,
XtreamVodStream,
} from '@iptvnator/shared/interfaces';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { VodDetailsRouteComponent } from './vod-details-route.component';
/**
* The TestBed every VOD-details route spec needs.
*
* The route pulls in the store, both playback ports, downloads, settings and
* the router, so standing it up costs ~150 lines. Three specs carried their
* own near-identical copy of that, which is how they drifted and how each one
* ran into the file-size rule. One harness, one set of stubs.
*/
/** Every stub the harness installs, so specs can drive and assert on them. */
export function createVodDetailsRouteStubs() {
return {
selectedItem: signal<XtreamVodDetails | null>(null),
isLoadingDetails: signal(false),
detailsError: signal<string | null>(null),
isFavorite: signal(false),
currentPlaylist: signal<{
id: string;
userAgent?: string;
referrer?: string;
origin?: string;
} | null>(null),
vodStreams: signal<Partial<XtreamVodStream>[]>([]),
vodCategories: signal<Partial<XtreamCategory>[]>([]),
fetchVodDetailsWithMetadata: jest.fn(),
checkFavoriteStatus: jest.fn(),
setSelectedItem: jest.fn(),
toggleFavorite: jest.fn(),
constructVodStreamUrl: jest
.fn()
.mockReturnValue('http://example.com/movie/650020.mp4'),
addRecentItem: jest.fn(),
downloads: signal([]),
getPlaybackPosition: jest.fn().mockResolvedValue(null),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
activeSession: signal<unknown>(null),
closeSession: jest.fn(),
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
snackBarOpen: jest.fn(),
locationBack: jest.fn(),
selectedPlayer: signal<VideoPlayer>(VideoPlayer.Html5Player),
updateSettings: jest.fn().mockResolvedValue(undefined),
};
}
export type VodDetailsRouteStubs = ReturnType<
typeof createVodDetailsRouteStubs
>;
/** Back to the state a fresh `beforeEach` expects. */
export function resetVodDetailsRouteStubs(stubs: VodDetailsRouteStubs): void {
stubs.selectedItem.set(null);
stubs.isLoadingDetails.set(false);
stubs.detailsError.set(null);
stubs.isFavorite.set(false);
stubs.currentPlaylist.set(null);
stubs.vodStreams.set([]);
stubs.vodCategories.set([]);
stubs.activeSession.set(null);
stubs.selectedPlayer.set(VideoPlayer.Html5Player);
stubs.updateSettings.mockClear().mockResolvedValue(undefined);
for (const value of Object.values(stubs)) {
if (jest.isMockFunction(value) && value !== stubs.updateSettings) {
value.mockClear();
}
}
}
/**
* Silences the route's own debug chatter without hiding anything else — a
* genuine warning from the component under test must still reach the console.
*/
export function silenceRouteLogging(): () => void {
const consoleDebug = console.debug.bind(console);
const consoleWarn = console.warn.bind(console);
const debugSpy = jest
.spyOn(console, 'debug')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' ||
args[0] === '[VodDetailsPlayback]'
) {
return;
}
consoleDebug(...args);
});
const warnSpy = jest
.spyOn(console, 'warn')
.mockImplementation((...args: unknown[]) => {
if (
args[0] === '[VodDetailsRoute]' &&
args[1] === 'Deferring VOD details init: playlist not ready'
) {
return;
}
consoleWarn(...args);
});
return () => {
debugSpy.mockRestore();
warnSpy.mockRestore();
};
}
export async function configureVodDetailsRouteTestBed(
stubs: VodDetailsRouteStubs
): Promise<void> {
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: stubs.selectedItem,
isLoadingDetails: stubs.isLoadingDetails,
detailsError: stubs.detailsError,
isFavorite: stubs.isFavorite,
currentPlaylist: stubs.currentPlaylist,
vodStreams: stubs.vodStreams,
vodCategories: stubs.vodCategories,
fetchVodDetailsWithMetadata:
stubs.fetchVodDetailsWithMetadata,
checkFavoriteStatus: stubs.checkFavoriteStatus,
setSelectedItem: stubs.setSelectedItem,
toggleFavorite: stubs.toggleFavorite,
constructVodStreamUrl: stubs.constructVodStreamUrl,
addRecentItem: stubs.addRecentItem,
},
},
{
provide: SettingsStore,
useValue: {
theme: signal('dark'),
player: stubs.selectedPlayer,
updateSettings: stubs.updateSettings,
},
},
{
provide: DownloadsService,
useValue: {
isAvailable: signal(false),
downloads: stubs.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: stubs.activeSession,
closeSession: stubs.closeSession,
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getPlaybackPosition: stubs.getPlaybackPosition,
savePlaybackPosition: stubs.savePlaybackPosition,
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: stubs.isEmbeddedPlayer,
openResolvedPlayback: stubs.openResolvedPlayback,
},
},
{ provide: MatSnackBar, useValue: { open: stubs.snackBarOpen } },
{ provide: Location, useValue: { back: stubs.locationBack } },
],
}).compileComponents();
}
@@ -15,6 +15,25 @@ export enum VideoPlayer {
ArtPlayer = 'artplayer',
}
/**
* Whether a failed stream on this player reaches the app at all.
*
* Only the built-in web players raise a playback diagnostic: Embedded MPV has
* its diagnostics suppressed, and MPV/VLC play outside the app entirely. On
* those, anything keyed off a playback failure — VOD auto-failover above all —
* can never fire, so offering the control there promises a feature that does
* nothing.
*/
export function reportsPlaybackFailures(
player: VideoPlayer | null | undefined
): boolean {
return (
player === VideoPlayer.VideoJs ||
player === VideoPlayer.Html5Player ||
player === VideoPlayer.ArtPlayer
);
}
export enum StartupBehavior {
FirstView = 'first-view',
RestoreLastView = 'restore-last-view',
@@ -39,6 +39,7 @@
[title]="title()"
[matchKind]="matchKind()"
[autoFailoverEnabled]="autoFailoverEnabled()"
[autoFailoverSupported]="autoFailoverSupported()"
[resumeLabel]="resumeLabel()"
[showPin]="showPin()"
(playRequested)="onPlay($event)"
@@ -42,6 +42,8 @@ export class VodSourcesChipComponent {
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
/** See `VodSourcesMenuComponent.autoFailoverSupported`. */
readonly autoFailoverSupported = input(true);
readonly resumeLabel = input<string | null>(null);
readonly showPin = input(true);
/** Falls back to the number of sources when the host does not override it. */
@@ -120,16 +120,20 @@
}
</div>
<div class="sources-menu__footer">
<span class="sources-menu__footer-label">{{
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
}}</span>
<mat-slide-toggle
class="sources-menu__toggle"
[checked]="autoFailoverEnabled()"
(change)="onAutoFailoverChange($event)"
(click)="$event.stopPropagation()"
[attr.aria-label]="'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate"
/>
</div>
@if (autoFailoverSupported()) {
<div class="sources-menu__footer">
<span class="sources-menu__footer-label">{{
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
}}</span>
<mat-slide-toggle
class="sources-menu__toggle"
[checked]="autoFailoverEnabled()"
(change)="onAutoFailoverChange($event)"
(click)="$event.stopPropagation()"
[attr.aria-label]="
'PORTALS.MULTI_SOURCE.AUTO_FAILOVER' | translate
"
/>
</div>
}
</div>
@@ -243,6 +243,22 @@ describe('VodSourcesMenuComponent', () => {
expect(emitted).toEqual([true]);
});
it('drops the footer switch on a player that cannot report failures', () => {
// MPV, VLC and Embedded MPV never raise the playback diagnostic that
// drives failover, so the switch there promises something that can
// never happen.
render([createSource()]);
fixture.componentRef.setInput('autoFailoverSupported', false);
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.sources-menu__toggle'))
).toBeNull();
expect(fixture.nativeElement.textContent).not.toContain(
'Auto-switch on error'
);
});
it('shows the match kind in the header, or the resume label when given', () => {
fixture.componentRef.setInput('sources', [createSource()]);
fixture.componentRef.setInput('matchKind', 'tmdb');
@@ -56,6 +56,12 @@ export class VodSourcesMenuComponent {
readonly title = input('');
readonly matchKind = input<VodSourceMatchKind>('title-year');
readonly autoFailoverEnabled = input(false);
/**
* False on players that never report a playback failure (MPV, VLC,
* Embedded MPV). The toggle would then promise a switch that can never
* happen, so it is not offered at all.
*/
readonly autoFailoverSupported = input(true);
/**
* In-player variant: replaces the match kind with e.g.
* "timecode will be kept · 0:42:18". Already formatted by the host.
@@ -35,6 +35,9 @@
[matchKind]="sourcesMatchKind()"
[resumeLabel]="sourcesResumeLabel()"
[autoFailoverEnabled]="sourcesAutoFailoverEnabled()"
[autoFailoverSupported]="
sourcesAutoFailoverSupported()
"
(playRequested)="
alternativeSourceRequested.emit($event)
"
@@ -221,6 +221,8 @@ export class PortalInlinePlayerComponent {
* nothing.
*/
readonly sourcesAutoFailoverEnabled = input(false);
/** See `VodSourcesMenuComponent.autoFailoverSupported`. */
readonly sourcesAutoFailoverSupported = input(true);
readonly sourcesAutoFailoverToggled = output<boolean>();
readonly sourcePinRequested = output<string>();
readonly sourceCheckRequested = output<string>();
@@ -2,7 +2,10 @@ import { Component, computed, effect, inject, input, output, signal, untracked }
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { SafePipe } from '@iptvnator/pipes';
import { PORTAL_EXTERNAL_PLAYBACK } from '@iptvnator/portal/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
createExternalPlaybackButtonState,
} from '@iptvnator/portal/shared/util';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -198,76 +201,17 @@ export class VodDetailsComponent {
readonly isDownloading = this.downloadState.isDownloading;
readonly isPausedDownload = this.downloadState.isPausedDownload;
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback();
const item = this.item();
if (
!session?.contentInfo ||
session.status === 'closed' ||
session.status === 'error'
) {
return null;
}
const contentInfo = session.contentInfo;
if (
contentInfo.playlistId !== item.playlistId ||
contentInfo.contentType !== 'vod' ||
contentInfo.contentXtreamId !== getVodNumericId(item)
) {
return null;
}
return session;
});
readonly externalPrimaryLabel = computed(() => {
const session = this.matchedExternalPlayback();
if (!session) {
return null;
}
const player = session.player.toUpperCase();
switch (session.status) {
case 'launching':
return `Opening in ${player}...`;
case 'opened':
case 'playing':
return `Stop ${player}`;
default:
return null;
}
});
readonly externalPrimaryIcon = computed(() => {
const session = this.matchedExternalPlayback();
switch (session?.status) {
case 'launching':
return 'hourglass_top';
case 'opened':
case 'playing':
return 'stop_circle';
default:
return 'play_arrow';
}
});
readonly isExternalLaunchPending = computed(
() => this.matchedExternalPlayback()?.status === 'launching'
);
readonly isExternalStopAction = computed(() => {
const status = this.matchedExternalPlayback()?.status;
return status === 'opened' || status === 'playing';
});
readonly externalPrimaryButtonState = computed(() => {
if (this.isExternalLaunchPending()) {
return 'launching';
}
return this.isExternalStopAction() ? 'stop' : 'idle';
private readonly externalButton = createExternalPlaybackButtonState({
session: this.externalPlayback,
playlistId: computed(() => this.item().playlistId),
contentId: computed(() => getVodNumericId(this.item())),
});
readonly matchedExternalPlayback = this.externalButton.matchedSession;
readonly externalPrimaryLabel = this.externalButton.primaryLabel;
readonly externalPrimaryIcon = this.externalButton.primaryIcon;
readonly isExternalLaunchPending = this.externalButton.isLaunchPending;
readonly isExternalStopAction = this.externalButton.isStopAction;
readonly externalPrimaryButtonState = this.externalButton.buttonState;
// ============ Actions ============
@@ -13,15 +13,15 @@ const PUBLISH_ACTION_ALLOWLIST = Object.freeze([
PINNED_UPLOAD_ARTIFACT_ACTION,
]);
const BUILD_ACTION_ALLOWLIST = Object.freeze([
'actions/cache/restore@v4',
'actions/cache/save@v4',
'actions/cache@v4',
'actions/cache/restore@v6',
'actions/cache/save@v6',
'actions/cache@v6',
'actions/checkout@v7',
'actions/download-artifact@v8',
'actions/setup-node@v4',
'actions/setup-node@v7',
'actions/upload-artifact@v7',
'pnpm/action-setup@v4',
'softprops/action-gh-release@v2',
'softprops/action-gh-release@v3',
]);
const VERIFY_JOB_ID = 'verify-snap';
const PUBLISH_JOB_ID = 'publish-snap';