diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index efc62626e..43158d2d8 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -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/') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14f32433b..af603b71a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 47295f5d7..97fe4ff4e 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -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' diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index abd9d577a..3d1f8e0e6 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -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' diff --git a/CLAUDE.md b/CLAUDE.md index 89dab68e4..8cf6b45e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts b/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts new file mode 100644 index 000000000..767b8c72b --- /dev/null +++ b/apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts @@ -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); + }); +}); diff --git a/apps/electron-backend-e2e/src/data-dir-reaper.ts b/apps/electron-backend-e2e/src/data-dir-reaper.ts new file mode 100644 index 000000000..1ec460035 --- /dev/null +++ b/apps/electron-backend-e2e/src/data-dir-reaper.ts @@ -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. + } + } +} diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 12424acd7..80f4f119a 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -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({ 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); diff --git a/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts new file mode 100644 index 000000000..82cbdc36e --- /dev/null +++ b/apps/web/src/app/settings/settings-playback-section.auto-failover.spec.ts @@ -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; + + 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), + }); +} diff --git a/apps/web/src/app/settings/settings-playback-section.component.html b/apps/web/src/app/settings/settings-playback-section.component.html index 25aecaff2..453179207 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.html +++ b/apps/web/src/app/settings/settings-playback-section.component.html @@ -133,7 +133,9 @@ } - @if (supportsVodMultiSource()) { + + @if (supportsVodMultiSource() && isWebPlayerSelected()) {

