From 553f45dedce672b843b927ee20a6f45328eca127 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:12:18 +0200 Subject: [PATCH 1/5] chore(deps): bump actions/cache from 4 to 6 (#1281) * chore(deps): bump actions/cache from 4 to 6 Bumps [actions/cache](https://github.com/actions/cache) from 4 to 6. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(ci): allow actions/cache v6 in the Snap workflow policy The Snap supply-chain policy test pins the exact major of every action the build workflow may use, so bumping actions/cache in the workflow without updating BUILD_ACTION_ALLOWLIST fails publish-snap-workflow.test.mjs. Co-Authored-By: Claude Opus 5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5 --- .github/workflows/build-and-make.yaml | 6 +++--- tools/packaging/snap-workflow-policy.test-helpers.mjs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index efc62626e..3fcb30ebb 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -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 @@ -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 diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 5fc55b065..1d1020c80 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -13,9 +13,9 @@ 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', From 55f68e73c8c28f818d7c0d9e88d2989215ac1944 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:00 +0200 Subject: [PATCH 2/5] chore(deps): bump actions/setup-node from 4 to 7 (#1285) * chore(deps): bump actions/setup-node from 4 to 7 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 7. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(ci): allow actions/setup-node v7 in the Snap workflow policy The Snap supply-chain policy test pins the exact major of every action the build workflow may use, so bumping actions/setup-node in the workflow without updating BUILD_ACTION_ALLOWLIST fails publish-snap-workflow.test.mjs. Co-Authored-By: Claude Opus 5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5 --- .github/workflows/build-and-make.yaml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/deploy-website.yml | 2 +- .github/workflows/e2e-tests.yaml | 4 ++-- tools/packaging/snap-workflow-policy.test-helpers.mjs | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 3fcb30ebb..12de042a2 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' @@ -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' 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/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 1d1020c80..2ca4fc68c 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -18,7 +18,7 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ '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', From c637a0520e6d675611041525b84e4aec8edeae79 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:13:12 +0200 Subject: [PATCH 3/5] chore(deps): bump softprops/action-gh-release from 2 to 3 (#1284) * chore(deps): bump softprops/action-gh-release from 2 to 3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * chore(ci): allow softprops/action-gh-release v3 in the Snap workflow policy The Snap supply-chain policy test pins the exact major of every action the build workflow may use, so bumping softprops/action-gh-release in the workflow without updating BUILD_ACTION_ALLOWLIST fails publish-snap-workflow.test.mjs. Co-Authored-By: Claude Opus 5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray Co-authored-by: Claude Opus 5 --- .github/workflows/build-and-make.yaml | 2 +- tools/packaging/snap-workflow-policy.test-helpers.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 12de042a2..43158d2d8 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -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/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 2ca4fc68c..be45319a4 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -21,7 +21,7 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ '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'; From 72f8cebd2e7e8582a597b0645eee4c598a35dc21 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:41:24 +0200 Subject: [PATCH 4/5] fix(e2e): reap data directories abandoned by earlier runs (#1296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Every teardown that lost that race leaked a database and user-data tree on developer machines and long-lived runners, invisibly, while CI stayed green. Sweeps leftover `iptvnator-electron-e2e-*` directories once per run, before the first one is created. Ownership is settled by pid rather than age: each run records its pid and the sweep asks the OS via `process.kill(pid, 0)`. - A live owner is kept, so a concurrent suite is never collected — this repo is routinely checked out into several worktrees at once. Age cannot answer this: writes land under `databases/` and `user-data/`, which never refreshes the root's mtime, so a run paused in a debugger looks arbitrarily old. - A dead owner is collected immediately. - An undeterminable owner (missing, empty or malformed marker) falls back to a 24h cutoff. The marker is published via rename so a half-written file cannot bypass that guard. - A live-looking owner past a week is collected anyway, since the OS recycles pids and a stranger inheriting one would otherwise pin the directory forever. Covered by a 10-test spec running on Linux, macOS and Windows, since `process.kill(pid, 0)` semantics are platform-specific. Each behaviour was verified to fail against the preceding implementation. Co-Authored-By: Claude Opus 5 --- .../src/data-dir-reaper.e2e.ts | 204 ++++++++++++++++++ .../src/data-dir-reaper.ts | 123 +++++++++++ .../src/electron-test-fixtures.ts | 14 +- 3 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/data-dir-reaper.e2e.ts create mode 100644 apps/electron-backend-e2e/src/data-dir-reaper.ts 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); From 80af9257a074d840927c748ebc42680c9edbf20c Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:50:59 +0200 Subject: [PATCH 5/5] refactor(portals): share external-button and position-writer logic (#1298) The Xtream and Stalker VOD detail views each carried a private copy of two behaviours: deriving the Play/Stop button state from the active external (MPV/VLC) session, and throttled persistence of the inline player position. A Play button or a resume point that behaves differently per portal is the kind of divergence users notice, so both now read from one implementation. Extracts `createExternalPlaybackButtonState` and `createInlinePlaybackPositionWriter` into portal/shared/util, and lifts the Stalker VOD download errand into its own helper. Behaviour is unchanged; the shared helpers are deliberately identical to the copies they replace. This also brings both hosts back under the 400-line ESLint limit, neither of which was baselined: vod-details.component.ts 389 -> 333 stalker-catalog-detail.component 394 -> 325 vod-details-playback.service.ts 345 -> 275 Co-authored-by: Claude Opus 5 --- libs/portal/shared/util/src/index.ts | 2 + .../external-playback-button-state.spec.ts | 138 ++++++++++++++++++ .../src/lib/external-playback-button-state.ts | 121 +++++++++++++++ .../inline-playback-position-writer.spec.ts | 113 ++++++++++++++ .../lib/inline-playback-position-writer.ts | 70 +++++++++ .../stalker-catalog-detail.component.ts | 111 +++----------- .../stalker-vod-download.ts | 118 +++++++++++++++ .../vod-details-playback.service.ts | 112 ++++---------- .../lib/vod-details/vod-details.component.ts | 84 ++--------- 9 files changed, 622 insertions(+), 247 deletions(-) create mode 100644 libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts create mode 100644 libs/portal/shared/util/src/lib/external-playback-button-state.ts create mode 100644 libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts create mode 100644 libs/portal/shared/util/src/lib/inline-playback-position-writer.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-catalog-detail/stalker-vod-download.ts 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..834a6200a --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.spec.ts @@ -0,0 +1,138 @@ +import { signal } from '@angular/core'; +import type { ExternalPlayerSession } 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(); + }); +}); 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..ea97ddfb6 --- /dev/null +++ b/libs/portal/shared/util/src/lib/external-playback-button-state.ts @@ -0,0 +1,121 @@ +import { computed, type Signal } from '@angular/core'; +import type { ExternalPlayerSession } 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'; +} + +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.playlistId !== config.playlistId() || + info.contentType !== contentType || + info.contentXtreamId !== config.contentId() + ) { + return null; + } + + return session; + }); + + 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..16b3b023c --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.spec.ts @@ -0,0 +1,113 @@ +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, + }) + ); + }); +}); 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..049078094 --- /dev/null +++ b/libs/portal/shared/util/src/lib/inline-playback-position-writer.ts @@ -0,0 +1,70 @@ +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. + * + * Deliberately behaviour-identical to the two implementations it replaces. + */ + +/** The player fires ~4x/second; persisting that often would hammer SQLite. */ +const DEFAULT_THROTTLE_MS = 15000; + +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 { + handleTimeUpdate(event: { currentTime: number; duration: number }): void; + /** Clears the throttle so the next update is written immediately. */ + reset(): void; +} + +export function createInlinePlaybackPositionWriter( + config: InlinePlaybackPositionWriterConfig +): InlinePlaybackPositionWriter { + const throttleMs = config.throttleMs ?? DEFAULT_THROTTLE_MS; + let lastSaveTime = 0; + + return { + handleTimeUpdate(event) { + const playback = config.playback(); + // Without contentInfo there is no key to store the position under. + if (!playback?.contentInfo) { + return; + } + + const now = Date.now(); + if (now - lastSaveTime <= throttleMs) { + return; + } + 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); + }, + + reset() { + lastSaveTime = 0; + }, + }; +} 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 c81f6b019..1b82f26c1 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'; @@ -52,79 +54,21 @@ export class VodDetailsPlaybackService { private readonly bindings = signal( null ); - private lastSaveTime = 0; readonly inlinePlayback = signal(null); readonly vodPlaybackPosition = signal(null); - readonly matchedExternalPlayback = computed(() => { - const session = this.externalPlayback.activeSession(); - const vodId = this.bindings()?.vodId(); - const playlistId = this.xtreamStore.currentPlaylist()?.id; - - if ( - !session?.contentInfo || - !playlistId || - session.status === 'error' || - session.status === 'closed' - ) { - return null; - } - - const contentInfo = session.contentInfo; - if ( - contentInfo.playlistId !== playlistId || - contentInfo.contentType !== 'vod' || - contentInfo.contentXtreamId !== vodId - ) { - 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.activeSession, + playlistId: computed(() => this.xtreamStore.currentPlaylist()?.id), + contentId: computed(() => this.bindings()?.vodId()), }); + 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()) ); @@ -273,30 +217,24 @@ export class VodDetailsPlaybackService { closeInlinePlayer(): void { this.inlinePlayback.set(null); - this.lastSaveTime = 0; + 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), + }); + handleInlineTimeUpdate(event: { currentTime: number; duration: number; }): void { - const playback = this.inlinePlayback(); - if (!playback?.contentInfo) return; - - const now = Date.now(); - if (now - this.lastSaveTime <= 15000) return; - - 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); + this.positionWriter.handleTimeUpdate(event); } handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { @@ -325,7 +263,7 @@ export class VodDetailsPlaybackService { } private startPlayback(playback: ResolvedPortalPlayback): void { - this.lastSaveTime = 0; + this.positionWriter.reset(); if (this.portalPlayer.isEmbeddedPlayer()) { this.inlinePlayback.set(playback); return; 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 ============