diff --git a/apps/web/src/app/settings/settings-playback-section.component.spec.ts b/apps/web/src/app/settings/settings-playback-section.component.spec.ts index 909d55fb4..8ad079ce4 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.spec.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.spec.ts @@ -394,5 +394,6 @@ function createForm(player = VideoPlayer.VideoJs): FormGroup { vlcPlayerArguments: new FormControl(''), vlcReuseInstance: new FormControl(false), recordingFolder: new FormControl(''), + vodAutoFailover: new FormControl(false), }); } diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 26858890b..52293eb2a 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -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(); 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 { diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 69d76e9f8..0fd04eda0 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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. diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 425e617b2..f661cf4d9 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts new file mode 100644 index 000000000..62cc47b40 --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -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 { + 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(initial); + const api = createExternalPlaybackButtonState({ + session: current, + playlistId: signal('playlist-1'), + contentId: signal(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) + ); + + 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) + ); + + 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) + ); + + 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(session()), + playlistId: signal('playlist-1'), + contentId: signal(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( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal({ + 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( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal(null), + }); + + expect(api.matchedSession()).toBeNull(); + }); + + it('still checks the content type of an owned alternative', () => { + const api = createExternalPlaybackButtonState({ + session: signal( + session({ + contentInfo: { + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'episode', + }, + }) + ), + playlistId: signal('playlist-1'), + contentId: signal(42), + alsoOwns: signal({ + playlistId: 'playlist-2', + contentXtreamId: 991, + contentType: 'vod', + }), + }); + + expect(api.matchedSession()).toBeNull(); + }); +}); diff --git a/libs/portal/shared/util/src/lib/external-playback-button-state.ts b/libs/portal/shared/util/src/lib/external-playback-button-state.ts new file mode 100644 index 000000000..916c2c784 --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -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; + /** Playlist the detail view is showing. */ + playlistId: Signal; + /** Provider-side id of the item on screen. */ + contentId: Signal; + /** 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; +} + +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; + primaryLabel: Signal; + primaryIcon: Signal; + isLaunchPending: Signal; + isStopAction: Signal; + buttonState: Signal; +} + +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(() => { + if (isLaunchPending()) { + return 'launching'; + } + return isStopAction() ? 'stop' : 'idle'; + }); + + return { + matchedSession, + primaryLabel, + primaryIcon, + isLaunchPending, + isStopAction, + buttonState, + }; +} diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts new file mode 100644 index 000000000..2b44dc2b0 --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts @@ -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(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); + }); + }); +}); diff --git a/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts new file mode 100644 index 000000000..eb36492e7 --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts @@ -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; + 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; + }, + }; +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts index 40905051e..4edc8b0f4 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-catalog-detail.component.ts @@ -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( 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 { - 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; diff --git a/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts new file mode 100644 index 000000000..06ee93e7e --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts @@ -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; +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; + fetchMovieFileId: (id: string) => Promise; + fetchLinkToPlay: ( + portalUrl: string, + macAddress: string, + cmd: string + ) => Promise; +} + +export async function startStalkerVodDownload( + item: VodDetailsItem, + deps: StalkerVodDownloadDeps +): Promise { + 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 { + 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; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index b5c39afa6..4c4df91b8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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(null); - private lastSaveTime = 0; - /** Cleared on every start; latches once playback reaches startTime. */ - private resumeSettled = false; + private readonly bindings = signal( + null + ); readonly inlinePlayback = signal(null); readonly vodPlaybackPosition = signal(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; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts index 7e88dc048..8a418cac8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -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; - let consoleDebugSpy: jest.SpyInstance | undefined; - let consoleWarnSpy: jest.SpyInstance | undefined; - const selectedItem = signal(null); - const isLoadingDetails = signal(false); - const detailsError = signal(null); - const isFavorite = signal(false); - const currentPlaylist = signal<{ - id: string; - userAgent?: string; - referrer?: string; - origin?: string; - } | null>(null); - const vodStreams = signal[]>([]); - const vodCategories = signal[]>([]); - const fetchVodDetailsWithMetadata = jest.fn(); - const checkFavoriteStatus = jest.fn(); - const setSelectedItem = jest.fn(); - const toggleFavorite = jest.fn(); - const constructVodStreamUrl = jest - .fn() - .mockReturnValue('http://example.com/movie/650020.mp4'); - const addRecentItem = jest.fn(); - const downloads = signal([]); - const getPlaybackPosition = jest.fn().mockResolvedValue(null); - const activeSession = signal(null); - const closeSession = jest.fn(); + 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?.(); }); /** diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index 977eae369..02f96c8d9 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -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; - let consoleDebugSpy: jest.SpyInstance | undefined; - let consoleWarnSpy: jest.SpyInstance | undefined; - const selectedItem = signal(null); - const isLoadingDetails = signal(false); - const detailsError = signal(null); - const isFavorite = signal(false); - const currentPlaylist = signal<{ - id: string; - userAgent?: string; - referrer?: string; - origin?: string; - } | null>(null); - const vodStreams = signal[]>([]); - const vodCategories = signal[]>([]); - const fetchVodDetailsWithMetadata = jest.fn(); - const checkFavoriteStatus = jest.fn(); - const setSelectedItem = jest.fn(); - const toggleFavorite = jest.fn(); - const constructVodStreamUrl = jest - .fn() - .mockReturnValue('http://example.com/movie/650020.mp4'); - const addRecentItem = jest.fn(); - const downloads = signal([]); - const getPlaybackPosition = jest.fn().mockResolvedValue(null); - const activeSession = signal(null); - const closeSession = jest.fn(); + 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(); + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index b61cc70ea..700b3dc08 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -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)" diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts index 319386657..d012d5374 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.spec.ts @@ -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; - let consoleDebugSpy: jest.SpyInstance | undefined; - let consoleWarnSpy: jest.SpyInstance | undefined; - const selectedItem = signal(null); - const isLoadingDetails = signal(false); - const detailsError = signal(null); - const isFavorite = signal(false); - const currentPlaylist = signal<{ - id: string; - userAgent?: string; - referrer?: string; - origin?: string; - } | null>(null); - const vodStreams = signal[]>([]); - const vodCategories = signal[]>([]); - const fetchVodDetailsWithMetadata = jest.fn(); - const checkFavoriteStatus = jest.fn(); - const setSelectedItem = jest.fn(); - const toggleFavorite = jest.fn(); - const constructVodStreamUrl = jest - .fn() - .mockReturnValue('http://example.com/movie/650020.mp4'); - const addRecentItem = jest.fn(); - const downloads = signal([]); - const getPlaybackPosition = jest.fn().mockResolvedValue(null); - const activeSession = signal(null); - const closeSession = jest.fn(); + 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', () => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 6185bfa79..79247e8b6 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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 } + ) + ); } /** diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts new file mode 100644 index 000000000..66c0a73ae --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -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(null), + isLoadingDetails: signal(false), + detailsError: signal(null), + isFavorite: signal(false), + currentPlaylist: signal<{ + id: string; + userAgent?: string; + referrer?: string; + origin?: string; + } | null>(null), + vodStreams: signal[]>([]), + vodCategories: signal[]>([]), + 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(null), + closeSession: jest.fn(), + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + snackBarOpen: jest.fn(), + locationBack: jest.fn(), + selectedPlayer: signal(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 { + 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(); +} diff --git a/libs/shared/interfaces/src/lib/settings.interface.ts b/libs/shared/interfaces/src/lib/settings.interface.ts index 2cc0851f2..1091391ea 100644 --- a/libs/shared/interfaces/src/lib/settings.interface.ts +++ b/libs/shared/interfaces/src/lib/settings.interface.ts @@ -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', diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html index 42f39d4e6..29f1c528a 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.html @@ -39,6 +39,7 @@ [title]="title()" [matchKind]="matchKind()" [autoFailoverEnabled]="autoFailoverEnabled()" + [autoFailoverSupported]="autoFailoverSupported()" [resumeLabel]="resumeLabel()" [showPin]="showPin()" (playRequested)="onPlay($event)" diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts index 34de8623d..03165f7ad 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-chip.component.ts @@ -42,6 +42,8 @@ export class VodSourcesChipComponent { readonly title = input(''); readonly matchKind = input('title-year'); readonly autoFailoverEnabled = input(false); + /** See `VodSourcesMenuComponent.autoFailoverSupported`. */ + readonly autoFailoverSupported = input(true); readonly resumeLabel = input(null); readonly showPin = input(true); /** Falls back to the number of sources when the host does not override it. */ diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html index 53c3016ec..1920d6d4d 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.html @@ -120,16 +120,20 @@ }

- + @if (autoFailoverSupported()) { + + }
diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts index 2390fb74c..893d8873e 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts @@ -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'); diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts index cf817e4d0..b09b73385 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.ts @@ -56,6 +56,12 @@ export class VodSourcesMenuComponent { readonly title = input(''); readonly matchKind = input('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. diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html index a70619c93..a9f68ccf6 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.html @@ -35,6 +35,9 @@ [matchKind]="sourcesMatchKind()" [resumeLabel]="sourcesResumeLabel()" [autoFailoverEnabled]="sourcesAutoFailoverEnabled()" + [autoFailoverSupported]=" + sourcesAutoFailoverSupported() + " (playRequested)=" alternativeSourceRequested.emit($event) " diff --git a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts index 4f5d04889..40f6b70e0 100644 --- a/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts +++ b/libs/ui/playback/src/lib/portal-inline-player/portal-inline-player.component.ts @@ -221,6 +221,8 @@ export class PortalInlinePlayerComponent { * nothing. */ readonly sourcesAutoFailoverEnabled = input(false); + /** See `VodSourcesMenuComponent.autoFailoverSupported`. */ + readonly sourcesAutoFailoverSupported = input(true); readonly sourcesAutoFailoverToggled = output(); readonly sourcePinRequested = output(); readonly sourceCheckRequested = output(); diff --git a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts index 030f7a666..e6ff8546a 100644 --- a/libs/ui/playback/src/lib/vod-details/vod-details.component.ts +++ b/libs/ui/playback/src/lib/vod-details/vod-details.component.ts @@ -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 ============ diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 5fc55b065..be45319a4 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -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';